@engineeros/connector 0.13.4 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,41 +1,41 @@
1
- {
2
- "name": "@engineeros/connector",
3
- "version": "0.13.4",
4
- "description": "Connect a local coding agent to EngineerOS, using ACP when supported.",
5
- "private": false,
6
- "type": "module",
7
- "license": "UNLICENSED",
8
- "files": [
9
- "bin",
10
- "src",
11
- "README.md"
12
- ],
13
- "bin": {
14
- "engineeros-connector": "bin/engineeros-connector.mjs"
15
- },
16
- "scripts": {
17
- "start": "node ./bin/engineeros-connector.mjs",
18
- "prepublishOnly": "node ./scripts/release.mjs check",
19
- "release:patch": "node ./scripts/release.mjs patch",
20
- "test": "node --test --test-concurrency=1",
21
- "type-check": "node --check ./bin/engineeros-connector.mjs && node --check ./scripts/release.mjs && node --check ./src/acp-client.mjs && node --check ./src/agent-harness.mjs && node --check ./src/agent-registry.mjs && node --check ./src/capabilities.mjs && node --check ./src/cli-args.mjs && node --check ./src/codex-app-server.mjs && node --check ./src/config.mjs && node --check ./src/connection.mjs && node --check ./src/mcp-server.mjs && node --check ./src/runner.mjs"
22
- },
23
- "engines": {
24
- "node": ">=22"
25
- },
26
- "repository": {
27
- "type": "git",
28
- "url": "git+https://github.com/vinpuli/engineerosv2.git",
29
- "directory": "packages/engineeros-connector"
30
- },
31
- "bugs": {
32
- "url": "https://github.com/vinpuli/engineerosv2/issues"
33
- },
34
- "publishConfig": {
35
- "access": "public"
36
- },
37
- "dependencies": {
38
- "@agentclientprotocol/sdk": "1.3.0",
39
- "zod": "3.25.76"
40
- }
41
- }
1
+ {
2
+ "name": "@engineeros/connector",
3
+ "version": "0.14.2",
4
+ "description": "Connect a local coding agent to EngineerOS, using ACP when supported.",
5
+ "private": false,
6
+ "type": "module",
7
+ "license": "UNLICENSED",
8
+ "files": [
9
+ "bin",
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "bin": {
14
+ "engineeros-connector": "bin/engineeros-connector.mjs"
15
+ },
16
+ "scripts": {
17
+ "start": "node ./bin/engineeros-connector.mjs",
18
+ "prepublishOnly": "node ./scripts/release.mjs check",
19
+ "release:patch": "node ./scripts/release.mjs patch",
20
+ "test": "node --test --test-concurrency=1",
21
+ "type-check": "node --check ./bin/engineeros-connector.mjs && node --check ./scripts/release.mjs && node --check ./src/acp-client.mjs && node --check ./src/agent-harness.mjs && node --check ./src/agent-registry.mjs && node --check ./src/capabilities.mjs && node --check ./src/cli-args.mjs && node --check ./src/codex-app-server.mjs && node --check ./src/config.mjs && node --check ./src/connection.mjs && node --check ./src/mcp-server.mjs && node --check ./src/runner.mjs"
22
+ },
23
+ "engines": {
24
+ "node": ">=22"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/vinpuli/engineerosv2.git",
29
+ "directory": "packages/engineeros-connector"
30
+ },
31
+ "bugs": {
32
+ "url": "https://github.com/vinpuli/engineerosv2/issues"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "dependencies": {
38
+ "@agentclientprotocol/sdk": "1.3.0",
39
+ "zod": "3.25.76"
40
+ }
41
+ }
@@ -5,7 +5,7 @@ import * as acp from "@agentclientprotocol/sdk";
5
5
  const RUNTIME_IDLE_MS = 30 * 60 * 1_000;
6
6
  const runtimes = new Map();
7
7
 
8
- export function permissionOutcome(options = [], allow = false) {
8
+ export function permissionOutcome(options = [], allow = false) {
9
9
  if (!allow) return { outcome: { outcome: "cancelled" } };
10
10
  const selected =
11
11
  options.find((option) => option.kind === "allow_once") ??
@@ -13,9 +13,9 @@ export function permissionOutcome(options = [], allow = false) {
13
13
  return selected
14
14
  ? { outcome: { outcome: "selected", optionId: selected.optionId } }
15
15
  : { outcome: { outcome: "cancelled" } };
16
- }
17
-
18
- export function launchAcpAgent(
16
+ }
17
+
18
+ export function launchAcpAgent(
19
19
  workspace,
20
20
  prompt,
21
21
  config,
@@ -39,7 +39,7 @@ export function launchAcpAgent(
39
39
  if (runtimeKey) runtimes.set(runtimeKey, runtime);
40
40
  }
41
41
  const sessionKey = options.sessionKey || `isolated-${crypto.randomUUID()}`;
42
- const completed = runtime
42
+ const completed = runtime
43
43
  .prompt({
44
44
  sessionKey,
45
45
  prompt,
@@ -49,10 +49,10 @@ export function launchAcpAgent(
49
49
  callbacks,
50
50
  })
51
51
  .finally(async () => {
52
- if (!persistent) await runtime.dispose();
53
- });
54
- runtime.child.engineerOsCancel = () => runtime.dispose();
55
- return {
52
+ if (!persistent) await runtime.dispose();
53
+ });
54
+ runtime.child.engineerOsCancel = () => runtime.dispose();
55
+ return {
56
56
  child: runtime.child,
57
57
  completed,
58
58
  cancel: () => runtime.cancel(sessionKey),
@@ -115,22 +115,23 @@ class AcpRuntime {
115
115
  this.onClose = onClose;
116
116
  this.sessions = new Map();
117
117
  this.turns = new Map();
118
- this.stderr = "";
119
- this.disposed = false;
120
- this.idleTimer = null;
121
- this.child = spawn(
122
- config.agent_command,
123
- Array.isArray(config.agent_args) ? config.agent_args : [],
124
- {
125
- cwd: workspace,
126
- env: { ...process.env, ...(config.agent_env || {}) },
127
- windowsHide: true,
128
- shell:
129
- process.platform === "win32" &&
130
- /\.(cmd|bat)$/i.test(config.agent_command),
131
- stdio: ["pipe", "pipe", "pipe"],
132
- },
133
- );
118
+ this.stderr = "";
119
+ this.disposed = false;
120
+ this.disposePromise = null;
121
+ this.idleTimer = null;
122
+ this.child = spawn(
123
+ config.agent_command,
124
+ Array.isArray(config.agent_args) ? config.agent_args : [],
125
+ {
126
+ cwd: workspace,
127
+ env: { ...process.env, ...(config.agent_env || {}) },
128
+ windowsHide: true,
129
+ shell:
130
+ process.platform === "win32" &&
131
+ /\.(cmd|bat)$/i.test(config.agent_command),
132
+ stdio: ["pipe", "pipe", "pipe"],
133
+ },
134
+ );
134
135
  this.child.stderr.setEncoding("utf8");
135
136
  this.child.stderr.on("data", (chunk) => {
136
137
  this.stderr = `${this.stderr}${chunk}`.slice(-4_000);
@@ -158,11 +159,13 @@ class AcpRuntime {
158
159
  )
159
160
  .connect(stream);
160
161
  this.context = this.connection.agent;
161
- this.ready = this.initialize();
162
- void this.ready.catch(() => {
163
- void this.dispose();
164
- });
165
- this.child.once("exit", () => this.closed());
162
+ this.ready = this.initialize();
163
+ void this.ready.catch(() => {
164
+ void this.dispose();
165
+ });
166
+ this.child.stdout.once("end", () => this.closed());
167
+ this.child.stdout.once("close", () => this.closed());
168
+ this.child.once("exit", () => this.closed());
166
169
  void this.connection.closed.then(
167
170
  () => this.closed(),
168
171
  () => this.closed(),
@@ -358,13 +361,17 @@ class AcpRuntime {
358
361
  });
359
362
  }
360
363
 
361
- isRunning() {
362
- return (
363
- !this.disposed &&
364
- this.child.exitCode === null &&
365
- !this.connection.signal.aborted
366
- );
367
- }
364
+ isRunning() {
365
+ return (
366
+ !this.disposed &&
367
+ this.childIsRunning() &&
368
+ !this.connection.signal.aborted
369
+ );
370
+ }
371
+
372
+ childIsRunning() {
373
+ return this.child.exitCode === null && this.child.signalCode === null;
374
+ }
368
375
 
369
376
  scheduleIdleDisposal() {
370
377
  if (this.turns.size || this.disposed) return;
@@ -386,32 +393,44 @@ class AcpRuntime {
386
393
  );
387
394
  }
388
395
 
389
- closed() {
390
- if (this.disposed) return;
391
- this.disposed = true;
392
- this.clearIdleTimer();
393
- this.onClose?.();
394
- }
395
-
396
- async dispose() {
397
- if (this.disposed) return;
398
- this.disposed = true;
399
- this.clearIdleTimer();
400
- this.onClose?.();
401
- this.connection.close();
402
- if (this.child.exitCode === null) {
403
- this.child.kill("SIGTERM");
404
- await Promise.race([
405
- new Promise((resolve) => this.child.once("exit", resolve)),
406
- new Promise((resolve) => setTimeout(resolve, 500)),
407
- ]);
408
- }
409
- if (this.child.exitCode === null) this.child.kill("SIGKILL");
410
- this.child.stdin.destroy();
411
- this.child.stdout.destroy();
412
- this.child.stderr.destroy();
413
- }
414
- }
396
+ closed() {
397
+ void this.dispose();
398
+ }
399
+
400
+ async dispose() {
401
+ if (this.disposePromise) return this.disposePromise;
402
+ this.disposed = true;
403
+ this.clearIdleTimer();
404
+ this.onClose?.();
405
+ this.disposePromise = this.releaseResources();
406
+ return this.disposePromise;
407
+ }
408
+
409
+ async releaseResources() {
410
+ try {
411
+ this.connection.close();
412
+ } catch {
413
+ // The ACP transport may already be closed.
414
+ }
415
+ if (this.childIsRunning()) {
416
+ this.child.kill("SIGTERM");
417
+ await Promise.race([
418
+ new Promise((resolve) => this.child.once("exit", resolve)),
419
+ new Promise((resolve) => setTimeout(resolve, 500)),
420
+ ]);
421
+ }
422
+ if (this.childIsRunning()) {
423
+ this.child.kill("SIGKILL");
424
+ await Promise.race([
425
+ new Promise((resolve) => this.child.once("exit", resolve)),
426
+ new Promise((resolve) => setTimeout(resolve, 500)),
427
+ ]);
428
+ }
429
+ this.child.stdin.destroy();
430
+ this.child.stdout.destroy();
431
+ this.child.stderr.destroy();
432
+ }
433
+ }
415
434
 
416
435
  export function normalizeTokenUsage(value) {
417
436
  if (!value || typeof value !== "object") return null;
@@ -426,11 +445,11 @@ export function normalizeTokenUsage(value) {
426
445
  input_tokens: number("inputTokens", "input_tokens"),
427
446
  output_tokens: number("outputTokens", "output_tokens"),
428
447
  cache_read_tokens: number("cachedReadTokens", "cachedInputTokens", "cache_read_input_tokens"),
429
- cache_write_tokens: number(
430
- "cachedWriteTokens",
431
- "cacheWriteInputTokens",
432
- "cache_creation_input_tokens",
433
- ),
448
+ cache_write_tokens: number(
449
+ "cachedWriteTokens",
450
+ "cacheWriteInputTokens",
451
+ "cache_creation_input_tokens",
452
+ ),
434
453
  reasoning_tokens: number("reasoningTokens", "reasoningOutputTokens", "reasoning_tokens"),
435
454
  };
436
455
  usage.total_tokens = number("totalTokens", "total_tokens") ||
@@ -141,15 +141,15 @@ export function normalizeAgentStructuredOutput(
141
141
  if (!content) {
142
142
  throw new Error("Agent completed without returning a response.");
143
143
  }
144
- const firstSection = {
145
- "# Workspace Assessment": "## Executive Summary",
146
- "# Workspace Assessment Delta": "## Updated Executive Summary",
147
- "# Assessment Stage: Architecture": "## Architecture Summary",
148
- "# Assessment Stage: Capability Catalog": "## Capability Catalog",
149
- "# Assessment Stage: Capabilities": "## Observed Capabilities",
150
- "# Assessment Stage: Quality": "## Quality Summary",
151
- "# Assessment Stage: Synthesis": "## Executive Summary",
152
- }[outputHeading];
144
+ const firstSection = {
145
+ "# Workspace Assessment": "## Executive Summary",
146
+ "# Workspace Assessment Delta": "## Updated Executive Summary",
147
+ "# Assessment Stage: Architecture": "## Architecture Summary",
148
+ "# Assessment Stage: Capability Catalog": "## Capability Catalog",
149
+ "# Assessment Stage: Capabilities": "## Observed Capabilities",
150
+ "# Assessment Stage: Quality": "## Quality Summary",
151
+ "# Assessment Stage: Synthesis": "## Executive Summary",
152
+ }[outputHeading];
153
153
  if (
154
154
  firstSection &&
155
155
  (content.startsWith(firstSection) ||
@@ -157,34 +157,34 @@ export function normalizeAgentStructuredOutput(
157
157
  ) {
158
158
  return `${outputHeading}\n\n${content}`;
159
159
  }
160
- const lines = content.split(/\r?\n/);
161
- const headingIndex = lines.findIndex(
162
- (line) => line.trim() === outputHeading,
163
- );
164
- const firstSectionIndex = firstSection
165
- ? lines.findIndex((line) => line.trim() === firstSection)
166
- : -1;
167
- if (
168
- headingIndex < 0 &&
169
- outputHeading.startsWith("# Assessment Stage:") &&
170
- firstSectionIndex >= 0
171
- ) {
172
- const preamble = lines.slice(0, firstSectionIndex).join("\n");
173
- if (preamble.length > 2_000) {
174
- throw new Error(
175
- `Agent response contains too much text before the required section '${firstSection}'.`,
176
- );
177
- }
178
- const normalized = lines.slice(firstSectionIndex).join("\n").trim();
179
- if (/```/.test(preamble) || /(?:^|\n)```\s*$/.test(normalized)) {
180
- throw new Error(
181
- `Agent response must return '${outputHeading}' as plain Markdown, without a code fence.`,
182
- );
183
- }
184
- return `${outputHeading}\n\n${normalized}`;
185
- }
186
- const preamble =
187
- headingIndex >= 0 ? lines.slice(0, headingIndex).join("\n") : content;
160
+ const lines = content.split(/\r?\n/);
161
+ const headingIndex = lines.findIndex(
162
+ (line) => line.trim() === outputHeading,
163
+ );
164
+ const firstSectionIndex = firstSection
165
+ ? lines.findIndex((line) => line.trim() === firstSection)
166
+ : -1;
167
+ if (
168
+ headingIndex < 0 &&
169
+ outputHeading.startsWith("# Assessment Stage:") &&
170
+ firstSectionIndex >= 0
171
+ ) {
172
+ const preamble = lines.slice(0, firstSectionIndex).join("\n");
173
+ if (preamble.length > 2_000) {
174
+ throw new Error(
175
+ `Agent response contains too much text before the required section '${firstSection}'.`,
176
+ );
177
+ }
178
+ const normalized = lines.slice(firstSectionIndex).join("\n").trim();
179
+ if (/```/.test(preamble) || /(?:^|\n)```\s*$/.test(normalized)) {
180
+ throw new Error(
181
+ `Agent response must return '${outputHeading}' as plain Markdown, without a code fence.`,
182
+ );
183
+ }
184
+ return `${outputHeading}\n\n${normalized}`;
185
+ }
186
+ const preamble =
187
+ headingIndex >= 0 ? lines.slice(0, headingIndex).join("\n") : content;
188
188
  if (headingIndex < 0) {
189
189
  throw new Error(
190
190
  `Agent response is missing the required heading '${outputHeading}'.`,
@@ -103,10 +103,10 @@ export function launchCodexAppServer({
103
103
  let threadId = previousSessionId || "";
104
104
  let turnId = "";
105
105
  let finalMessage = "";
106
- let stderr = "";
107
- let usage = null;
108
- let settled = false;
109
- let completionTimer = null;
106
+ let stderr = "";
107
+ let usage = null;
108
+ let settled = false;
109
+ let completionTimer = null;
110
110
 
111
111
  const request = (method, params) =>
112
112
  new Promise((resolve, reject) => {
@@ -116,10 +116,10 @@ export function launchCodexAppServer({
116
116
  });
117
117
 
118
118
  const completed = new Promise((resolve, reject) => {
119
- const finish = (error) => {
120
- if (settled) return;
121
- settled = true;
122
- if (completionTimer) clearTimeout(completionTimer);
119
+ const finish = (error) => {
120
+ if (settled) return;
121
+ settled = true;
122
+ if (completionTimer) clearTimeout(completionTimer);
123
123
  for (const waiter of pending.values()) waiter.reject(error || new Error("Codex app-server stopped."));
124
124
  pending.clear();
125
125
  if (error) reject(error);
@@ -157,38 +157,38 @@ export function launchCodexAppServer({
157
157
  if (message.method === "item/agentMessage/delta" && typeof params.delta === "string") {
158
158
  finalMessage += params.delta;
159
159
  callbacks.onEvent?.({ type: "codex.agent_message_delta", delta: params.delta });
160
- } else if (message.method === "item/started" || message.method === "item/completed") {
161
- callbacks.onEvent?.({ type: `codex.${message.method}`, item: params.item });
162
- } else if (message.method === "thread/tokenUsage/updated") {
163
- const tokenUsage = params.tokenUsage || params.usage || params;
164
- usage = normalizeTokenUsage(
165
- previousSessionId
166
- ? tokenUsage.last || params.last || tokenUsage
167
- : tokenUsage.total || params.total || tokenUsage.last || params.last || tokenUsage,
168
- );
169
- callbacks.onEvent?.({ type: "codex.usage", update: params });
170
- if (usage && completionTimer) {
171
- finish();
172
- child.kill();
173
- }
174
- } else if (message.method === "turn/completed" && params.threadId === threadId) {
175
- const status = params.turn?.status;
176
- if (status === "failed") {
177
- finish(new Error(params.turn?.error?.message || "Codex turn failed."));
178
- child.kill();
179
- } else if (!finalMessage.trim()) {
180
- finish(new Error("Codex completed without returning a response."));
181
- child.kill();
182
- } else if (usage) {
183
- finish();
184
- child.kill();
185
- } else if (!completionTimer) {
186
- completionTimer = setTimeout(() => {
187
- finish();
188
- child.kill();
189
- }, 250);
190
- }
191
- }
160
+ } else if (message.method === "item/started" || message.method === "item/completed") {
161
+ callbacks.onEvent?.({ type: `codex.${message.method}`, item: params.item });
162
+ } else if (message.method === "thread/tokenUsage/updated") {
163
+ const tokenUsage = params.tokenUsage || params.usage || params;
164
+ usage = normalizeTokenUsage(
165
+ previousSessionId
166
+ ? tokenUsage.last || params.last || tokenUsage
167
+ : tokenUsage.total || params.total || tokenUsage.last || params.last || tokenUsage,
168
+ );
169
+ callbacks.onEvent?.({ type: "codex.usage", update: params });
170
+ if (usage && completionTimer) {
171
+ finish();
172
+ child.kill();
173
+ }
174
+ } else if (message.method === "turn/completed" && params.threadId === threadId) {
175
+ const status = params.turn?.status;
176
+ if (status === "failed") {
177
+ finish(new Error(params.turn?.error?.message || "Codex turn failed."));
178
+ child.kill();
179
+ } else if (!finalMessage.trim()) {
180
+ finish(new Error("Codex completed without returning a response."));
181
+ child.kill();
182
+ } else if (usage) {
183
+ finish();
184
+ child.kill();
185
+ } else if (!completionTimer) {
186
+ completionTimer = setTimeout(() => {
187
+ finish();
188
+ child.kill();
189
+ }, 250);
190
+ }
191
+ }
192
192
  });
193
193
 
194
194
  child.stderr.setEncoding("utf8");
@@ -1,74 +1,74 @@
1
- const PROTOCOL_FAILURE_MESSAGE_MAX_LENGTH = 2_000;
2
- const TRUNCATED_FAILURE_SUFFIX = "\n… [truncated by EngineerOS connector]";
3
-
4
- export function describeWebSocketError(event) {
5
- return (
6
- event?.error?.message ||
7
- event?.message ||
8
- "The WebSocket connection failed without providing an error detail."
9
- );
10
- }
11
-
12
- export function protocolFailureMessage(error) {
13
- const raw = error instanceof Error ? error.message : String(error ?? "");
14
- const message =
15
- raw.trim() || "The connector failed without providing an error detail.";
16
- const characters = [...message];
17
- if (characters.length <= PROTOCOL_FAILURE_MESSAGE_MAX_LENGTH) return message;
18
- const suffix = [...TRUNCATED_FAILURE_SUFFIX];
19
- return `${characters
20
- .slice(0, PROTOCOL_FAILURE_MESSAGE_MAX_LENGTH - suffix.length)
21
- .join("")}${TRUNCATED_FAILURE_SUFFIX}`;
22
- }
23
-
24
- export async function describeRejectedResponse(response, subject) {
25
- const responseBody = await response.text();
26
- const detail = responseDetail(responseBody);
27
- return protocolFailureMessage(
28
- `EngineerOS rejected ${subject} (${response.status})${detail ? `: ${detail}` : "."}`,
29
- );
30
- }
31
-
32
- export function startConnectionWatchdog(
33
- socket,
34
- url,
35
- { timeoutMs = 15_000, onTimeout = console.error } = {},
36
- ) {
37
- const timer = setTimeout(() => {
38
- onTimeout(
39
- `EngineerOS did not complete the WebSocket handshake at ${url} within ${Math.round(timeoutMs / 1_000)} seconds. Check that the backend is running and the URL is reachable from this machine.`,
40
- );
41
- try {
42
- socket.close();
43
- } catch {
44
- // Reconnect scheduling is owned by the caller.
45
- }
46
- }, timeoutMs);
47
-
48
- return () => clearTimeout(timer);
49
- }
50
-
51
- function responseDetail(responseBody) {
52
- const body = String(responseBody || "").trim();
53
- if (!body) return "";
54
- try {
55
- const parsed = JSON.parse(body);
56
- if (typeof parsed?.detail === "string") return parsed.detail;
57
- if (Array.isArray(parsed?.detail)) {
58
- return parsed.detail.map(validationIssue).filter(Boolean).join("; ");
59
- }
60
- } catch {
61
- // Plain-text server errors are already useful to the operator.
62
- }
63
- return body;
64
- }
65
-
66
- function validationIssue(issue) {
67
- if (typeof issue === "string") return issue;
68
- if (!issue || typeof issue !== "object") return "";
69
- const location = Array.isArray(issue.loc) ? issue.loc.join(".") : "";
70
- const message = typeof issue.msg === "string" ? issue.msg : "";
71
- const type = typeof issue.type === "string" ? ` (${issue.type})` : "";
72
- if (!location && !message) return "";
73
- return `${location ? `${location}: ` : ""}${message}${type}`;
74
- }
1
+ const PROTOCOL_FAILURE_MESSAGE_MAX_LENGTH = 2_000;
2
+ const TRUNCATED_FAILURE_SUFFIX = "\n… [truncated by EngineerOS connector]";
3
+
4
+ export function describeWebSocketError(event) {
5
+ return (
6
+ event?.error?.message ||
7
+ event?.message ||
8
+ "The WebSocket connection failed without providing an error detail."
9
+ );
10
+ }
11
+
12
+ export function protocolFailureMessage(error) {
13
+ const raw = error instanceof Error ? error.message : String(error ?? "");
14
+ const message =
15
+ raw.trim() || "The connector failed without providing an error detail.";
16
+ const characters = [...message];
17
+ if (characters.length <= PROTOCOL_FAILURE_MESSAGE_MAX_LENGTH) return message;
18
+ const suffix = [...TRUNCATED_FAILURE_SUFFIX];
19
+ return `${characters
20
+ .slice(0, PROTOCOL_FAILURE_MESSAGE_MAX_LENGTH - suffix.length)
21
+ .join("")}${TRUNCATED_FAILURE_SUFFIX}`;
22
+ }
23
+
24
+ export async function describeRejectedResponse(response, subject) {
25
+ const responseBody = await response.text();
26
+ const detail = responseDetail(responseBody);
27
+ return protocolFailureMessage(
28
+ `EngineerOS rejected ${subject} (${response.status})${detail ? `: ${detail}` : "."}`,
29
+ );
30
+ }
31
+
32
+ export function startConnectionWatchdog(
33
+ socket,
34
+ url,
35
+ { timeoutMs = 15_000, onTimeout = console.error } = {},
36
+ ) {
37
+ const timer = setTimeout(() => {
38
+ onTimeout(
39
+ `EngineerOS did not complete the WebSocket handshake at ${url} within ${Math.round(timeoutMs / 1_000)} seconds. Check that the backend is running and the URL is reachable from this machine.`,
40
+ );
41
+ try {
42
+ socket.close();
43
+ } catch {
44
+ // Reconnect scheduling is owned by the caller.
45
+ }
46
+ }, timeoutMs);
47
+
48
+ return () => clearTimeout(timer);
49
+ }
50
+
51
+ function responseDetail(responseBody) {
52
+ const body = String(responseBody || "").trim();
53
+ if (!body) return "";
54
+ try {
55
+ const parsed = JSON.parse(body);
56
+ if (typeof parsed?.detail === "string") return parsed.detail;
57
+ if (Array.isArray(parsed?.detail)) {
58
+ return parsed.detail.map(validationIssue).filter(Boolean).join("; ");
59
+ }
60
+ } catch {
61
+ // Plain-text server errors are already useful to the operator.
62
+ }
63
+ return body;
64
+ }
65
+
66
+ function validationIssue(issue) {
67
+ if (typeof issue === "string") return issue;
68
+ if (!issue || typeof issue !== "object") return "";
69
+ const location = Array.isArray(issue.loc) ? issue.loc.join(".") : "";
70
+ const message = typeof issue.msg === "string" ? issue.msg : "";
71
+ const type = typeof issue.type === "string" ? ` (${issue.type})` : "";
72
+ if (!location && !message) return "";
73
+ return `${location ? `${location}: ` : ""}${message}${type}`;
74
+ }