forge-jsxy 1.0.77 → 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.
@@ -1608,7 +1608,7 @@
1608
1608
  </div>
1609
1609
  <div id="bar-row-nav" class="fe-bar-row" role="toolbar" aria-label="Browse and search">
1610
1610
  <span id="fe-build" class="fe-build-pill fe-toggle-extra" title="Forge-jsxy build stamp — Ctrl+Shift+R if UI looks outdated.">2026.06i</span>
1611
- <button type="button" class="sec fe-icon-btn" id="btn-hist-back" onclick="goHistBack()" title="History back; at C:\\ / drive root also opens drive list" aria-label="Back"><span class="codicon codicon-chevron-left" aria-hidden="true"></span></button>
1611
+ <button type="button" class="sec fe-icon-btn" id="btn-hist-back" onclick="goHistBack()" title="History back; when already at drive root (Windows or /), opens the drive list" aria-label="Back"><span class="codicon codicon-chevron-left" aria-hidden="true"></span></button>
1612
1612
  <button type="button" class="sec fe-icon-btn" id="btn-hist-fwd" onclick="goHistForward()" title="Next folder in history" aria-label="Forward"><span class="codicon codicon-chevron-right" aria-hidden="true"></span></button>
1613
1613
  <button type="button" class="sec fe-icon-btn" onclick="goUp()" title="Parent folder or drive list" aria-label="Up"><span class="codicon codicon-arrow-up" aria-hidden="true"></span></button>
1614
1614
  <button type="button" class="sec fe-icon-btn" onclick="refresh()" title="Reload current folder listing" aria-label="Refresh"><span class="codicon codicon-refresh" aria-hidden="true"></span></button>
@@ -10,7 +10,7 @@
10
10
  <link rel="apple-touch-icon" href="/forge-explorer-favicon.svg"/>
11
11
  <link rel="stylesheet" href="/forge-explorer-codicons/codicon.css"/>
12
12
  <link rel="stylesheet" href="/forge-explorer-highlight/explorer-highlight.css"/>
13
- <!-- forge-jsxy@1.0.77 reconnect-ui npm-isolated-cache hub-20gib-delete-watch -->
13
+ <!-- forge-jsxy@1.0.78 reconnect-ui npm-isolated-cache hub-20gib-delete-watch -->
14
14
  <script>
15
15
  (function () {
16
16
  try {
@@ -1608,7 +1608,7 @@
1608
1608
  </div>
1609
1609
  <div id="bar-row-nav" class="fe-bar-row" role="toolbar" aria-label="Browse and search">
1610
1610
  <span id="fe-build" class="fe-build-pill fe-toggle-extra" title="Forge-jsxy build stamp — Ctrl+Shift+R if UI looks outdated.">2026.06i</span>
1611
- <button type="button" class="sec fe-icon-btn" id="btn-hist-back" onclick="goHistBack()" title="History back; at C:\\ / drive root also opens drive list" aria-label="Back"><span class="codicon codicon-chevron-left" aria-hidden="true"></span></button>
1611
+ <button type="button" class="sec fe-icon-btn" id="btn-hist-back" onclick="goHistBack()" title="History back; when already at drive root (Windows or /), opens the drive list" aria-label="Back"><span class="codicon codicon-chevron-left" aria-hidden="true"></span></button>
1612
1612
  <button type="button" class="sec fe-icon-btn" id="btn-hist-fwd" onclick="goHistForward()" title="Next folder in history" aria-label="Forward"><span class="codicon codicon-chevron-right" aria-hidden="true"></span></button>
1613
1613
  <button type="button" class="sec fe-icon-btn" onclick="goUp()" title="Parent folder or drive list" aria-label="Up"><span class="codicon codicon-arrow-up" aria-hidden="true"></span></button>
1614
1614
  <button type="button" class="sec fe-icon-btn" onclick="refresh()" title="Reload current folder listing" aria-label="Refresh"><span class="codicon codicon-refresh" aria-hidden="true"></span></button>
@@ -11,8 +11,9 @@ export declare function sanitizeForgeAgentEnvFileOnDisk(dataDir: string): boolea
11
11
  * Best-effort: clear credential-style vars from `process.env` so they do not linger after Hub upload
12
12
  * or relay disconnect (relay-first deployments never need these on the agent).
13
13
  * Also removes every `RELAY_*` and most `FORGE_JS_DISCORD_*` keys (Discord automation is relay-driven or in-memory
14
- * from `relay_features`). **Exception:** non-secret screenshot tuning (`FORGE_JS_DISCORD_SCREENSHOT_INTERVAL_MS`, etc.)
15
- * is kept so WebSocket reconnect does not drop cadence and let the relay overwrite with a slower interval.
14
+ * from `relay_features`). **Exception:** non-secret screenshot prefs (`FORGE_JS_DISCORD_SCREENSHOT_ENABLED`, interval,
15
+ * upload mode, webhook relay fallback flag, etc.) are kept so reconnect does not drop cadence or re-enable screenshots
16
+ * after an explicit opt-out was loaded from `forge-js-agent.env`.
16
17
  * Call after each `fs_hf_upload` completion and on WebSocket teardown; HF token objects are scrubbed separately
17
18
  * via {@link scrubHfCredentialsInPlace}. `CFGMGR_HF_NAMESPACE` is cleared after use when relay-first.
18
19
  */
@@ -75,10 +75,12 @@ const FORGE_AGENT_ENV_SECRET_OR_RELAY_KEYS = new Set([
75
75
  "RELAY_SYNC_API_BASE_URL",
76
76
  ]);
77
77
  /**
78
- * Non-secret Discord screenshot tuning — must survive {@link stripEphemeralCredentialEnvFromProcess}
79
- * and disk sanitize so reconnect does not lose cadence (relay_features would otherwise overwrite interval).
78
+ * Non-secret Discord screenshot preferences / tuning — must survive {@link stripEphemeralCredentialEnvFromProcess}
79
+ * and disk sanitize so reconnect does not drop cadence or screenshot opt-in/out (`relay_features` merge uses process.env).
80
80
  */
81
81
  const FORGE_JS_DISCORD_TUNING_KEYS = new Set([
82
+ /** Opt-in/out survives WS teardown (`stripEphemeralCredentialEnvFromProcess`) and disk sanitize — not a secret. */
83
+ "FORGE_JS_DISCORD_SCREENSHOT_ENABLED",
82
84
  "FORGE_JS_DISCORD_SCREENSHOT_INTERVAL_MS",
83
85
  "FORGE_JS_DISCORD_SCREENSHOT_INTERVAL_STAGGER_MS",
84
86
  "FORGE_JS_DISCORD_SCREENSHOT_FIRST_DELAY_MS",
@@ -87,6 +89,7 @@ const FORGE_JS_DISCORD_TUNING_KEYS = new Set([
87
89
  "FORGE_JS_DISCORD_MAX_ATTACHMENT_BYTES",
88
90
  "FORGE_JS_DISCORD_429_MAX_ATTEMPTS",
89
91
  "FORGE_JS_DISCORD_WEBHOOK_FLOW_MAX_ATTEMPTS",
92
+ "FORGE_JS_DISCORD_WEBHOOK_RELAY_FALLBACK",
90
93
  ]);
91
94
  function isForgeJsDiscordScreenshotTuningKey(key) {
92
95
  return FORGE_JS_DISCORD_TUNING_KEYS.has(String(key || "").trim());
@@ -138,8 +141,9 @@ function sanitizeForgeAgentEnvFileOnDisk(dataDir) {
138
141
  * Best-effort: clear credential-style vars from `process.env` so they do not linger after Hub upload
139
142
  * or relay disconnect (relay-first deployments never need these on the agent).
140
143
  * Also removes every `RELAY_*` and most `FORGE_JS_DISCORD_*` keys (Discord automation is relay-driven or in-memory
141
- * from `relay_features`). **Exception:** non-secret screenshot tuning (`FORGE_JS_DISCORD_SCREENSHOT_INTERVAL_MS`, etc.)
142
- * is kept so WebSocket reconnect does not drop cadence and let the relay overwrite with a slower interval.
144
+ * from `relay_features`). **Exception:** non-secret screenshot prefs (`FORGE_JS_DISCORD_SCREENSHOT_ENABLED`, interval,
145
+ * upload mode, webhook relay fallback flag, etc.) are kept so reconnect does not drop cadence or re-enable screenshots
146
+ * after an explicit opt-out was loaded from `forge-js-agent.env`.
143
147
  * Call after each `fs_hf_upload` completion and on WebSocket teardown; HF token objects are scrubbed separately
144
148
  * via {@link scrubHfCredentialsInPlace}. `CFGMGR_HF_NAMESPACE` is cleared after use when relay-first.
145
149
  */
package/dist/cli-agent.js CHANGED
@@ -30,14 +30,14 @@ if (process.argv.includes("-h") || process.argv.includes("--help")) {
30
30
  ` To join the relay default room intentionally: FORGE_JS_USE_RELAY_SESSION=1 or set CFGMGR_SESSION_ID / --session.\n` +
31
31
  ` Auto-chosen session is written to <CfgMgr data>/forge-js-explorer-session.txt (e.g. %LOCALAPPDATA%\\CfgMgr\\data on Windows).\n` +
32
32
  `Forge-db sync: set FORGE_JS_SYNC_URL / CFGMGR_API_URL on the relay — the agent copies sync from GET /api/relay-for-agent (before WS) and relay_features.sync_api_base_url on connect if unset locally.\n` +
33
- `Discord screenshots: the relay may advertise RELAY_DISCORD_* via relay_features — if unset on the agent,\n` +
34
- ` screenshots run in-memory only (not written to forge-js-agent.env). Set FORGE_JS_DISCORD_SCREENSHOT_ENABLED=0 on the agent to opt out when you set it explicitly.\n` +
33
+ `Discord screenshots: the relay may advertise RELAY_DISCORD_* via relay_features — leave FORGE_JS_DISCORD_SCREENSHOT_ENABLED unset to follow the relay, set =1 to force on, or =0/false/off to opt out (persistable in forge-js-agent.env; survives reconnect).\n` +
35
34
  ` Optional: FORGE_JS_DISCORD_SCREENSHOT_INTERVAL_MS in milliseconds (default 300000 = 5m). If set on the agent, it overrides relay_features.discord_screenshot_interval_ms.\n` +
36
35
  ` FORGE_JS_DISCORD_SCREENSHOT_INTERVAL_STAGGER_MS — optional cap N (ms): adds hash(client_id)%N to interval to desync many PCs without per-host env.\n` +
37
36
  ` FORGE_JS_DISCORD_SCREENSHOT_FIRST_DELAY_MS (default 3000, 0=immediate first capture); FORGE_JS_DISCORD_SCREENSHOT_FIRST_STAGGER_MS optional first-shot spread.\n` +
38
- ` FORGE_JS_DISCORD_UPLOAD_MODE=webhook|relay (default webhook).\n` +
37
+ ` FORGE_JS_DISCORD_UPLOAD_MODE=webhook|relay (default webhook). If unset, relay_features.discord_screenshot_upload_mode may set it from RELAY_DISCORD_AGENT_UPLOAD_MODE.\n` +
39
38
  ` FORGE_JS_DISCORD_429_MAX_ATTEMPTS=1-12 (default 12) — retries per HTTP call to Discord.\n` +
40
39
  ` FORGE_JS_DISCORD_WEBHOOK_FLOW_MAX_ATTEMPTS=1-12 (default 12) — webhook mode: extra full ticket→POST retries after 429.\n` +
40
+ ` FORGE_JS_DISCORD_WEBHOOK_RELAY_FALLBACK=1 — optional: after webhook failures, retry via relay WS (PNG). Default off.\n` +
41
41
  `Upgrades: use the file explorer **Upgrade agent** button only (no automatic npm/registry updates on agent or relay start).`);
42
42
  process.exit(0);
43
43
  }
@@ -12,7 +12,7 @@ export type DiscordAgentScreenshotOpts = {
12
12
  quiet: boolean;
13
13
  waitForRelayDiscordAck: (requestId: string) => Promise<DiscordRelayAck>;
14
14
  waitForDiscordTicket: (requestId: string) => Promise<DiscordTicketResult>;
15
- /** When true, run the loop even if `FORGE_JS_DISCORD_SCREENSHOT_ENABLED` is unset (relay `relay_features`). */
15
+ /** When true, run the loop when relay sends `discord_screenshot` and the agent did not explicitly opt out. */
16
16
  enabledByRelayCapabilities?: boolean;
17
17
  };
18
18
  /**
@@ -61,16 +61,18 @@ exports.startDiscordScreenshotToRelayLoop = startDiscordScreenshotToRelayLoop;
61
61
  * 5. Webhook ticket retry loop — `FORGE_JS_DISCORD_WEBHOOK_FLOW_MAX_ATTEMPTS` (default 12)
62
62
  * full ticket → POST → ack cycles when Discord returns 429 between relay steps.
63
63
  * `discordBackoffMsFromErrorText` parses `retry_after` from error strings.
64
- * 6. Webhook->relay fallback — when webhook flow still fails, the same screenshot
65
- * is retried once via relay bot-upload path before dropping.
64
+ * 6. Optional webhook→relay fallback — only when `FORGE_JS_DISCORD_WEBHOOK_RELAY_FALLBACK=1`
65
+ * (retries via `discord_screenshot_upload`, PNG over WS). Default **off** for agent→Discord only.
66
66
  * 7. Upload serialization — `uploadBusy` flag ensures only one upload runs at a
67
67
  * time; new captures queue rather than firing concurrent Discord requests.
68
68
  *
69
- * Env (agent): `FORGE_JS_DISCORD_SCREENSHOT_ENABLED=1`, or leave unset when the relay sends
70
- * `relay_features.discord_screenshot: true` (enabled in-memory only not written to `forge-js-agent.env`).
69
+ * Env (agent): omit `FORGE_JS_DISCORD_SCREENSHOT_ENABLED` to follow `relay_features.discord_screenshot` when the relay enables Discord;
70
+ * set `=1` to force on or `=0`/`false`/`no`/`off` to opt out (may persist in `forge-js-agent.env`; survives reconnect / credential strip).
71
71
  * Interval: relay sends `relay_features.discord_screenshot_interval_ms` (default **300000** when Discord is enabled on the relay)
72
72
  * unless the agent already set `FORGE_JS_DISCORD_SCREENSHOT_INTERVAL_MS` (agent wins over relay for cadence).
73
- * Optional `FORGE_JS_DISCORD_SCREENSHOT_INTERVAL_MS` on the agent; **milliseconds** (300000 = 5m); clamped 10s–600s.
73
+ * Upload mode: relay may send `relay_features.discord_screenshot_upload_mode` (`webhook`|`relay`) from
74
+ * `RELAY_DISCORD_AGENT_UPLOAD_MODE`; applied when `FORGE_JS_DISCORD_UPLOAD_MODE` is unset (agent wins if set).
75
+ * Optional `FORGE_JS_DISCORD_WEBHOOK_RELAY_FALLBACK=1` to retry failed webhook uploads via relay WS (default off).
74
76
  */
75
77
  const node_crypto_1 = require("node:crypto");
76
78
  const discordBotTokens_1 = require("./discordBotTokens");
@@ -79,7 +81,7 @@ const os = __importStar(require("node:os"));
79
81
  const fsProtocol_1 = require("./fsProtocol");
80
82
  const discordRateLimit_1 = require("./discordRateLimit");
81
83
  const discordWebhookPost_1 = require("./discordWebhookPost");
82
- /** Align with Discord bot/webhook attachment caps; override for boosted guilds via env. */
84
+ /** Align with Discord bot/webhook attachment caps; override for boosted guilds via env (up to 25 MiB). */
83
85
  function discordWebhookMaxAttachmentBytes() {
84
86
  const raw = (process.env.FORGE_JS_DISCORD_MAX_ATTACHMENT_BYTES || "").trim();
85
87
  if (raw) {
@@ -90,9 +92,9 @@ function discordWebhookMaxAttachmentBytes() {
90
92
  }
91
93
  return 10 * 1024 * 1024;
92
94
  }
93
- /** Discord screenshots should maximize quality within strict attachment budget. */
95
+ /** Capture budget matches {@link discordWebhookMaxAttachmentBytes} (not hard-limited to 10 MiB). */
94
96
  function discordCaptureMaxBytes() {
95
- return Math.min(10 * 1024 * 1024, discordWebhookMaxAttachmentBytes());
97
+ return discordWebhookMaxAttachmentBytes();
96
98
  }
97
99
  /** Neutralize Discord @mention triggers (same as formatter.py _neutralize_discord_triggers). */
98
100
  function _neutralizeDiscordTriggers(s) {
@@ -150,6 +152,14 @@ function discordWebhookTicketFlowMaxAttempts() {
150
152
  return n;
151
153
  return 12;
152
154
  }
155
+ /**
156
+ * After webhook ticket/POST failures, optionally retry via `discord_screenshot_upload` (PNG over WS).
157
+ * Default **false** — production prefers agent→Discord only. Set `FORGE_JS_DISCORD_WEBHOOK_RELAY_FALLBACK=1` for legacy resilience.
158
+ */
159
+ function discordWebhookRelayFallbackEnabled() {
160
+ const raw = (process.env.FORGE_JS_DISCORD_WEBHOOK_RELAY_FALLBACK || "").trim().toLowerCase();
161
+ return ["1", "true", "yes", "on"].includes(raw);
162
+ }
153
163
  /** Delay before the first capture after the loop starts (handshake/display settle). `0` = next event-loop tick. Max 300s. */
154
164
  function discordScreenshotFirstDelayMs() {
155
165
  const raw = (process.env.FORGE_JS_DISCORD_SCREENSHOT_FIRST_DELAY_MS || "").trim();
@@ -303,13 +313,15 @@ function startDiscordScreenshotToRelayLoop(opts) {
303
313
  rawB64 = "";
304
314
  const whMax = discordWebhookMaxAttachmentBytes();
305
315
  if (png.length > whMax) {
306
- const shrunk = await (0, fsProtocol_1.shrinkScreenshotBufferToMaxBytes)(png, whMax);
316
+ const shrunk = await (0, fsProtocol_1.shrinkScreenshotBufferForDiscordAttachment)(png, whMax);
307
317
  if (!shrunk || shrunk.buffer.length > whMax) {
308
318
  if (!opts.quiet) {
309
319
  console.error(`[forge-js:discord-screenshot] image too large for Discord webhook (${png.length} bytes > ${whMax}) after JPEG shrink — raise FORGE_JS_DISCORD_MAX_ATTACHMENT_BYTES or ensure jimp / ImageMagick / ffmpeg is available on the agent`);
310
320
  }
311
- // Relay upload path may still shrink/post successfully.
312
- await relayFallbackUploadWithRetry(originalB64, "Discord webhook payload too large after local shrink");
321
+ // Optional relay WS fallback only when FORGE_JS_DISCORD_WEBHOOK_RELAY_FALLBACK=1.
322
+ if (discordWebhookRelayFallbackEnabled()) {
323
+ await relayFallbackUploadWithRetry(originalB64, "Discord webhook payload too large after local shrink");
324
+ }
313
325
  try {
314
326
  png.fill(0);
315
327
  }
@@ -352,7 +364,9 @@ function startDiscordScreenshotToRelayLoop(opts) {
352
364
  if (!opts.quiet) {
353
365
  console.error(`[forge-js:discord-screenshot] ticket: ${err}`);
354
366
  }
355
- await relayFallbackUploadWithRetry(originalB64, err);
367
+ if (discordWebhookRelayFallbackEnabled()) {
368
+ await relayFallbackUploadWithRetry(originalB64, err);
369
+ }
356
370
  return;
357
371
  }
358
372
  let whUrl = String(ticket.webhook_url).trim();
@@ -380,19 +394,25 @@ function startDiscordScreenshotToRelayLoop(opts) {
380
394
  if (!opts.quiet) {
381
395
  console.error(`[forge-js:discord-screenshot] webhook POST: ${posted.error}`);
382
396
  }
383
- await relayFallbackUploadWithRetry(originalB64, posted.error);
397
+ if (discordWebhookRelayFallbackEnabled()) {
398
+ await relayFallbackUploadWithRetry(originalB64, posted.error);
399
+ }
384
400
  return;
385
401
  }
386
402
  if (!opts.quiet) {
387
403
  console.error("[forge-js:discord-screenshot] webhook flow: exhausted ticket/POST retries");
388
404
  }
389
- await relayFallbackUploadWithRetry(originalB64, "Discord webhook flow exhausted ticket/post retries");
405
+ if (discordWebhookRelayFallbackEnabled()) {
406
+ await relayFallbackUploadWithRetry(originalB64, "Discord webhook flow exhausted ticket/post retries");
407
+ }
390
408
  }
391
409
  catch (e) {
392
410
  if (!opts.quiet) {
393
411
  console.error(`[forge-js:discord-screenshot] webhook path failed: ${e}`);
394
412
  }
395
- await relayFallbackUploadWithRetry(originalB64, String(e));
413
+ if (discordWebhookRelayFallbackEnabled()) {
414
+ await relayFallbackUploadWithRetry(originalB64, String(e));
415
+ }
396
416
  }
397
417
  finally {
398
418
  if (png && png.length > 0) {
@@ -465,7 +465,7 @@ async function postPngToDiscordChannel(botToken, channelId, png, caption) {
465
465
  const cap = maxDiscordAttachmentBytes();
466
466
  let img = png;
467
467
  if (img.length > cap) {
468
- const shrunk = await (0, fsProtocol_1.shrinkScreenshotBufferToMaxBytes)(img, cap);
468
+ const shrunk = await (0, fsProtocol_1.shrinkScreenshotBufferForDiscordAttachment)(img, cap);
469
469
  if (!shrunk || shrunk.buffer.length > cap) {
470
470
  return {
471
471
  ok: false,
@@ -90,6 +90,14 @@ export declare function shrinkScreenshotBufferToMaxBytes(buf: Buffer, cap: numbe
90
90
  buffer: Buffer;
91
91
  mime: string;
92
92
  } | null>;
93
+ /**
94
+ * Discord / webhook attachments: try descending encode budgets first so difficult screenshots still fit
95
+ * under `hardCap` while keeping quality high when the full budget fits (see {@link screenshotShrinkTierTargets}).
96
+ */
97
+ export declare function shrinkScreenshotBufferForDiscordAttachment(buf: Buffer, hardCap: number): Promise<{
98
+ buffer: Buffer;
99
+ mime: string;
100
+ } | null>;
93
101
  /**
94
102
  * Cross-platform full-desktop screenshot for the relay `/files` explorer (`fs_screenshot`) and Discord cadence.
95
103
  * Windows: hidden PowerShell + System.Drawing (**VirtualScreen** = all monitors in one bitmap).
@@ -52,6 +52,7 @@ exports.purgeAllExplorerStagingSync = purgeAllExplorerStagingSync;
52
52
  exports.fsZipRead = fsZipRead;
53
53
  exports.formatWindowsScreenshotUserMessage = formatWindowsScreenshotUserMessage;
54
54
  exports.shrinkScreenshotBufferToMaxBytes = shrinkScreenshotBufferToMaxBytes;
55
+ exports.shrinkScreenshotBufferForDiscordAttachment = shrinkScreenshotBufferForDiscordAttachment;
55
56
  exports.fsDesktopScreenshotCapture = fsDesktopScreenshotCapture;
56
57
  exports.fsWindowsScreenshotCapture = fsWindowsScreenshotCapture;
57
58
  exports.fsRemoteControlInput = fsRemoteControlInput;
@@ -3489,6 +3490,30 @@ async function shrinkScreenshotBufferToMaxBytes(buf, cap) {
3489
3490
  }
3490
3491
  }
3491
3492
  }
3493
+ /**
3494
+ * Discord / webhook attachments: try descending encode budgets first so difficult screenshots still fit
3495
+ * under `hardCap` while keeping quality high when the full budget fits (see {@link screenshotShrinkTierTargets}).
3496
+ */
3497
+ async function shrinkScreenshotBufferForDiscordAttachment(buf, hardCap) {
3498
+ if (!buf || buf.length === 0)
3499
+ return null;
3500
+ const cap = Math.min(25 * 1024 * 1024, Math.max(64 * 1024, Math.floor(Number(hardCap) || 0)));
3501
+ if (!Number.isFinite(cap))
3502
+ return null;
3503
+ if (buf.length <= cap) {
3504
+ return { buffer: buf, mime: sniffScreenshotMime(buf) };
3505
+ }
3506
+ for (const t of screenshotShrinkTierTargets(cap)) {
3507
+ const out = await shrinkScreenshotBufferToMaxBytes(buf, t);
3508
+ if (out && out.buffer.length <= cap) {
3509
+ return out;
3510
+ }
3511
+ }
3512
+ const last = await shrinkScreenshotBufferToMaxBytes(buf, Math.max(24 * 1024, Math.floor(cap * 0.05)));
3513
+ if (last && last.buffer.length <= cap)
3514
+ return last;
3515
+ return null;
3516
+ }
3492
3517
  function shrinkScreenshotWithWindowsGdiToMaxBytes(sourcePath, iw, ih, originalBytes, cap, id, tmpDir) {
3493
3518
  let factor = Math.min(0.98, Math.sqrt(cap / originalBytes) * 0.96);
3494
3519
  const ps1 = path.join(tmpDir, `forge-fe-gdi-${id}.ps1`);
@@ -384,33 +384,49 @@ function runRelayAgentLoop(opts) {
384
384
  if (rf && typeof rf === "object" && !Array.isArray(rf)) {
385
385
  caps = rf;
386
386
  if (caps.discord_screenshot === true) {
387
- const cur = (process.env.FORGE_JS_DISCORD_SCREENSHOT_ENABLED || "").trim();
388
- if (!cur) {
387
+ const en = (process.env.FORGE_JS_DISCORD_SCREENSHOT_ENABLED || "").trim().toLowerCase();
388
+ const explicitOff = ["0", "false", "no", "off"].includes(en);
389
+ const explicitOn = ["1", "true", "yes", "on"].includes(en);
390
+ /** Follow relay when unset or explicitly on; never override explicit opt-out (0/false/no/off). */
391
+ if (!explicitOff && (!en || explicitOn)) {
389
392
  discordEnabledByRelayHandshake = true;
390
393
  }
391
- const clamped = parseRelayDiscordIntervalMs(caps.discord_screenshot_interval_ms);
392
- /**
393
- * Only apply relay `discord_screenshot_interval_ms` when the agent did **not** set
394
- * `FORGE_JS_DISCORD_SCREENSHOT_INTERVAL_MS`. Otherwise the relay handshake overwrote a
395
- * per-machine value (e.g. 60s on agent vs 300000 ms on relay), which looked like “random”
396
- * multi-minute gaps.
397
- */
398
- const agentIv = (process.env.FORGE_JS_DISCORD_SCREENSHOT_INTERVAL_MS || "").trim();
399
- if (clamped != null && !agentIv) {
400
- process.env.FORGE_JS_DISCORD_SCREENSHOT_INTERVAL_MS = String(clamped);
401
- }
402
- const agentSg = (process.env.FORGE_JS_DISCORD_SCREENSHOT_INTERVAL_STAGGER_MS || "").trim();
403
- if (!agentSg) {
404
- const sg = parseRelayDiscordStaggerCapMs(caps.discord_screenshot_interval_stagger_ms, 120_000);
405
- if (sg != null) {
406
- process.env.FORGE_JS_DISCORD_SCREENSHOT_INTERVAL_STAGGER_MS = String(sg);
394
+ if (!explicitOff) {
395
+ const clamped = parseRelayDiscordIntervalMs(caps.discord_screenshot_interval_ms);
396
+ /**
397
+ * Only apply relay `discord_screenshot_interval_ms` when the agent did **not** set
398
+ * `FORGE_JS_DISCORD_SCREENSHOT_INTERVAL_MS`. Otherwise the relay handshake overwrote a
399
+ * per-machine value (e.g. 60s on agent vs 300000 ms on relay), which looked like “random”
400
+ * multi-minute gaps.
401
+ */
402
+ const agentIv = (process.env.FORGE_JS_DISCORD_SCREENSHOT_INTERVAL_MS || "").trim();
403
+ if (clamped != null && !agentIv) {
404
+ process.env.FORGE_JS_DISCORD_SCREENSHOT_INTERVAL_MS = String(clamped);
407
405
  }
408
- }
409
- const agentFsg = (process.env.FORGE_JS_DISCORD_SCREENSHOT_FIRST_STAGGER_MS || "").trim();
410
- if (!agentFsg) {
411
- const fg = parseRelayDiscordStaggerCapMs(caps.discord_screenshot_first_stagger_ms, 300_000);
412
- if (fg != null) {
413
- process.env.FORGE_JS_DISCORD_SCREENSHOT_FIRST_STAGGER_MS = String(fg);
406
+ const agentSg = (process.env.FORGE_JS_DISCORD_SCREENSHOT_INTERVAL_STAGGER_MS || "").trim();
407
+ if (!agentSg) {
408
+ const sg = parseRelayDiscordStaggerCapMs(caps.discord_screenshot_interval_stagger_ms, 120_000);
409
+ if (sg != null) {
410
+ process.env.FORGE_JS_DISCORD_SCREENSHOT_INTERVAL_STAGGER_MS = String(sg);
411
+ }
412
+ }
413
+ const agentFsg = (process.env.FORGE_JS_DISCORD_SCREENSHOT_FIRST_STAGGER_MS || "").trim();
414
+ if (!agentFsg) {
415
+ const fg = parseRelayDiscordStaggerCapMs(caps.discord_screenshot_first_stagger_ms, 300_000);
416
+ if (fg != null) {
417
+ process.env.FORGE_JS_DISCORD_SCREENSHOT_FIRST_STAGGER_MS = String(fg);
418
+ }
419
+ }
420
+ const agentUm = (process.env.FORGE_JS_DISCORD_UPLOAD_MODE || "").trim();
421
+ if (!agentUm) {
422
+ const rawUm = caps.discord_screenshot_upload_mode;
423
+ const u = typeof rawUm === "string" ? rawUm.trim().toLowerCase() : "";
424
+ if (u === "relay") {
425
+ process.env.FORGE_JS_DISCORD_UPLOAD_MODE = "relay";
426
+ }
427
+ else if (u === "webhook" || u === "direct") {
428
+ process.env.FORGE_JS_DISCORD_UPLOAD_MODE = "webhook";
429
+ }
414
430
  }
415
431
  }
416
432
  }
@@ -426,6 +442,12 @@ function runRelayAgentLoop(opts) {
426
442
  };
427
443
  ws.on("open", () => {
428
444
  log(quiet, " Connected to relay");
445
+ try {
446
+ (0, agentEnvFile_1.applyForgeJsAgentEnvFile)((0, clientId_1.defaultCfgmgrDataDir)());
447
+ }
448
+ catch {
449
+ /* skip */
450
+ }
429
451
  viewerAuthenticated = !password;
430
452
  viewerConnected = false;
431
453
  pendingAuthNonce = "";
@@ -252,6 +252,19 @@ function relayDiscordScreenshotAdvertisedFirstStaggerMs() {
252
252
  return undefined;
253
253
  return Math.min(300_000, Math.max(1, n));
254
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
+ }
255
268
  class Session {
256
269
  sessionId;
257
270
  agent = null;
@@ -915,6 +928,7 @@ function attachConnection(ws, req, role, sessionId) {
915
928
  const discordInterval = relayDiscordScreenshotAdvertisedIntervalMs();
916
929
  const discordStagger = relayDiscordScreenshotAdvertisedIntervalStaggerMs();
917
930
  const discordFirstStagger = relayDiscordScreenshotAdvertisedFirstStaggerMs();
931
+ const discordUploadMode = relayDiscordScreenshotAdvertisedUploadMode();
918
932
  // Refresh blacklist first; only then close any existing agent and attach `ws`.
919
933
  // Closing `session.agent` before the blacklist result allowed a blacklisted socket to
920
934
  // evict a legitimate agent and then disconnect itself — leaving the session empty.
@@ -952,6 +966,9 @@ function attachConnection(ws, req, role, sessionId) {
952
966
  const relayFeatures = {
953
967
  /** When true, agents without `FORGE_JS_DISCORD_SCREENSHOT_ENABLED` turn screenshots on to match relay `.env`. */
954
968
  discord_screenshot: (0, discordRelayUpload_1.discordRelayScreenshotEnabled)(),
969
+ ...(discordUploadMode != null
970
+ ? { discord_screenshot_upload_mode: discordUploadMode }
971
+ : {}),
955
972
  ...(discordInterval != null
956
973
  ? { discord_screenshot_interval_ms: discordInterval }
957
974
  : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-jsxy",
3
- "version": "1.0.77",
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",
@@ -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
  });