agent-relay-sdk 0.2.8 → 0.2.10

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 (57) hide show
  1. package/dist/bus-client.d.ts.map +1 -1
  2. package/dist/bus-client.js +1 -3
  3. package/dist/bus-client.js.map +1 -1
  4. package/dist/context-probe.d.ts.map +1 -1
  5. package/dist/context-probe.js +3 -7
  6. package/dist/context-probe.js.map +1 -1
  7. package/dist/contracts.d.ts +3 -0
  8. package/dist/contracts.d.ts.map +1 -1
  9. package/dist/contracts.js +3 -0
  10. package/dist/contracts.js.map +1 -1
  11. package/dist/fs-name.d.ts +20 -0
  12. package/dist/fs-name.d.ts.map +1 -0
  13. package/dist/fs-name.js +29 -0
  14. package/dist/fs-name.js.map +1 -0
  15. package/dist/http-client.d.ts.map +1 -1
  16. package/dist/http-client.js +2 -1
  17. package/dist/http-client.js.map +1 -1
  18. package/dist/index.d.ts +4 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +4 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/process-utils.d.ts +14 -0
  23. package/dist/process-utils.d.ts.map +1 -0
  24. package/dist/process-utils.js +53 -0
  25. package/dist/process-utils.js.map +1 -0
  26. package/dist/protocol.d.ts.map +1 -1
  27. package/dist/protocol.js +1 -3
  28. package/dist/protocol.js.map +1 -1
  29. package/dist/provider-catalog.d.ts +4 -1
  30. package/dist/provider-catalog.d.ts.map +1 -1
  31. package/dist/provider-catalog.js +5 -0
  32. package/dist/provider-catalog.js.map +1 -1
  33. package/dist/shell-utils.d.ts +3 -0
  34. package/dist/shell-utils.d.ts.map +1 -0
  35. package/dist/shell-utils.js +29 -0
  36. package/dist/shell-utils.js.map +1 -0
  37. package/dist/tmux-utils.d.ts +5 -0
  38. package/dist/tmux-utils.d.ts.map +1 -0
  39. package/dist/tmux-utils.js +17 -0
  40. package/dist/tmux-utils.js.map +1 -0
  41. package/dist/types.d.ts +106 -3
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/types.js +43 -1
  44. package/dist/types.js.map +1 -1
  45. package/package.json +17 -1
  46. package/src/bus-client.ts +1 -4
  47. package/src/context-probe.ts +3 -9
  48. package/src/contracts.ts +4 -0
  49. package/src/fs-name.ts +47 -0
  50. package/src/http-client.ts +2 -1
  51. package/src/index.ts +4 -0
  52. package/src/process-utils.ts +54 -0
  53. package/src/protocol.ts +2 -4
  54. package/src/provider-catalog.ts +6 -1
  55. package/src/shell-utils.ts +28 -0
  56. package/src/tmux-utils.ts +18 -0
  57. package/src/types.ts +123 -3
package/src/bus-client.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  } from "./protocol.js";
11
11
  import { ReconnectionManager } from "./reconnect.js";
12
12
  import type { ContextState, ProviderCapabilities } from "./types.js";
13
+ import { isRecord } from "./types.js";
13
14
 
14
15
  export interface CursorStore {
15
16
  load(): Promise<number | null>;
@@ -503,7 +504,3 @@ export class RelayBusClient extends EventEmitter {
503
504
  return Object.keys(meta).length ? meta : undefined;
504
505
  }
505
506
  }
506
-
507
- function isRecord(value: unknown): value is Record<string, unknown> {
508
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
509
- }
@@ -3,6 +3,8 @@ import { tmpdir } from "node:os";
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import type { ContextProbeMetrics } from "./types.js";
6
+ import { sanitizeFsName } from "./fs-name.js";
7
+ import { isRecord, stringValue } from "./types.js";
6
8
 
7
9
  export interface ContextProbeOptions {
8
10
  wrapCommand?: string;
@@ -208,7 +210,7 @@ function formatTokens(tokens: number): string {
208
210
  }
209
211
 
210
212
  function safeStateId(agentId: string): string {
211
- return basename(agentId).replace(/[^a-zA-Z0-9._-]/g, "-") || "unknown";
213
+ return sanitizeFsName(basename(agentId), { replacement: "-", collapse: false, fallback: "unknown" });
212
214
  }
213
215
 
214
216
  function envAgentId(env: Record<string, string | undefined> | undefined): string | undefined {
@@ -225,10 +227,6 @@ function numberValue(value: unknown): number | undefined {
225
227
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
226
228
  }
227
229
 
228
- function stringValue(value: unknown): string | undefined {
229
- return typeof value === "string" && value ? value : undefined;
230
- }
231
-
232
230
  function sumNumbers(...values: unknown[]): number | undefined {
233
231
  let total = 0;
234
232
  let seen = false;
@@ -251,7 +249,3 @@ function isContextProbeMetrics(value: unknown): value is ContextProbeMetrics {
251
249
  typeof value.contextPercent === "number" &&
252
250
  typeof value.timestamp === "number";
253
251
  }
254
-
255
- function isRecord(value: unknown): value is Record<string, unknown> {
256
- return typeof value === "object" && value !== null && !Array.isArray(value);
257
- }
package/src/contracts.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import type { ContractCompatibility, ContractCompatibilityIssue, RuntimeContractName, RuntimeContracts } from "./types.js";
2
2
 
3
+ /** HTTP header carrying an Agent Relay token. Single source of truth — import this
4
+ * instead of hand-writing the literal (duplication ratchet, docs/dry-audit.md). */
5
+ export const RELAY_TOKEN_HEADER = "X-Agent-Relay-Token";
6
+
3
7
  export const CONTRACT_VERSIONS = {
4
8
  relayApi: 1,
5
9
  orchestratorProtocol: 3,
package/src/fs-name.ts ADDED
@@ -0,0 +1,47 @@
1
+ // Filesystem-name sanitizer shared by the runner and orchestrator. Replaces a
2
+ // pile of near-identical `safe*`/`sanitize*` helpers whose subtle differences
3
+ // (replacement char, run-collapsing, edge-trimming, length cap, empty fallback)
4
+ // are now explicit options. Crucially this single home guarantees the runner's
5
+ // session-mirror log filename and the orchestrator's reader resolve to the SAME
6
+ // string — previously kept in sync by hand-copied comments.
7
+ //
8
+ // Allowed characters (everything else is replaced): a-z A-Z 0-9 . _ -
9
+
10
+ export interface SanitizeFsNameOptions {
11
+ /** Truncate the result to this many chars. Omit for no truncation. */
12
+ maxLen?: number;
13
+ /** Character substituted for disallowed input. Default "_". */
14
+ replacement?: string;
15
+ /** Collapse a run of disallowed chars into one replacement. Default true. */
16
+ collapse?: boolean;
17
+ /** Also collapse runs of the replacement char itself (incl. pre-existing). Default false. */
18
+ collapseReplacement?: boolean;
19
+ /** Trim surrounding whitespace before sanitizing. Default false. */
20
+ trimWhitespace?: boolean;
21
+ /** Strip leading/trailing replacement chars from the result. Default false. */
22
+ trimEdge?: boolean;
23
+ /** Lowercase the result — for tmux/session labels. Default false. */
24
+ lowercase?: boolean;
25
+ /** Returned when the sanitized result is empty. Default "". */
26
+ fallback?: string;
27
+ }
28
+
29
+ function escapeRe(s: string): string {
30
+ return s.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
31
+ }
32
+
33
+ export function sanitizeFsName(value: string, opts: SanitizeFsNameOptions = {}): string {
34
+ const repl = opts.replacement ?? "_";
35
+ let s = opts.trimWhitespace ? value.trim() : value;
36
+ s = s.replace(opts.collapse === false ? /[^a-zA-Z0-9._-]/g : /[^a-zA-Z0-9._-]+/g, repl);
37
+ if (opts.collapseReplacement) {
38
+ s = s.replace(new RegExp(`${escapeRe(repl)}+`, "g"), repl);
39
+ }
40
+ if (opts.trimEdge) {
41
+ const e = escapeRe(repl);
42
+ s = s.replace(new RegExp(`^(?:${e})+|(?:${e})+$`, "g"), "");
43
+ }
44
+ if (opts.lowercase) s = s.toLowerCase();
45
+ if (opts.maxLen !== undefined) s = s.slice(0, opts.maxLen);
46
+ return s || opts.fallback || "";
47
+ }
@@ -1,4 +1,5 @@
1
1
  import type { AgentCard, Artifact, ArtifactKind, ArtifactSensitivity, PollQuery, RegisterAgentInput, SendMessageInput, Message, MessageDeliveryState, MessageDeliveryStatus, ReplyObligation, Task, TaskStatusInput } from "./types.js";
2
+ import { RELAY_TOKEN_HEADER } from "./contracts.js";
2
3
 
3
4
  export interface HttpClientOptions {
4
5
  baseUrl: string;
@@ -322,7 +323,7 @@ export class RelayHttpClient {
322
323
 
323
324
  private headers(base: Record<string, string> = {}): Record<string, string> {
324
325
  const token = this.token ?? process.env.AGENT_RELAY_TOKEN;
325
- return token ? { ...base, "X-Agent-Relay-Token": token } : base;
326
+ return token ? { ...base, [RELAY_TOKEN_HEADER]: token } : base;
326
327
  }
327
328
 
328
329
  private url(path: string): URL {
package/src/index.ts CHANGED
@@ -8,3 +8,7 @@ export * from "./bus-client.js";
8
8
  export * from "./provider-base.js";
9
9
  export * from "./claim-tracker.js";
10
10
  export * from "./context-probe.js";
11
+ export * from "./process-utils.js";
12
+ export * from "./shell-utils.js";
13
+ export * from "./tmux-utils.js";
14
+ export * from "./fs-name.js";
@@ -0,0 +1,54 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+
4
+ // Process-liveness primitives shared by the runner and orchestrator. A zombie
5
+ // process still has a PID-table entry, so kill(pid, 0) succeeds even though it
6
+ // is dead and unreapable — we never wait() our spawned children, so they linger
7
+ // as zombies. isPidAlive treats them as not-alive (Linux-only via /proc).
8
+ //
9
+ // Kept Node-portable (no Bun-only APIs) so agent-relay-client can use it too.
10
+
11
+ /** Parse a /proc/<pid>/status blob; true iff the process State is `Z` (zombie). */
12
+ export function parseProcStateIsZombie(statusText: string): boolean {
13
+ const match = statusText.match(/^State:\s+(\w)/m);
14
+ return match?.[1] === "Z";
15
+ }
16
+
17
+ /** True iff <pid> exists and its /proc state is zombie. Non-Linux → false. */
18
+ export function isZombie(pid: number): boolean {
19
+ try {
20
+ return parseProcStateIsZombie(readFileSync(`/proc/${pid}/status`, "utf8"));
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ /** True iff <pid> is alive AND not a zombie. */
27
+ export function isPidAlive(pid: number): boolean {
28
+ try {
29
+ process.kill(pid, 0);
30
+ } catch {
31
+ return false;
32
+ }
33
+ return !isZombie(pid);
34
+ }
35
+
36
+ /** Send <signal> to <pid>, swallowing ESRCH/EPERM. */
37
+ export function killPid(pid: number, signal: "SIGTERM" | "SIGKILL"): void {
38
+ try {
39
+ process.kill(pid, signal);
40
+ } catch {}
41
+ }
42
+
43
+ /**
44
+ * Poll until every pid in <pids> has exited (zombie-aware), or <timeoutMs>
45
+ * elapses. Returns true if all exited within the window.
46
+ */
47
+ export async function waitForPidsExit(pids: number[], timeoutMs: number): Promise<boolean> {
48
+ const deadline = Date.now() + timeoutMs;
49
+ while (Date.now() < deadline) {
50
+ if (!pids.some(isPidAlive)) return true;
51
+ await delay(100);
52
+ }
53
+ return !pids.some(isPidAlive);
54
+ }
package/src/protocol.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { isRecord } from "./types.js";
2
+
1
3
  export interface BusFrame {
2
4
  type: string;
3
5
  id?: string;
@@ -290,7 +292,3 @@ function invalidPayload(field: string): BusProtocolError {
290
292
  function isRole(value: unknown): value is BusRole {
291
293
  return value === "provider" || value === "channel" || value === "orchestrator" || value === "integration";
292
294
  }
293
-
294
- function isRecord(value: unknown): value is Record<string, unknown> {
295
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
296
- }
@@ -1,6 +1,11 @@
1
1
  import type { SpawnProvider } from "./types.js";
2
2
 
3
- export type ProviderEffort = "low" | "medium" | "high" | "xhigh" | "max";
3
+ /** Valid reasoning-effort levels runtime tuple is the single source of truth. */
4
+ export const VALID_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
5
+ export type ProviderEffort = (typeof VALID_EFFORTS)[number];
6
+ export function isProviderEffort(value: unknown): value is ProviderEffort {
7
+ return typeof value === "string" && (VALID_EFFORTS as readonly string[]).includes(value);
8
+ }
4
9
  export type ProviderCatalogValueSource = "catalog" | "provider" | "runtime" | "override";
5
10
  export type ProviderCatalogConfidence = "declared" | "verified" | "estimated" | "unknown";
6
11
 
@@ -0,0 +1,28 @@
1
+ // POSIX shell argument quoting, shared by the server, runner, and orchestrator.
2
+ //
3
+ // Two variants, because the callers genuinely want two behaviours — the
4
+ // single-quote fallback (wrap in '...' and rewrite embedded ' as '\'') is the
5
+ // canonical-safe core of both:
6
+ //
7
+ // shellQuote — ALWAYS quotes. Used when generating files (env files,
8
+ // systemd/launchd units) where uniform `KEY='value'` quoting is
9
+ // the convention and readability/consistency matter.
10
+ //
11
+ // shellEscape — quotes ONLY when needed, returning strings built entirely
12
+ // from shell-safe characters unquoted. Used for command-arg
13
+ // assembly where clean tokens read better bare.
14
+ //
15
+ // The fast-path allowlist is the reconciled superset of the prior copies; every
16
+ // character was audited non-special in POSIX sh, and the fallback would quote
17
+ // correctly even if one weren't.
18
+ const SHELL_SAFE = /^[a-zA-Z0-9_@%+=:,./-]+$/;
19
+
20
+ export function shellQuote(value: string): string {
21
+ return `'${value.replace(/'/g, "'\\''")}'`;
22
+ }
23
+
24
+ export function shellEscape(value: string): string {
25
+ if (value.length === 0) return "''";
26
+ if (SHELL_SAFE.test(value)) return value;
27
+ return `'${value.replace(/'/g, "'\\''")}'`;
28
+ }
@@ -0,0 +1,18 @@
1
+ // tmux command helpers shared by the runner and orchestrator. Both run under
2
+ // Bun; `Bun.spawnSync` is referenced only inside tmuxHasSession's body, so the
3
+ // module is import-safe under Node (the client never calls tmuxHasSession).
4
+
5
+ /** Build a `tmux` argv, threading the optional `-L <socket>` selector. */
6
+ export function tmuxCommand(socketName: string | undefined, ...args: string[]): string[] {
7
+ return socketName ? ["tmux", "-L", socketName, ...args] : ["tmux", ...args];
8
+ }
9
+
10
+ /** True iff a tmux session named `sessionName` exists on the (optional) socket. */
11
+ export function tmuxHasSession(sessionName: string, socketName?: string): boolean {
12
+ const result = Bun.spawnSync(tmuxCommand(socketName, "has-session", "-t", sessionName), {
13
+ stdin: "ignore",
14
+ stdout: "ignore",
15
+ stderr: "ignore",
16
+ });
17
+ return result.exitCode === 0;
18
+ }
package/src/types.ts CHANGED
@@ -1130,10 +1130,33 @@ export interface ActivityEventInput {
1130
1130
  // --- Orchestrators ---
1131
1131
 
1132
1132
  export type OrchestratorStatus = "online" | "offline";
1133
- export type SpawnProvider = "claude" | "codex";
1134
- export type SpawnApprovalMode = "open" | "guarded" | "read-only";
1133
+
1134
+ /** Spawn providers the runtime tuple is the single source of truth; the type derives from it. */
1135
+ export const SPAWN_PROVIDERS = ["claude", "codex"] as const;
1136
+ export type SpawnProvider = (typeof SPAWN_PROVIDERS)[number];
1137
+ export function isSpawnProvider(value: unknown): value is SpawnProvider {
1138
+ return typeof value === "string" && (SPAWN_PROVIDERS as readonly string[]).includes(value);
1139
+ }
1140
+
1141
+ /** Approval modes — runtime tuple + derived type. */
1142
+ export const APPROVAL_MODES = ["open", "guarded", "read-only"] as const;
1143
+ export type SpawnApprovalMode = (typeof APPROVAL_MODES)[number];
1144
+ export function isApprovalMode(value: unknown): value is SpawnApprovalMode {
1145
+ return typeof value === "string" && (APPROVAL_MODES as readonly string[]).includes(value);
1146
+ }
1147
+
1135
1148
  export type SpawnEffort = "low" | "medium" | "high" | "xhigh" | "max";
1136
- export type WorkspaceMode = "isolated" | "shared" | "inherit";
1149
+
1150
+ /** Workspace modes — runtime tuple + derived type + guard. */
1151
+ export const VALID_WORKSPACE_MODES = ["isolated", "shared", "inherit"] as const;
1152
+ export type WorkspaceMode = (typeof VALID_WORKSPACE_MODES)[number];
1153
+ export function isWorkspaceMode(value: unknown): value is WorkspaceMode {
1154
+ return typeof value === "string" && (VALID_WORKSPACE_MODES as readonly string[]).includes(value);
1155
+ }
1156
+ /** Narrow an unknown to a WorkspaceMode, or undefined if it isn't one. */
1157
+ export function normalizeWorkspaceMode(value: unknown): WorkspaceMode | undefined {
1158
+ return isWorkspaceMode(value) ? value : undefined;
1159
+ }
1137
1160
  export type WorkspaceStatus = "active" | "ready" | "conflict" | "review_requested" | "merge_planned" | "merged" | "abandoned" | "cleanup_requested" | "cleaned";
1138
1161
 
1139
1162
  /** How a workspace's work is integrated back into its base branch. */
@@ -1194,6 +1217,8 @@ export interface WorkspaceGitState {
1194
1217
  lastCommit?: WorkspaceGitCommit;
1195
1218
  /** Ref/sha the ahead/behind counts were computed against. */
1196
1219
  baseRef?: string;
1220
+ /** Branch currently checked out in the worktree (for recorded-vs-live mismatch). */
1221
+ branch?: string;
1197
1222
  /** Set when the worktree path no longer exists on disk. */
1198
1223
  missing?: boolean;
1199
1224
  /** Populated when git interrogation failed. */
@@ -1262,12 +1287,47 @@ export interface WorkspaceMergeResult {
1262
1287
  mergedSha?: string;
1263
1288
  branchDeleted?: boolean;
1264
1289
  worktreeRemoved?: boolean;
1290
+ /** Fresh branch the worktree was recycled onto after a land-and-continue merge
1291
+ * (#206) — the relay repoints the workspace row at it. Absent for terminal lands. */
1292
+ newBranch?: string;
1293
+ /** True when the landed base branch was pushed to its upstream (origin). */
1294
+ pushed?: boolean;
1265
1295
  /** Resulting workspace status the relay should record. */
1266
1296
  status: WorkspaceStatus;
1297
+ /** Deps re-provisioned after a land-and-continue recycle when the advanced base
1298
+ * brought new dependencies (issue #51). Absent when nothing was stale. */
1299
+ depsRefresh?: WorkspaceDepsRefreshResult;
1267
1300
  /** Populated when the merge could not complete. */
1268
1301
  error?: string;
1269
1302
  }
1270
1303
 
1304
+ /**
1305
+ * Joined steward briefing for one workspace (#208): the row, owner + orchestrator
1306
+ * liveness, live git state, recorded-vs-live branch mismatch, any active steward
1307
+ * claim, and a recommended next action — so a steward (or release agent) doesn't
1308
+ * reconstruct it from ad-hoc shell + API calls.
1309
+ */
1310
+ export interface WorkspaceDiagnostics {
1311
+ workspaceId: string;
1312
+ status: WorkspaceStatus;
1313
+ mode: WorkspaceMode;
1314
+ repoRoot: string;
1315
+ worktreePath?: string;
1316
+ recordedBranch?: string;
1317
+ liveBranch?: string;
1318
+ baseRef?: string;
1319
+ branchMismatch?: boolean;
1320
+ owner?: { id?: string; status?: string; online: boolean };
1321
+ orchestrator?: { id?: string; online: boolean };
1322
+ /** Active claim that auto-merge yields to (#208), if held and unexpired. */
1323
+ claim?: { by?: string; purpose?: string; expiresAt?: number };
1324
+ /** Live worktree git state (proxied from the host); absent when unavailable. */
1325
+ gitState?: WorkspaceGitState;
1326
+ /** Why git state is absent (host offline, no worktree, …). */
1327
+ gitStateUnavailable?: string;
1328
+ recommendation: { action: "merge" | "rebase" | "cleanup" | "review" | "wait" | "none"; confidence: "high" | "medium" | "low"; reason: string };
1329
+ }
1330
+
1271
1331
  /** One changed file in a workspace diff against its base. */
1272
1332
  export interface WorkspaceDiffFile {
1273
1333
  path: string;
@@ -1328,6 +1388,36 @@ export interface WorkspaceSymlinkProvision {
1328
1388
  errors?: string[];
1329
1389
  }
1330
1390
 
1391
+ /** One project dir's outcome in a workspace deps refresh (issue #51). */
1392
+ export interface WorkspaceDepsRefreshDir {
1393
+ /** Relative dir from repo root (`.` for root, e.g. `dashboard`). */
1394
+ dir: string;
1395
+ /** ok — declared deps already present; stale — missing (check-only, not installed);
1396
+ * installed — re-installed in isolation; failed — install errored. */
1397
+ status: "ok" | "stale" | "installed" | "failed";
1398
+ /** Declared deps (dependencies + devDependencies) missing from node_modules, capped sample. */
1399
+ missing?: string[];
1400
+ /** True when the dir's node_modules was a shared symlink replaced with a real install. */
1401
+ wasSymlink?: boolean;
1402
+ packageManager?: string;
1403
+ error?: string;
1404
+ }
1405
+
1406
+ /**
1407
+ * Outcome of refreshing an isolated worktree's deps (issue #51). The default symlink
1408
+ * provisioning shares the source checkout's node_modules, so a dep added to the base
1409
+ * AFTER worktree creation is missing in the worktree. Refresh promotes each stale dir
1410
+ * to a real, isolated install — never mutating the shared source node_modules.
1411
+ */
1412
+ export interface WorkspaceDepsRefreshResult {
1413
+ /** True when at least one dir was actually (re)installed. */
1414
+ refreshed: boolean;
1415
+ /** True (check-only) when any dir is stale, or (refresh) when any install left deps missing. */
1416
+ stale?: boolean;
1417
+ dirs: WorkspaceDepsRefreshDir[];
1418
+ error?: string;
1419
+ }
1420
+
1331
1421
  export interface WorkspaceMetadata {
1332
1422
  id?: string;
1333
1423
  mode: WorkspaceMode;
@@ -2094,3 +2184,33 @@ export interface HealthReport {
2094
2184
  generatedAt: number;
2095
2185
  checks: HealthCheck[];
2096
2186
  }
2187
+
2188
+ // --- Shared micro-helpers (single source of truth; do not re-declare per file) ---
2189
+
2190
+ /** True for a non-null, non-array object. The canonical type guard for the whole repo. */
2191
+ export function isRecord(value: unknown): value is Record<string, unknown> {
2192
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2193
+ }
2194
+
2195
+ /**
2196
+ * Narrow `unknown` to a non-empty trimmed string, else `undefined`.
2197
+ * Settled semantics: whitespace-only is treated as empty (returns `undefined`).
2198
+ */
2199
+ export function stringValue(value: unknown): string | undefined {
2200
+ if (typeof value !== "string") return undefined;
2201
+ const trimmed = value.trim();
2202
+ return trimmed.length > 0 ? trimmed : undefined;
2203
+ }
2204
+
2205
+ /** Extract a human-readable message from any thrown value. */
2206
+ export function errMessage(error: unknown): string {
2207
+ return error instanceof Error ? error.message : String(error);
2208
+ }
2209
+
2210
+ // --- Relay connection defaults ---
2211
+
2212
+ /** Default port the relay server listens on. */
2213
+ export const DEFAULT_RELAY_PORT = 4850;
2214
+
2215
+ /** Default relay base URL. Loopback spelling settled on `127.0.0.1` (not `localhost`). */
2216
+ export const DEFAULT_RELAY_URL = `http://127.0.0.1:${DEFAULT_RELAY_PORT}`;