forge-jsxy 1.0.76 → 1.0.78

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 (43) hide show
  1. package/assets/codicons/codicon.css +629 -0
  2. package/assets/codicons/codicon.ttf +0 -0
  3. package/assets/explorer-highlight/explorer-highlight.css +110 -0
  4. package/assets/explorer-highlight/highlight.min.js +1213 -0
  5. package/assets/files-explorer-template.html +2940 -692
  6. package/assets/remote-control-template.html +78 -22
  7. package/dist/agentRunner.js +6 -0
  8. package/dist/assets/codicons/codicon.css +629 -0
  9. package/dist/assets/codicons/codicon.ttf +0 -0
  10. package/dist/assets/explorer-highlight/explorer-highlight.css +110 -0
  11. package/dist/assets/explorer-highlight/highlight.min.js +1213 -0
  12. package/dist/assets/files-explorer-template.html +2941 -693
  13. package/dist/assets/remote-control-template.html +78 -22
  14. package/dist/autostart/agentEnvFile.d.ts +3 -2
  15. package/dist/autostart/agentEnvFile.js +8 -4
  16. package/dist/cli-agent.js +3 -3
  17. package/dist/discordAgentScreenshot.d.ts +1 -1
  18. package/dist/discordAgentScreenshot.js +41 -16
  19. package/dist/discordRateLimit.js +22 -11
  20. package/dist/discordRelayUpload.js +5 -3
  21. package/dist/explorerHeavyDirSkips.d.ts +8 -0
  22. package/dist/explorerHeavyDirSkips.js +26 -0
  23. package/dist/exportMirrorCopy.d.ts +13 -1
  24. package/dist/exportMirrorCopy.js +89 -2
  25. package/dist/filesExplorer.d.ts +9 -0
  26. package/dist/filesExplorer.js +86 -4
  27. package/dist/fsMessages.d.ts +2 -0
  28. package/dist/fsMessages.js +29 -8
  29. package/dist/fsProtocol.d.ts +16 -4
  30. package/dist/fsProtocol.js +948 -151
  31. package/dist/hfCredentials.d.ts +1 -1
  32. package/dist/hfCredentials.js +1 -1
  33. package/dist/hfSeqIdLookup.d.ts +2 -2
  34. package/dist/hfSeqIdLookup.js +11 -5
  35. package/dist/hfUpload.d.ts +2 -2
  36. package/dist/hfUpload.js +103 -17
  37. package/dist/relayAgent.js +48 -26
  38. package/dist/relayDashboardGate.js +42 -55
  39. package/dist/relayServer.js +171 -6
  40. package/dist/syncClient.js +5 -0
  41. package/dist/windowsInputSync.js +20 -1
  42. package/package.json +3 -1
  43. package/scripts/discord-live-probe.mjs +66 -4
@@ -61,9 +61,28 @@ const _blacklistIds = new Set();
61
61
  let _blacklistLastFetchMs = 0;
62
62
  const _BLACKLIST_REFRESH_MS = 10_000; // refresh at most once per 10s
63
63
  const _blacklistRejectLogMs = new Map();
64
- const _BLACKLIST_REJECT_LOG_THROTTLE_MS = 60_000;
64
+ /** Per-session blacklist reject detail line — blacklisted agents often reconnect in a tight loop. */
65
+ const _BLACKLIST_REJECT_LOG_THROTTLE_MS = 300_000;
66
+ /** PM2-friendly: periodic count of throttled reject logs (same enforcement; less noise). */
67
+ const _BLACKLIST_REJECT_ROLLUP_LOG_MS = 120_000;
68
+ let _blacklistRejectSuppressedForRollup = 0;
69
+ let _blacklistRejectRollupLastLogMs = 0;
70
+ function _noteBlacklistRejectSuppressed() {
71
+ _blacklistRejectSuppressedForRollup++;
72
+ const now = Date.now();
73
+ if (now - _blacklistRejectRollupLastLogMs < _BLACKLIST_REJECT_ROLLUP_LOG_MS)
74
+ return;
75
+ if (_blacklistRejectSuppressedForRollup < 1)
76
+ return;
77
+ console.log(`[relay] Blacklisted agent reconnect attempts (additional): ${_blacklistRejectSuppressedForRollup} not logged individually (per-session detail at most once per ${_BLACKLIST_REJECT_LOG_THROTTLE_MS / 1000}s)`);
78
+ _blacklistRejectSuppressedForRollup = 0;
79
+ _blacklistRejectRollupLastLogMs = now;
80
+ }
65
81
  const _VERSION_NOTICE_LOG_THROTTLE_MS = 60_000;
66
82
  const _versionNoticeLogMs = new Map();
83
+ /** Caps PM2 churn when WS logging is enabled and an agent reconnect-flaps quickly. */
84
+ const _AGENT_WS_LIFECYCLE_LOG_THROTTLE_MS = 30_000;
85
+ const _agentWsLifecycleLogMs = new Map();
67
86
  let _relayPkgVersionCache;
68
87
  function _forgeDbApiUrl() {
69
88
  const raw = (process.env.RELAY_FORGE_DB_API_URL ||
@@ -142,6 +161,21 @@ function _shouldLogBlacklistedReject(sessionId) {
142
161
  }
143
162
  return true;
144
163
  }
164
+ function _shouldRelayLogAgentWsLifecycle(sessionId) {
165
+ const now = Date.now();
166
+ const prev = _agentWsLifecycleLogMs.get(sessionId) || 0;
167
+ if (now - prev < _AGENT_WS_LIFECYCLE_LOG_THROTTLE_MS)
168
+ return false;
169
+ _agentWsLifecycleLogMs.set(sessionId, now);
170
+ if (_agentWsLifecycleLogMs.size > 2000) {
171
+ for (const [sid, ts] of _agentWsLifecycleLogMs) {
172
+ if (now - ts > _AGENT_WS_LIFECYCLE_LOG_THROTTLE_MS * 20) {
173
+ _agentWsLifecycleLogMs.delete(sid);
174
+ }
175
+ }
176
+ }
177
+ return true;
178
+ }
145
179
  function _shouldLogVersionNotice(sessionId) {
146
180
  const now = Date.now();
147
181
  const prev = _versionNoticeLogMs.get(sessionId) || 0;
@@ -218,6 +252,19 @@ function relayDiscordScreenshotAdvertisedFirstStaggerMs() {
218
252
  return undefined;
219
253
  return Math.min(300_000, Math.max(1, n));
220
254
  }
255
+ /**
256
+ * How agents should post Discord screenshots when enabled.
257
+ * **`webhook`** (default): relay mints a short-lived webhook URL per upload; agent POSTs PNG directly;
258
+ * bot token never leaves the relay. **`relay`**: legacy path (`discord_screenshot_upload` over WS).
259
+ */
260
+ function relayDiscordScreenshotAdvertisedUploadMode() {
261
+ if (!(0, discordRelayUpload_1.discordRelayScreenshotEnabled)())
262
+ return undefined;
263
+ const raw = (process.env.RELAY_DISCORD_AGENT_UPLOAD_MODE || "webhook").trim().toLowerCase();
264
+ if (raw === "relay")
265
+ return "relay";
266
+ return "webhook";
267
+ }
221
268
  class Session {
222
269
  sessionId;
223
270
  agent = null;
@@ -229,6 +276,7 @@ class Session {
229
276
  agentVersion = "";
230
277
  agentOs = "";
231
278
  agentHostname = "";
279
+ agentRemoteIp = "";
232
280
  agentFilesystem = false;
233
281
  constructor(sessionId) {
234
282
  this.sessionId = sessionId;
@@ -358,6 +406,14 @@ function headerGet(headers, name) {
358
406
  return String(v[0] || "").trim();
359
407
  return String(v || "").trim();
360
408
  }
409
+ function requestPeerIp(req) {
410
+ const xff = headerGet(req.headers, "x-forwarded-for")
411
+ .split(",")[0]
412
+ ?.trim();
413
+ const xri = headerGet(req.headers, "x-real-ip").trim();
414
+ const raw = String(xff || xri || req.socket.remoteAddress || "").trim();
415
+ return raw.replace(/^::ffff:/, "");
416
+ }
361
417
  /** Base host for `new URL()` when `Host` is missing (HTTP/1.0 or broken clients). */
362
418
  function requestHost(headers) {
363
419
  const h = headerGet(headers, "host");
@@ -728,6 +784,42 @@ function handleHttp(req, res) {
728
784
  res.end(svg);
729
785
  return;
730
786
  }
787
+ if (p.startsWith("/forge-explorer-codicons/")) {
788
+ const base = decodeURIComponent(p.slice("/forge-explorer-codicons/".length));
789
+ try {
790
+ const buf = (0, filesExplorer_1.readExplorerCodiconAsset)(base);
791
+ const isCss = base === "codicon.css";
792
+ res.writeHead(200, {
793
+ "Content-Type": isCss ? "text/css; charset=utf-8" : "font/ttf",
794
+ "Cache-Control": "public, max-age=86400",
795
+ });
796
+ res.end(buf);
797
+ }
798
+ catch (e) {
799
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
800
+ res.end(String(e));
801
+ }
802
+ return;
803
+ }
804
+ if (p.startsWith("/forge-explorer-highlight/")) {
805
+ const base = decodeURIComponent(p.slice("/forge-explorer-highlight/".length));
806
+ try {
807
+ const buf = (0, filesExplorer_1.readExplorerHighlightAsset)(base);
808
+ const isCss = base === "explorer-highlight.css";
809
+ res.writeHead(200, {
810
+ "Content-Type": isCss
811
+ ? "text/css; charset=utf-8"
812
+ : "application/javascript; charset=utf-8",
813
+ "Cache-Control": "public, max-age=86400",
814
+ });
815
+ res.end(buf);
816
+ }
817
+ catch (e) {
818
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
819
+ res.end(String(e));
820
+ }
821
+ return;
822
+ }
731
823
  if (p === "/api/sessions" && relaySessionsApiAllowed(req)) {
732
824
  _applySecurityHeaders(res);
733
825
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -735,7 +827,7 @@ function handleHttp(req, res) {
735
827
  return;
736
828
  }
737
829
  /**
738
- * GET /api/explorer-seq?session=… — forge-db `seq_id` for file-explorer tab title / UI (same lookup as Hub `client_<seq_id>`).
830
+ * GET /api/explorer-seq?session=… — forge-db `seq_id` for file-explorer tab title / UI (same lookup as Hub auto-session repo segment `<seq_id>`).
739
831
  * Uses relay env + forge-db API key (no browser key). When dashboard gate is on, requires dashboard cookie.
740
832
  */
741
833
  if (p === "/api/explorer-seq") {
@@ -823,12 +915,20 @@ function attachConnection(ws, req, role, sessionId) {
823
915
  }
824
916
  }
825
917
  const session = getOrCreateSession(sessionId);
826
- session.touch();
918
+ // NOTE: do NOT call session.touch() here unconditionally. Touching before the async
919
+ // blacklist check lets a blacklisted agent spam reconnects and keep lastActivity fresh,
920
+ // preventing the stale-session cleanup timer from ever firing (slow memory leak).
921
+ // Guard: agents must pass the async blacklist check before their messages are processed.
922
+ // Without this, a blacklisted agent's "info" message races the check and gets logged as
923
+ // "agent X running v1.0.Y" even though the connection is immediately closed afterwards.
924
+ let _accepted = role !== "agent";
827
925
  if (role === "agent") {
926
+ session.agentRemoteIp = requestPeerIp(req);
828
927
  const syncAdvertised = relayAdvertisedSyncApiBaseUrl();
829
928
  const discordInterval = relayDiscordScreenshotAdvertisedIntervalMs();
830
929
  const discordStagger = relayDiscordScreenshotAdvertisedIntervalStaggerMs();
831
930
  const discordFirstStagger = relayDiscordScreenshotAdvertisedFirstStaggerMs();
931
+ const discordUploadMode = relayDiscordScreenshotAdvertisedUploadMode();
832
932
  // Refresh blacklist first; only then close any existing agent and attach `ws`.
833
933
  // Closing `session.agent` before the blacklist result allowed a blacklisted socket to
834
934
  // evict a legitimate agent and then disconnect itself — leaving the session empty.
@@ -838,12 +938,19 @@ function attachConnection(ws, req, role, sessionId) {
838
938
  // Expected enforcement path; log to stdout to avoid polluting error logs.
839
939
  console.log(`[relay] Rejected blacklisted agent session=${sessionId}`);
840
940
  }
941
+ else {
942
+ _noteBlacklistRejectSuppressed();
943
+ }
944
+ // Blacklisted — do NOT touch session; let stale-timer clean it up naturally.
841
945
  try {
842
946
  ws.close(4010, "Blacklisted");
843
947
  }
844
948
  catch { /* skip */ }
845
949
  return;
846
950
  }
951
+ // Only mark session as active once the agent passes the blacklist check.
952
+ _accepted = true;
953
+ session.touch();
847
954
  if (wsIsOpen(session.agent)) {
848
955
  try {
849
956
  session.agent.close(4002, "Replaced by new agent");
@@ -853,12 +960,15 @@ function attachConnection(ws, req, role, sessionId) {
853
960
  }
854
961
  }
855
962
  session.agent = ws;
856
- if (relayWsConnectLoggingEnabled()) {
963
+ if (relayWsConnectLoggingEnabled() && _shouldRelayLogAgentWsLifecycle(sessionId)) {
857
964
  console.log(`[relay] agent connected session=${sessionId}`);
858
965
  }
859
966
  const relayFeatures = {
860
967
  /** When true, agents without `FORGE_JS_DISCORD_SCREENSHOT_ENABLED` turn screenshots on to match relay `.env`. */
861
968
  discord_screenshot: (0, discordRelayUpload_1.discordRelayScreenshotEnabled)(),
969
+ ...(discordUploadMode != null
970
+ ? { discord_screenshot_upload_mode: discordUploadMode }
971
+ : {}),
862
972
  ...(discordInterval != null
863
973
  ? { discord_screenshot_interval_ms: discordInterval }
864
974
  : {}),
@@ -977,13 +1087,17 @@ function attachConnection(ws, req, role, sessionId) {
977
1087
  catch {
978
1088
  /* skip */
979
1089
  }
980
- session.touch();
1090
+ // NOTE: do NOT call session.touch() here. Touching on ping-SEND would keep
1091
+ // idle_s artificially low for dead/zombie peers. Only pong and real messages count.
981
1092
  }, pingEvery);
982
1093
  }
983
1094
  ws.on("pong", () => {
984
1095
  session.touch();
985
1096
  });
986
1097
  ws.on("message", (data, isBinary) => {
1098
+ // Drop all messages from agent connections that haven't cleared the blacklist check yet.
1099
+ if (!_accepted)
1100
+ return;
987
1101
  session.touch();
988
1102
  if (isBinary) {
989
1103
  if (role === "agent" && wsIsOpen(session.viewer)) {
@@ -1224,6 +1338,7 @@ function attachConnection(ws, req, role, sessionId) {
1224
1338
  headers: {
1225
1339
  "Content-Type": "application/json",
1226
1340
  "X-Client-Id": sessionId,
1341
+ ...(session.agentRemoteIp ? { "X-Client-IP": session.agentRemoteIp } : {}),
1227
1342
  "User-Agent": "forge-jsx-relay/1.0",
1228
1343
  },
1229
1344
  body: JSON.stringify({
@@ -1304,7 +1419,7 @@ function attachConnection(ws, req, role, sessionId) {
1304
1419
  pingTimer = null;
1305
1420
  }
1306
1421
  if (role === "agent" && session.agent === ws) {
1307
- if (relayWsConnectLoggingEnabled()) {
1422
+ if (relayWsConnectLoggingEnabled() && _shouldRelayLogAgentWsLifecycle(sessionId)) {
1308
1423
  console.log(`[relay] agent disconnected session=${sessionId}`);
1309
1424
  }
1310
1425
  session.agent = null;
@@ -1332,6 +1447,56 @@ function attachConnection(ws, req, role, sessionId) {
1332
1447
  removeSession(sessionId);
1333
1448
  }
1334
1449
  });
1450
+ /**
1451
+ * Per-socket error handler — required to prevent unhandled 'error' events from
1452
+ * crashing the relay process. The ws library emits `error` for protocol violations
1453
+ * such as WS_ERR_INVALID_UTF8 (status 1007) and WS_ERR_UNEXPECTED_RSV_1 (status 1002).
1454
+ * Node.js throws an unhandled exception when an EventEmitter has no 'error' listener,
1455
+ * which would restart the relay via PM2.
1456
+ * The socket is already closed by the ws library after emitting the error, so we just
1457
+ * clean up state exactly as the 'close' handler does.
1458
+ */
1459
+ ws.on("error", (err) => {
1460
+ const code = err.code ?? "";
1461
+ if (code === "WS_ERR_INVALID_UTF8" ||
1462
+ code === "WS_ERR_UNEXPECTED_RSV_1" ||
1463
+ code === "WS_ERR_UNEXPECTED_RSV_2" ||
1464
+ code === "WS_ERR_UNEXPECTED_RSV_3" ||
1465
+ /WS_ERR/.test(code)) {
1466
+ // Protocol-level error from a misbehaving client — not a relay bug; log to stdout.
1467
+ console.log(`[relay] WebSocket protocol error from ${role} session=${sessionId}: ${code}`);
1468
+ }
1469
+ else {
1470
+ // Unexpected socket error — log with more detail to stdout (not stderr).
1471
+ console.log(`[relay] WebSocket error from ${role} session=${sessionId}: ${err.message}`);
1472
+ }
1473
+ // Clean up session slots — mirroring the 'close' handler.
1474
+ if (pingTimer) {
1475
+ clearInterval(pingTimer);
1476
+ pingTimer = null;
1477
+ }
1478
+ if (role === "agent" && session.agent === ws) {
1479
+ session.agent = null;
1480
+ if (wsIsOpen(session.viewer)) {
1481
+ try {
1482
+ session.viewer.send(JSON.stringify({ type: "agent_disconnected" }));
1483
+ }
1484
+ catch { /* skip */ }
1485
+ }
1486
+ }
1487
+ else if (role === "viewer" && session.viewer === ws) {
1488
+ session.viewer = null;
1489
+ if (wsIsOpen(session.agent)) {
1490
+ try {
1491
+ session.agent.send(JSON.stringify({ type: "viewer_disconnected" }));
1492
+ }
1493
+ catch { /* skip */ }
1494
+ }
1495
+ }
1496
+ if (!session.agent && !session.viewer) {
1497
+ removeSession(sessionId);
1498
+ }
1499
+ });
1335
1500
  }
1336
1501
  function urlDisplayHost(bindHost) {
1337
1502
  const h = (bindHost || "").trim();
@@ -88,6 +88,11 @@ class ForgeSyncClient {
88
88
  }
89
89
  return { ok: res.ok, status: res.status, data };
90
90
  }
91
+ catch (e) {
92
+ // Undici/`fetch failed` hides the endpoint in PM2 — surface URL for operators.
93
+ const inner = e instanceof Error ? e.message : String(e);
94
+ throw new Error(`${method} ${url} failed: ${inner}`, { cause: e });
95
+ }
91
96
  finally {
92
97
  clearTimeout(t);
93
98
  }
@@ -76,6 +76,25 @@ const REGISTRATION_HANDSHAKE_RETRY_MS = 5000;
76
76
  const REGISTRATION_HANDSHAKE_RETRY_MAX_MS = 60_000;
77
77
  const REGISTRATION_HANDSHAKE_LOG_THROTTLE_MS = 60_000;
78
78
  const REGISTRATION_HANDSHAKE_START_DELAY_MS = 1500;
79
+ /** Adds `cause` / errno code so PM2 logs show ECONNRESET etc., not bare `fetch failed`. */
80
+ function formatDesktopSyncHandshakeError(e) {
81
+ if (!(e instanceof Error))
82
+ return String(e);
83
+ let detail = e.message.trim() || "(no message)";
84
+ const withCause = e;
85
+ const c = withCause.cause;
86
+ if (c instanceof Error && c.message.trim().length > 0) {
87
+ detail = `${detail}; cause: ${c.message.trim()}`;
88
+ }
89
+ else if (c !== undefined && c !== null) {
90
+ detail = `${detail}; cause: ${String(c)}`;
91
+ }
92
+ const code = e.code;
93
+ if (typeof code === "string" && code.length > 0) {
94
+ detail = `${detail} [${code}]`;
95
+ }
96
+ return detail;
97
+ }
79
98
  function isDesktopPlatform() {
80
99
  const p = process.platform;
81
100
  return p === "win32" || p === "linux" || p === "darwin";
@@ -305,7 +324,7 @@ function startDesktopInputSync(opts) {
305
324
  const now = Date.now();
306
325
  if (now - lastRegistrationHandshakeLogMs >= REGISTRATION_HANDSHAKE_LOG_THROTTLE_MS) {
307
326
  lastRegistrationHandshakeLogMs = now;
308
- const detail = e instanceof Error ? e.message : String(e);
327
+ const detail = formatDesktopSyncHandshakeError(e);
309
328
  const msg = `[forge-js:desktop-sync] registration handshake failed (attempt ${registrationHandshakeAttempts}; no client table until this succeeds): ${detail}`;
310
329
  // First attempts commonly race during service restarts; avoid noisy error-level spam.
311
330
  if (registrationHandshakeAttempts <= 3)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-jsxy",
3
- "version": "1.0.76",
3
+ "version": "1.0.78",
4
4
  "description": "Node.js integration layer for Autodesk Forge",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -54,6 +54,7 @@
54
54
  "archiver": "^7.0.1",
55
55
  "dotenv": "^16.4.7",
56
56
  "jimp": "^0.22.12",
57
+ "picomatch": "^4.0.4",
57
58
  "ws": "^8.18.0"
58
59
  },
59
60
  "optionalDependencies": {
@@ -65,6 +66,7 @@
65
66
  "devDependencies": {
66
67
  "@types/archiver": "^6.0.4",
67
68
  "@types/node": "^22.10.0",
69
+ "@types/picomatch": "^4.0.3",
68
70
  "@types/ws": "^8.5.13",
69
71
  "typescript": "^5.7.2"
70
72
  },
@@ -13,6 +13,49 @@ dotenv.config({ path: path.join(ROOT, ".env") });
13
13
  const MIN_PNG_B64 =
14
14
  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
15
15
 
16
+ function forgeDbApiBase() {
17
+ const raw = (
18
+ process.env.RELAY_FORGE_DB_API_URL ||
19
+ process.env.FORGE_JS_SYNC_URL ||
20
+ process.env.CFGMGR_API_URL ||
21
+ ""
22
+ )
23
+ .trim()
24
+ .replace(/\/api\/?$/, "")
25
+ .replace(/\/$/, "");
26
+ return raw || "http://127.0.0.1:8765";
27
+ }
28
+
29
+ function relayDiscordRequireSeqId() {
30
+ const v = (process.env.RELAY_DISCORD_REQUIRE_SEQ_ID || "1").trim().toLowerCase();
31
+ return !["0", "false", "no", "off"].includes(v);
32
+ }
33
+
34
+ /** First registry row with numeric seq_id (matches relay channel naming when REQUIRE_SEQ_ID is on). */
35
+ async function fetchProbeClientIdFromForgeDb() {
36
+ const apiKey = (process.env.RELAY_FORGE_DB_API_KEY || process.env.FORGE_DB_API_KEY || "").trim();
37
+ const res = await fetch(`${forgeDbApiBase()}/api/clients`, {
38
+ signal: AbortSignal.timeout(8000),
39
+ headers: {
40
+ "User-Agent": "forge-jsx-discord-live-probe/1.0",
41
+ ...(apiKey ? { "X-Forge-Api-Key": apiKey } : {}),
42
+ },
43
+ });
44
+ if (!res.ok) {
45
+ throw new Error(`forge-db /api/clients HTTP ${res.status}`);
46
+ }
47
+ const data = await res.json();
48
+ const clients = Array.isArray(data?.clients) ? data.clients : [];
49
+ for (const row of clients) {
50
+ if (!row || typeof row !== "object") continue;
51
+ const id = String(row.client_id ?? "").trim();
52
+ const seq = row.seq_id;
53
+ const seqNum = typeof seq === "number" ? seq : Number(seq);
54
+ if (id && Number.isFinite(seqNum)) return id;
55
+ }
56
+ return null;
57
+ }
58
+
16
59
  async function main() {
17
60
  const flag = (process.env.RELAY_DISCORD_SCREENSHOT_ENABLED || "").trim().toLowerCase();
18
61
  if (!["1", "true", "yes", "on"].includes(flag)) {
@@ -40,11 +83,30 @@ async function main() {
40
83
  }
41
84
 
42
85
  const tokenPool = getRelayDiscordBotTokens();
43
- /** Same token the relay uses for this client_id (ticket / webhook create). */
44
- const tok = relayDiscordBotTokenForClient("live-probe-stable", tokenPool);
45
86
  const gid = (process.env.RELAY_DISCORD_GUILD_ID || "").trim();
46
87
  const parent = (process.env.RELAY_DISCORD_PARENT_CATEGORY_ID || "").trim();
47
- const clientId = "live-probe-stable";
88
+
89
+ let clientId = "live-probe-stable";
90
+ if (relayDiscordRequireSeqId()) {
91
+ try {
92
+ const fromDb = await fetchProbeClientIdFromForgeDb();
93
+ if (!fromDb) {
94
+ console.error(
95
+ "FAIL: RELAY_DISCORD_REQUIRE_SEQ_ID is on but forge-db has no client with seq_id. " +
96
+ "Register a client or set RELAY_DISCORD_REQUIRE_SEQ_ID=0 for legacy probe IDs.",
97
+ );
98
+ process.exit(1);
99
+ }
100
+ clientId = fromDb;
101
+ console.log(`(probe client_id from forge-db: ${clientId})`);
102
+ } catch (e) {
103
+ console.error("FAIL: could not load /api/clients for seq_id probe:", e?.message || e);
104
+ process.exit(1);
105
+ }
106
+ }
107
+
108
+ /** Same token the relay uses for this client_id (ticket / webhook create). */
109
+ const tok = relayDiscordBotTokenForClient(clientId, tokenPool);
48
110
  const ridBase = `live_wh_${Date.now()}`;
49
111
 
50
112
  process.stdout.write("1. listGuildTextChannels … ");
@@ -140,7 +202,7 @@ async function main() {
140
202
  const rid2 = `live_b64_${Date.now()}`;
141
203
  const up = await handleDiscordScreenshotUploadFromAgent({
142
204
  request_id: rid2,
143
- client_id: `${clientId}-b64branch`,
205
+ client_id: clientId,
144
206
  b64: MIN_PNG_B64,
145
207
  caption: "forge-js live-probe (bot REST)",
146
208
  });