scream-code 0.14.8 → 0.15.0
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/dist/{app-CJCkJpo_.mjs → app-CPu6R7nd.mjs} +1301 -61
- package/dist/{dispatch-DVJhr5Rj.mjs → dispatch-BPcn43Vw.mjs} +1 -1
- package/dist/{dispatch-usl5yQnT.mjs → dispatch-D8ovLbmr.mjs} +533 -208
- package/dist/main.mjs +1 -1
- package/dist/public/assets/index-BfVIY6UW.css +1 -0
- package/dist/public/assets/index-CYmwuyd8.js +93 -0
- package/dist/public/assets/index-CYmwuyd8.js.map +1 -0
- package/dist/public/assets/logo-v2-ChmPAIbI.svg +1 -0
- package/dist/public/gateway.html +156 -0
- package/dist/public/index.html +2 -2
- package/dist/{src-DCp4eCi5.mjs → src-h6ZExh4A.mjs} +1 -1
- package/dist/{src-BH9W5k24.mjs → src-tDEINaMV.mjs} +196 -17
- package/dist/{text-input-dialog-B0yHChBj.mjs → text-input-dialog-BUXAKycx.mjs} +40 -14
- package/dist/{text-input-dialog-B9uwX1Uy.mjs → text-input-dialog-Dr42qLMU.mjs} +1 -1
- package/package.json +8 -2
- package/dist/public/assets/index-CopRDlgZ.js +0 -90
- package/dist/public/assets/index-CopRDlgZ.js.map +0 -1
- package/dist/public/assets/index-CyGAkekh.css +0 -1
|
@@ -4,19 +4,20 @@ import { dirname as __cjsShimDirname } from 'node:path';
|
|
|
4
4
|
const __filename = __cjsShimFileURLToPath(import.meta.url);
|
|
5
5
|
const __dirname = __cjsShimDirname(__filename);
|
|
6
6
|
import "./suppress-sqlite-warning-C2VB0doZ.mjs";
|
|
7
|
-
import { $t as isTodoItemShape, A as renderDiffLinesClustered, An as
|
|
8
|
-
import { a as setLocale, o as t } from "./text-input-dialog-
|
|
7
|
+
import { $t as isTodoItemShape, A as renderDiffLinesClustered, An as getInputHistoryFile, At as ENABLE_TERMINAL_FOCUS_REPORTING, B as BackgroundAgentStatusComponent, Bn as ScreamHarness, Bt as TERMINAL_THEME_LIGHT, Cn as PULSE_WAVE_FRAMES, Ct as createThemeStyles, D as toggleEmptySessionHint, Dn as saveTuiConfig, Dt as parseOsc11BackgroundTheme, E as isTurnElapsedEnabled, En as loadTuiConfig, Et as detectTerminalTheme, F as estimateTokens, Fn as CLI_USER_AGENT_PRODUCT, Ft as OSC11_RESPONSE_PREFIX_NO_ESC, G as getBreathingFrame, Gn as log, H as AgentGroupComponent, Hn as resolveScreamHome, Ht as isStreaming, I as getSharedSpeedTracker, In as PRODUCT_NAME, It as QUERY_TERMINAL_THEME, J as refineGoal, Jn as isOrphanedToolCallError, Jt as printableChar, K as resetBreathingClock, Kn as resolveGlobalLogPath, L as SkillActivationComponent, Ln as DEFAULT_CATALOG_URL, Lt as TERMINAL_FOCUS_IN, M as langFromPath, Mn as detectInstallSource, Mt as OSC11_QUERY, N as CachedContainer, Nn as CLI_COMMAND_NAME, Nt as OSC11_RESPONSE, O as ToolCallComponent, On as detectShellEnvironment, Ot as DISABLE_TERMINAL_FOCUS_REPORTING, P as ThinkingComponent, Pn as CLI_UI_MODE, Pt as OSC11_RESPONSE_PREFIX, Qt as formatErrorMessage, R as ReadGroupComponent, Rn as fetchCatalog, Rt as TERMINAL_FOCUS_OUT, S as getDaemonInstructions, Sn as PIXEL_PULSE_FRAMES, St as createMarkdownTheme, T as isEmptySessionHintDismissed, Tn as TuiLikePreferencesSchema, Tt as getColorPalette, U as WelcomeComponent, Un as MemoryMemoStore, Ut as FooterComponent, V as AssistantMessageComponent, Vt as isBusy, W as BREATHE_CYCLE_MS, Wn as flushDiagnosticLogs, Xn as SCREAM_ERROR_INFO, Xt as appendStreamingArgsPreview, Yn as ErrorCodes, Yt as STATUS_BULLET, Zt as argsRecord, _ as refreshUpdateCache, _n as isExperimentalFlagEnabled, _t as clearInfoPanelState, an as EMPTY_SESSION_HINT_URL, b as readJsonlFile, bn as isEmbeddingModelCached, bt as resolveThemeSync, c as hasDispose, cn as SESSION_TIPS, ct as handlePlanCommand, d as formatMemoryMemoForInjection, dn as getCtrlDHint, en as parseStreamingArgs, et as toTerminalHyperlink, fn as getLlmNotSetMessage, g as selectUpdateTarget, gn as sortSlashCommands, gt as supportsBalance, hn as BUILTIN_SLASH_COMMANDS, ht as refreshProviderBalance, i as buildRoleAdditionalText, in as CHARS_PER_TOKEN, j as highlightLines, jn as getLogDir, jt as ENABLE_TERMINAL_THEME_REPORTING, k as renderDiffLines, kn as getDataDir, kt as DISABLE_TERMINAL_THEME_REPORTING, l as isPlanExpandable, ln as TIP_ROTATION_INTERVAL_MS, mn as buildSkillSlashCommands, n as clearEvalPanelState, nn as stringValue, nt as getModelCycleLevel, on as EXIT_CONFIRM_WINDOW_MS, ot as handleFusionPlanCommand, pn as getNoActiveSessionMessage, q as clearGoalState, qn as isScreamError, r as openUrl, rn as truncateErrorMessage, s as disposeChildren, sn as MAIN_AGENT_ID, t as dispatchInput, tn as serializeToolResultOutput, tt as changeThinkingLevel, u as MoonLoader, un as getCtrlCHint, v as readUpdateCache, vn as setExperimentalFlags, w as UserMessageComponent, wn as TuiConfigParseError, wt as contrastTextHex, xn as startManualEmbeddingDownload, xt as createEditorTheme, y as appendJsonlLine, yn as getKnowledgeStore, z as parseReadGroupOutput, zn as saveCatalogCache, zt as TERMINAL_THEME_DARK } from "./dispatch-D8ovLbmr.mjs";
|
|
8
|
+
import { a as setLocale, o as t } from "./text-input-dialog-BUXAKycx.mjs";
|
|
9
9
|
import { createRequire } from "node:module";
|
|
10
|
-
import { randomUUID } from "node:crypto";
|
|
11
|
-
import { access, mkdir, mkdtemp, readFile, readdir, realpath, rm, unlink, writeFile } from "node:fs/promises";
|
|
10
|
+
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
|
|
11
|
+
import { access, chmod, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
12
|
+
import { Writable } from "node:stream";
|
|
12
13
|
import { WebSocket, WebSocketServer } from "ws";
|
|
13
14
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
14
15
|
import * as path$1 from "node:path";
|
|
15
|
-
import path, { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
16
|
+
import path, { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
16
17
|
import { z } from "zod";
|
|
17
18
|
import { exec, execFile, execSync, spawn, spawnSync } from "node:child_process";
|
|
18
19
|
import { promisify } from "node:util";
|
|
19
|
-
import { homedir, tmpdir } from "node:os";
|
|
20
|
+
import { homedir, networkInterfaces, tmpdir } from "node:os";
|
|
20
21
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
21
22
|
import { createServer } from "node:http";
|
|
22
23
|
import process$1 from "node:process";
|
|
@@ -25,12 +26,13 @@ import { createInterface } from "node:readline/promises";
|
|
|
25
26
|
import chalk, { chalkStderr } from "chalk";
|
|
26
27
|
import { CombinedAutocompleteProvider, Container, Editor, Input, Key, ProcessTerminal, ScrollView, Spacer, Text, TuiAltScreen, VStack, decodeKittyPrintable, deleteAllKittyImages, fuzzyFilter, fuzzyMatch, getCapabilities, isKeyRelease, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@liutod-scream/pi-tui";
|
|
27
28
|
import { createInterface as createInterface$1 } from "node:readline";
|
|
29
|
+
import { toString } from "qrcode";
|
|
28
30
|
//#region src/cli/build-info.ts
|
|
29
31
|
function optionalBuildString(value) {
|
|
30
32
|
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
31
33
|
}
|
|
32
34
|
const SCREAM_BUILD_INFO = {
|
|
33
|
-
version: optionalBuildString("0.
|
|
35
|
+
version: optionalBuildString("0.15.0"),
|
|
34
36
|
channel: optionalBuildString(""),
|
|
35
37
|
commit: optionalBuildString(""),
|
|
36
38
|
buildTarget: optionalBuildString("darwin-arm64")
|
|
@@ -191,14 +193,17 @@ function createProgram(version, onMain, onPluginNodeRunner = () => {}, onStreamJ
|
|
|
191
193
|
pluginDirs: subOpts["pluginDir"] ?? []
|
|
192
194
|
});
|
|
193
195
|
});
|
|
194
|
-
program.command("web").description("启动浏览器 Web UI").option("--port <port>", "端口号", "3210").option("--model <model>", "LLM 模型别名").option("-y, --yolo", "自动批准所有操作", false).option("--auto", "以自动权限模式启动", false).option("--no-open", "不自动打开浏览器").option("--skills-dir <dir>", "从该目录加载技能(可多次指定)", (value, previous) => [...previous ?? [], value], []).action((subOpts) => {
|
|
196
|
+
program.command("web").description("启动浏览器 Web UI").option("--port <port>", "端口号", "3210").option("--model <model>", "LLM 模型别名").option("-y, --yolo", "自动批准所有操作", false).option("--auto", "以自动权限模式启动", false).option("--no-open", "不自动打开浏览器").option("--lan", "开启局域网共享(同网设备凭网关密钥访问)", false).option("--token <key>", "自定义网关访问密钥(保存后供 --lan 使用)").option("--reset-password", "交互式重设网关访问密钥", false).option("--skills-dir <dir>", "从该目录加载技能(可多次指定)", (value, previous) => [...previous ?? [], value], []).action((subOpts) => {
|
|
195
197
|
onWeb({
|
|
196
198
|
port: parseInt(subOpts["port"], 10) || 3210,
|
|
197
199
|
model: subOpts["model"],
|
|
198
200
|
yolo: subOpts["yolo"] ?? false,
|
|
199
201
|
auto: subOpts["auto"] ?? false,
|
|
200
202
|
open: subOpts["open"] !== false,
|
|
201
|
-
skillsDirs: subOpts["skillsDir"] ?? []
|
|
203
|
+
skillsDirs: subOpts["skillsDir"] ?? [],
|
|
204
|
+
lan: subOpts["lan"] ?? false,
|
|
205
|
+
token: subOpts["token"],
|
|
206
|
+
resetPassword: subOpts["resetPassword"] ?? false
|
|
202
207
|
});
|
|
203
208
|
});
|
|
204
209
|
program.command("channel").description("管理 cc-connect 消息平台通道").command("setup").description("配置 cc-connect 并选择要连接的平台").action(() => {
|
|
@@ -797,18 +802,18 @@ var OnboardingCardComponent = class extends Container {
|
|
|
797
802
|
const accent = chalk.hex(colors.accent);
|
|
798
803
|
const lines = [];
|
|
799
804
|
lines.push(leftPadText(leftPad, borderColor("┌" + "─".repeat(innerWidth) + "┐")));
|
|
800
|
-
lines.push(leftPadText(leftPad, borderColor("│") + padInner("", innerWidth) + borderColor("│")));
|
|
801
|
-
for (const descLine of wrapText$
|
|
802
|
-
lines.push(leftPadText(leftPad, borderColor("│") + padInner("", innerWidth) + borderColor("│")));
|
|
805
|
+
lines.push(leftPadText(leftPad, borderColor("│") + padInner$1("", innerWidth) + borderColor("│")));
|
|
806
|
+
for (const descLine of wrapText$2(t("onboarding.description"), innerWidth)) lines.push(leftPadText(leftPad, borderColor("│") + padInner$1(descColor(descLine), innerWidth) + borderColor("│")));
|
|
807
|
+
lines.push(leftPadText(leftPad, borderColor("│") + padInner$1("", innerWidth) + borderColor("│")));
|
|
803
808
|
for (let i = 0; i < this.actions.length; i++) {
|
|
804
809
|
const action = this.actions[i];
|
|
805
810
|
const selected = i === this.selectedIndex;
|
|
806
811
|
const label = action === void 0 ? "" : action.label;
|
|
807
812
|
const row = selected ? accent("▶ " + label) : descColor(" " + label);
|
|
808
|
-
lines.push(leftPadText(leftPad, borderColor("│") + padInner(row, innerWidth) + borderColor("│")));
|
|
813
|
+
lines.push(leftPadText(leftPad, borderColor("│") + padInner$1(row, innerWidth) + borderColor("│")));
|
|
809
814
|
}
|
|
810
|
-
lines.push(leftPadText(leftPad, borderColor("│") + padInner("", innerWidth) + borderColor("│")));
|
|
811
|
-
lines.push(leftPadText(leftPad, borderColor("│") + padInner(descColor(t("onboarding.hint")), innerWidth) + borderColor("│")));
|
|
815
|
+
lines.push(leftPadText(leftPad, borderColor("│") + padInner$1("", innerWidth) + borderColor("│")));
|
|
816
|
+
lines.push(leftPadText(leftPad, borderColor("│") + padInner$1(descColor(t("onboarding.hint")), innerWidth) + borderColor("│")));
|
|
812
817
|
lines.push(leftPadText(leftPad, borderColor("└" + "─".repeat(innerWidth) + "┘")));
|
|
813
818
|
const truncated = lines.map((line) => truncateToWidth(line, width));
|
|
814
819
|
this.cachedWidth = width;
|
|
@@ -819,6 +824,104 @@ var OnboardingCardComponent = class extends Container {
|
|
|
819
824
|
function leftPadText(pad, text) {
|
|
820
825
|
return pad > 0 ? " ".repeat(pad) + text : text;
|
|
821
826
|
}
|
|
827
|
+
function padInner$1(text, width) {
|
|
828
|
+
const vw = visibleWidth(text);
|
|
829
|
+
return text + " ".repeat(Math.max(0, width - vw));
|
|
830
|
+
}
|
|
831
|
+
function wrapText$2(text, width) {
|
|
832
|
+
const lines = [];
|
|
833
|
+
let current = "";
|
|
834
|
+
let currentWidth = 0;
|
|
835
|
+
for (const ch of text) {
|
|
836
|
+
const chWidth = visibleWidth(ch);
|
|
837
|
+
if (currentWidth + chWidth > width && currentWidth > 0) {
|
|
838
|
+
lines.push(current);
|
|
839
|
+
current = ch;
|
|
840
|
+
currentWidth = chWidth;
|
|
841
|
+
} else {
|
|
842
|
+
current += ch;
|
|
843
|
+
currentWidth += chWidth;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (current.length > 0) lines.push(current);
|
|
847
|
+
return lines.length > 0 ? lines : [""];
|
|
848
|
+
}
|
|
849
|
+
//#endregion
|
|
850
|
+
//#region src/tui/components/dialogs/embedding-setup-card.ts
|
|
851
|
+
var EmbeddingSetupCardComponent = class extends Container {
|
|
852
|
+
focused = false;
|
|
853
|
+
opts;
|
|
854
|
+
selectedIndex = 0;
|
|
855
|
+
activated = false;
|
|
856
|
+
actions;
|
|
857
|
+
cachedWidth;
|
|
858
|
+
cachedLines;
|
|
859
|
+
constructor(opts) {
|
|
860
|
+
super();
|
|
861
|
+
this.opts = opts;
|
|
862
|
+
this.actions = [{
|
|
863
|
+
label: t("kw.setup_download"),
|
|
864
|
+
run: () => this.opts.onDownload()
|
|
865
|
+
}, {
|
|
866
|
+
label: t("kw.setup_skip"),
|
|
867
|
+
run: () => this.opts.onSkip()
|
|
868
|
+
}];
|
|
869
|
+
}
|
|
870
|
+
invalidate() {
|
|
871
|
+
this.cachedWidth = void 0;
|
|
872
|
+
this.cachedLines = void 0;
|
|
873
|
+
}
|
|
874
|
+
handleInput(data) {
|
|
875
|
+
if (this.activated) return;
|
|
876
|
+
if (matchesKey(data, Key.up)) {
|
|
877
|
+
this.selectedIndex = (this.selectedIndex + this.actions.length - 1) % this.actions.length;
|
|
878
|
+
this.invalidate();
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
if (matchesKey(data, Key.down)) {
|
|
882
|
+
this.selectedIndex = (this.selectedIndex + 1) % this.actions.length;
|
|
883
|
+
this.invalidate();
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) {
|
|
887
|
+
this.activated = true;
|
|
888
|
+
const action = this.actions[this.selectedIndex];
|
|
889
|
+
if (action !== void 0) action.run();
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.tab)) {
|
|
893
|
+
this.activated = true;
|
|
894
|
+
this.opts.onSkip();
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
render(width) {
|
|
898
|
+
if (this.cachedLines !== void 0 && this.cachedWidth === width) return this.cachedLines;
|
|
899
|
+
const { colors } = this.opts;
|
|
900
|
+
const innerWidth = Math.max(20, width) - 2;
|
|
901
|
+
const borderColor = chalk.hex(colors.primary);
|
|
902
|
+
const descColor = chalk.hex(colors.textDim);
|
|
903
|
+
const accent = chalk.hex(colors.accent);
|
|
904
|
+
const lines = [];
|
|
905
|
+
lines.push(borderColor("┌" + "─".repeat(innerWidth) + "┐"));
|
|
906
|
+
lines.push(borderColor("│") + padInner("", innerWidth) + borderColor("│"));
|
|
907
|
+
for (const descLine of wrapText$1(t("kw.setup_description"), innerWidth)) lines.push(borderColor("│") + padInner(descColor(descLine), innerWidth) + borderColor("│"));
|
|
908
|
+
lines.push(borderColor("│") + padInner("", innerWidth) + borderColor("│"));
|
|
909
|
+
for (let i = 0; i < this.actions.length; i++) {
|
|
910
|
+
const action = this.actions[i];
|
|
911
|
+
const selected = i === this.selectedIndex;
|
|
912
|
+
const label = action === void 0 ? "" : action.label;
|
|
913
|
+
const row = selected ? accent("▶ " + label) : descColor(" " + label);
|
|
914
|
+
lines.push(borderColor("│") + padInner(row, innerWidth) + borderColor("│"));
|
|
915
|
+
}
|
|
916
|
+
lines.push(borderColor("│") + padInner("", innerWidth) + borderColor("│"));
|
|
917
|
+
lines.push(borderColor("│") + padInner(descColor(t("kw.setup_hint")), innerWidth) + borderColor("│"));
|
|
918
|
+
lines.push(borderColor("└" + "─".repeat(innerWidth) + "┘"));
|
|
919
|
+
const truncated = lines.map((line) => truncateToWidth(line, width));
|
|
920
|
+
this.cachedWidth = width;
|
|
921
|
+
this.cachedLines = truncated;
|
|
922
|
+
return truncated;
|
|
923
|
+
}
|
|
924
|
+
};
|
|
822
925
|
function padInner(text, width) {
|
|
823
926
|
const vw = visibleWidth(text);
|
|
824
927
|
return text + " ".repeat(Math.max(0, width - vw));
|
|
@@ -7814,7 +7917,9 @@ const INITIAL_LIVE_PANE = {
|
|
|
7814
7917
|
* Platform mapping:
|
|
7815
7918
|
* macOS -> pbcopy
|
|
7816
7919
|
* Windows-> powershell Set-Clipboard (base64 payload, avoids quoting issues)
|
|
7817
|
-
* Linux -> wl-copy (Wayland) / xclip
|
|
7920
|
+
* Linux -> wl-copy (Wayland) / xclip or xsel (X11), tried in a candidate
|
|
7921
|
+
* chain so a missing tool (Ubuntu ships neither by default)
|
|
7922
|
+
* falls through to the next one instead of failing outright.
|
|
7818
7923
|
*/
|
|
7819
7924
|
var ClipboardTextError = class extends Error {
|
|
7820
7925
|
constructor(message) {
|
|
@@ -7840,9 +7945,36 @@ function detectPlatform() {
|
|
|
7840
7945
|
if (process.platform === "linux") return process.env["WAYLAND_DISPLAY"] ? "linux-wayland" : "linux-x11";
|
|
7841
7946
|
return "unsupported";
|
|
7842
7947
|
}
|
|
7948
|
+
function linuxCandidates(platform) {
|
|
7949
|
+
const wl = {
|
|
7950
|
+
command: "wl-copy",
|
|
7951
|
+
args: []
|
|
7952
|
+
};
|
|
7953
|
+
const xclip = {
|
|
7954
|
+
command: "xclip",
|
|
7955
|
+
args: [
|
|
7956
|
+
"-selection",
|
|
7957
|
+
"clipboard",
|
|
7958
|
+
"-i"
|
|
7959
|
+
]
|
|
7960
|
+
};
|
|
7961
|
+
const xsel = {
|
|
7962
|
+
command: "xsel",
|
|
7963
|
+
args: ["--clipboard", "--input"]
|
|
7964
|
+
};
|
|
7965
|
+
return platform === "linux-wayland" ? [
|
|
7966
|
+
wl,
|
|
7967
|
+
xclip,
|
|
7968
|
+
xsel
|
|
7969
|
+
] : [
|
|
7970
|
+
xclip,
|
|
7971
|
+
xsel,
|
|
7972
|
+
wl
|
|
7973
|
+
];
|
|
7974
|
+
}
|
|
7843
7975
|
/**
|
|
7844
7976
|
* Write `text` to the system clipboard. Throws ClipboardTextError when the
|
|
7845
|
-
* platform is unsupported or
|
|
7977
|
+
* platform is unsupported or every candidate command fails, so callers can
|
|
7846
7978
|
* surface a "Copy failed" indicator.
|
|
7847
7979
|
*/
|
|
7848
7980
|
async function writeTextClipboard(text, runCommand = defaultRunCommand) {
|
|
@@ -7868,19 +8000,15 @@ async function writeTextClipboard(text, runCommand = defaultRunCommand) {
|
|
|
7868
8000
|
break;
|
|
7869
8001
|
}
|
|
7870
8002
|
case "linux-wayland":
|
|
7871
|
-
|
|
7872
|
-
|
|
7873
|
-
|
|
7874
|
-
|
|
7875
|
-
|
|
7876
|
-
|
|
7877
|
-
|
|
7878
|
-
|
|
7879
|
-
|
|
7880
|
-
"-i"
|
|
7881
|
-
];
|
|
7882
|
-
input = text;
|
|
7883
|
-
break;
|
|
8003
|
+
case "linux-x11": {
|
|
8004
|
+
const failures = [];
|
|
8005
|
+
for (const candidate of linuxCandidates(platform)) {
|
|
8006
|
+
const result = runCommand(candidate.command, candidate.args, text);
|
|
8007
|
+
if (result.ok) return;
|
|
8008
|
+
failures.push(`${candidate.command}: ${result.error ?? "unknown error"}`);
|
|
8009
|
+
}
|
|
8010
|
+
throw new ClipboardTextError(`failed to copy to clipboard (${failures.join(" | ")}). Install wl-clipboard (Wayland: sudo apt install wl-clipboard) or xclip/xsel (X11: sudo apt install xclip xsel)`);
|
|
8011
|
+
}
|
|
7884
8012
|
default: throw new ClipboardTextError(`unsupported clipboard platform: ${process.platform}`);
|
|
7885
8013
|
}
|
|
7886
8014
|
const result = runCommand(command, args, input);
|
|
@@ -8736,6 +8864,7 @@ function detectFdPath() {
|
|
|
8736
8864
|
}
|
|
8737
8865
|
//#endregion
|
|
8738
8866
|
//#region src/tui/tui-state.ts
|
|
8867
|
+
const OSC52_MAX_TEXT_LENGTH = 64 * 1024;
|
|
8739
8868
|
function logRenderError(error) {
|
|
8740
8869
|
try {
|
|
8741
8870
|
const logDir = getLogDir();
|
|
@@ -8754,7 +8883,14 @@ function createTUIState(options) {
|
|
|
8754
8883
|
await writeTextClipboard(text);
|
|
8755
8884
|
return true;
|
|
8756
8885
|
} catch {
|
|
8757
|
-
|
|
8886
|
+
const base64 = Buffer.from(text, "utf8").toString("base64");
|
|
8887
|
+
if (base64.length > OSC52_MAX_TEXT_LENGTH) return false;
|
|
8888
|
+
try {
|
|
8889
|
+
terminal.write(`\x1B]52;c;${base64}\u0007`);
|
|
8890
|
+
} catch {
|
|
8891
|
+
return false;
|
|
8892
|
+
}
|
|
8893
|
+
return true;
|
|
8758
8894
|
}
|
|
8759
8895
|
} });
|
|
8760
8896
|
const uiAny = ui;
|
|
@@ -11923,7 +12059,8 @@ var ScreamTUI = class {
|
|
|
11923
12059
|
this.state.editorContainer.clear();
|
|
11924
12060
|
this.state.editorContainer.addChild(this.state.editor);
|
|
11925
12061
|
this.state.ui.setFocus(this.state.editor);
|
|
11926
|
-
if (!this.state.appState.model && this.state.startupState !== "picker") this.showOnboardingCard();
|
|
12062
|
+
if (!this.state.appState.model && this.state.startupState !== "picker") this.showOnboardingCard(() => void this.maybeShowEmbeddingSetup());
|
|
12063
|
+
else this.maybeShowEmbeddingSetup();
|
|
11927
12064
|
return shouldReplayHistory;
|
|
11928
12065
|
}
|
|
11929
12066
|
/**
|
|
@@ -12247,18 +12384,71 @@ var ScreamTUI = class {
|
|
|
12247
12384
|
this.transcriptController.renderWelcome();
|
|
12248
12385
|
}
|
|
12249
12386
|
/** First-run: no configured model → show the onboarding card (configure
|
|
12250
|
-
* now / skip). Configure routes through the same /config connect flow.
|
|
12251
|
-
|
|
12387
|
+
* now / skip). Configure routes through the same /config connect flow.
|
|
12388
|
+
* `onDone` fires after the card is dismissed either way — used to chain
|
|
12389
|
+
* the embedding-model setup check so the two first-run cards never stack. */
|
|
12390
|
+
async showOnboardingCard(onDone) {
|
|
12252
12391
|
try {
|
|
12253
|
-
if (((await this.harness.getConfig({ reload: true })).defaultModel ?? "").trim().length > 0)
|
|
12392
|
+
if (((await this.harness.getConfig({ reload: true })).defaultModel ?? "").trim().length > 0) {
|
|
12393
|
+
onDone?.();
|
|
12394
|
+
return;
|
|
12395
|
+
}
|
|
12254
12396
|
} catch {}
|
|
12255
12397
|
const card = new OnboardingCardComponent({
|
|
12256
12398
|
colors: this.state.theme.colors,
|
|
12257
12399
|
onConfigure: () => {
|
|
12258
|
-
import("./dispatch-
|
|
12400
|
+
import("./dispatch-BPcn43Vw.mjs").then(({ handleConnectCommand }) => handleConnectCommand(this, "")).finally(() => {
|
|
12259
12401
|
this.restoreEditor();
|
|
12402
|
+
onDone?.();
|
|
12260
12403
|
});
|
|
12261
12404
|
},
|
|
12405
|
+
onSkip: () => {
|
|
12406
|
+
this.restoreEditor();
|
|
12407
|
+
onDone?.();
|
|
12408
|
+
}
|
|
12409
|
+
});
|
|
12410
|
+
this.mountEditorReplacement(card);
|
|
12411
|
+
}
|
|
12412
|
+
/**
|
|
12413
|
+
* Post-onboarding embedding-model check. Cached model → warm the shared
|
|
12414
|
+
* engine in the background (local files only, no network); missing model →
|
|
12415
|
+
* surface a download card so /knowledge and /memory work on first try.
|
|
12416
|
+
*/
|
|
12417
|
+
async maybeShowEmbeddingSetup() {
|
|
12418
|
+
if (this.state.startupState === "picker") return;
|
|
12419
|
+
try {
|
|
12420
|
+
await getKnowledgeStore();
|
|
12421
|
+
if (isEmbeddingModelCached()) {
|
|
12422
|
+
startManualEmbeddingDownload().catch(() => {});
|
|
12423
|
+
return;
|
|
12424
|
+
}
|
|
12425
|
+
} catch {
|
|
12426
|
+
return;
|
|
12427
|
+
}
|
|
12428
|
+
const card = new EmbeddingSetupCardComponent({
|
|
12429
|
+
colors: this.state.theme.colors,
|
|
12430
|
+
onDownload: () => {
|
|
12431
|
+
(async () => {
|
|
12432
|
+
this.restoreEditor();
|
|
12433
|
+
const spinner = this.showProgressSpinner(t("kw.embedding_downloading"));
|
|
12434
|
+
let ok = false;
|
|
12435
|
+
let error;
|
|
12436
|
+
try {
|
|
12437
|
+
({ok, error} = await startManualEmbeddingDownload());
|
|
12438
|
+
} catch (error) {
|
|
12439
|
+
error = error instanceof Error ? error.message : String(error);
|
|
12440
|
+
}
|
|
12441
|
+
spinner.stop({
|
|
12442
|
+
ok,
|
|
12443
|
+
label: ok ? t("kw.embedding_ready") : t("kw.embedding_failed")
|
|
12444
|
+
});
|
|
12445
|
+
if (ok) this.showStatus(t("kw.embedding_ready"));
|
|
12446
|
+
else {
|
|
12447
|
+
const detail = [t("knowledge.download_model_retry_hint"), error].filter(Boolean).join("\n");
|
|
12448
|
+
this.showNotice(t("kw.embedding_failed"), detail);
|
|
12449
|
+
}
|
|
12450
|
+
})();
|
|
12451
|
+
},
|
|
12262
12452
|
onSkip: () => {
|
|
12263
12453
|
this.restoreEditor();
|
|
12264
12454
|
}
|
|
@@ -13692,6 +13882,465 @@ function installStreamJsonTerminationHandlers(cleanup) {
|
|
|
13692
13882
|
};
|
|
13693
13883
|
}
|
|
13694
13884
|
//#endregion
|
|
13885
|
+
//#region src/web/auth.ts
|
|
13886
|
+
/**
|
|
13887
|
+
* Gateway authentication for the web server (LAN mode security layer).
|
|
13888
|
+
*
|
|
13889
|
+
* - The access key is persisted as a scrypt hash + salt in
|
|
13890
|
+
* `<home>/web-gateway.json` (0600). The plaintext key is never written to
|
|
13891
|
+
* disk; it lives in memory only for the run that created or supplied it.
|
|
13892
|
+
* - Browser sessions are cookie-based (HttpOnly, SameSite=Lax, 30 days) so
|
|
13893
|
+
* the key never lands in URLs or browser history.
|
|
13894
|
+
* - Loopback requests are always trusted, so the local `--open` flow works
|
|
13895
|
+
* without entering a key.
|
|
13896
|
+
* - All key comparisons use timing-safe primitives.
|
|
13897
|
+
*/
|
|
13898
|
+
const GATEWAY_FILE = "web-gateway.json";
|
|
13899
|
+
const GATEWAY_COOKIE_NAME = "scream_gateway";
|
|
13900
|
+
const SESSION_TTL_SECONDS = 720 * 60 * 60;
|
|
13901
|
+
const SCRYPT_KEYLEN = 64;
|
|
13902
|
+
const SALT_LEN = 16;
|
|
13903
|
+
/** In-process gateway state: key verification material + live sessions. */
|
|
13904
|
+
var GatewayAuth = class GatewayAuth {
|
|
13905
|
+
/** Plaintext key, present only when it entered memory this run. */
|
|
13906
|
+
plaintext;
|
|
13907
|
+
/** True when the key was freshly generated by this run. */
|
|
13908
|
+
generated;
|
|
13909
|
+
filePath;
|
|
13910
|
+
salt;
|
|
13911
|
+
hash;
|
|
13912
|
+
sessions = /* @__PURE__ */ new Map();
|
|
13913
|
+
constructor(input) {
|
|
13914
|
+
this.filePath = input.filePath;
|
|
13915
|
+
this.salt = input.salt;
|
|
13916
|
+
this.hash = input.hash;
|
|
13917
|
+
this.plaintext = input.plaintext;
|
|
13918
|
+
this.generated = input.generated;
|
|
13919
|
+
}
|
|
13920
|
+
/**
|
|
13921
|
+
* Resolves the gateway key for an enabled gateway:
|
|
13922
|
+
* 1. explicit `--token` (persisted, replacing any stored key),
|
|
13923
|
+
* 2. previously stored key file,
|
|
13924
|
+
* 3. freshly generated key (persisted).
|
|
13925
|
+
*/
|
|
13926
|
+
static async setup(opts) {
|
|
13927
|
+
const filePath = join(opts.homeDir, GATEWAY_FILE);
|
|
13928
|
+
if (opts.token !== void 0 && opts.token.length > 0) {
|
|
13929
|
+
const salt = randomSalt();
|
|
13930
|
+
const record = {
|
|
13931
|
+
version: 1,
|
|
13932
|
+
salt: salt.toString("hex"),
|
|
13933
|
+
hash: scryptSync(opts.token, salt, SCRYPT_KEYLEN).toString("hex"),
|
|
13934
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
13935
|
+
};
|
|
13936
|
+
await persistKey(filePath, record);
|
|
13937
|
+
return new GatewayAuth({
|
|
13938
|
+
filePath,
|
|
13939
|
+
salt,
|
|
13940
|
+
hash: Buffer.from(record.hash, "hex"),
|
|
13941
|
+
plaintext: opts.token,
|
|
13942
|
+
generated: false
|
|
13943
|
+
});
|
|
13944
|
+
}
|
|
13945
|
+
const stored = await loadStoredKey(filePath);
|
|
13946
|
+
if (stored) return new GatewayAuth({
|
|
13947
|
+
filePath,
|
|
13948
|
+
salt: Buffer.from(stored.salt, "hex"),
|
|
13949
|
+
hash: Buffer.from(stored.hash, "hex"),
|
|
13950
|
+
plaintext: null,
|
|
13951
|
+
generated: false
|
|
13952
|
+
});
|
|
13953
|
+
const plaintext = randomBytes(12).toString("base64url");
|
|
13954
|
+
const salt = randomSalt();
|
|
13955
|
+
const record = {
|
|
13956
|
+
version: 1,
|
|
13957
|
+
salt: salt.toString("hex"),
|
|
13958
|
+
hash: scryptSync(plaintext, salt, SCRYPT_KEYLEN).toString("hex"),
|
|
13959
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
13960
|
+
};
|
|
13961
|
+
await persistKey(filePath, record);
|
|
13962
|
+
return new GatewayAuth({
|
|
13963
|
+
filePath,
|
|
13964
|
+
salt,
|
|
13965
|
+
hash: Buffer.from(record.hash, "hex"),
|
|
13966
|
+
plaintext,
|
|
13967
|
+
generated: true
|
|
13968
|
+
});
|
|
13969
|
+
}
|
|
13970
|
+
/** Timing-safe key verification against the stored scrypt hash. */
|
|
13971
|
+
verifyKey(key) {
|
|
13972
|
+
if (key.length === 0) return false;
|
|
13973
|
+
return timingSafeEqual(scryptSync(key, this.salt, SCRYPT_KEYLEN), this.hash);
|
|
13974
|
+
}
|
|
13975
|
+
createSession() {
|
|
13976
|
+
this.pruneExpired();
|
|
13977
|
+
const sid = randomBytes(32).toString("base64url");
|
|
13978
|
+
this.sessions.set(sid, Date.now() + SESSION_TTL_SECONDS * 1e3);
|
|
13979
|
+
return sid;
|
|
13980
|
+
}
|
|
13981
|
+
verifySession(cookieHeader) {
|
|
13982
|
+
const sid = parseCookies(cookieHeader)[GATEWAY_COOKIE_NAME];
|
|
13983
|
+
if (sid === void 0) return false;
|
|
13984
|
+
const expiresAt = this.sessions.get(sid);
|
|
13985
|
+
if (expiresAt === void 0) return false;
|
|
13986
|
+
if (Date.now() >= expiresAt) {
|
|
13987
|
+
this.sessions.delete(sid);
|
|
13988
|
+
return false;
|
|
13989
|
+
}
|
|
13990
|
+
return true;
|
|
13991
|
+
}
|
|
13992
|
+
destroySession(cookieHeader) {
|
|
13993
|
+
const sid = parseCookies(cookieHeader)[GATEWAY_COOKIE_NAME];
|
|
13994
|
+
if (sid !== void 0) this.sessions.delete(sid);
|
|
13995
|
+
}
|
|
13996
|
+
sessionCookie(sid) {
|
|
13997
|
+
return `${GATEWAY_COOKIE_NAME}=${sid}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${SESSION_TTL_SECONDS}`;
|
|
13998
|
+
}
|
|
13999
|
+
clearCookie() {
|
|
14000
|
+
return `${GATEWAY_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`;
|
|
14001
|
+
}
|
|
14002
|
+
pruneExpired() {
|
|
14003
|
+
const now = Date.now();
|
|
14004
|
+
for (const [sid, expiresAt] of this.sessions) if (now >= expiresAt) this.sessions.delete(sid);
|
|
14005
|
+
}
|
|
14006
|
+
};
|
|
14007
|
+
/** Loopback requests are trusted without a key (local `--open` flow). */
|
|
14008
|
+
function isLoopbackAddress(address) {
|
|
14009
|
+
return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
|
|
14010
|
+
}
|
|
14011
|
+
function parseCookies(header) {
|
|
14012
|
+
const out = {};
|
|
14013
|
+
if (!header) return out;
|
|
14014
|
+
for (const part of header.split(";")) {
|
|
14015
|
+
const eq = part.indexOf("=");
|
|
14016
|
+
if (eq <= 0) continue;
|
|
14017
|
+
const name = part.slice(0, eq).trim();
|
|
14018
|
+
const raw = part.slice(eq + 1).trim();
|
|
14019
|
+
if (name.length === 0) continue;
|
|
14020
|
+
try {
|
|
14021
|
+
out[name] = decodeURIComponent(raw);
|
|
14022
|
+
} catch {
|
|
14023
|
+
out[name] = raw;
|
|
14024
|
+
}
|
|
14025
|
+
}
|
|
14026
|
+
return out;
|
|
14027
|
+
}
|
|
14028
|
+
/**
|
|
14029
|
+
* Pure decision for the HTTP gateway gate, factored out of server.ts so it
|
|
14030
|
+
* can be unit-tested without real sockets.
|
|
14031
|
+
*/
|
|
14032
|
+
function gatewayVerdict(input) {
|
|
14033
|
+
if (input.loopback || input.authenticated) return "allow";
|
|
14034
|
+
if (input.path === "/gateway") return "allow";
|
|
14035
|
+
if (input.path === `${input.apiPrefix}/gateway/status`) return "allow";
|
|
14036
|
+
if (input.path === `${input.apiPrefix}/gateway/login`) return "allow";
|
|
14037
|
+
if (input.path === `${input.apiPrefix}/gateway/logout`) return "allow";
|
|
14038
|
+
if (input.path.startsWith(`${input.apiPrefix}/`)) return "unauthorized";
|
|
14039
|
+
if (input.method === "GET") return "redirect";
|
|
14040
|
+
return "unauthorized";
|
|
14041
|
+
}
|
|
14042
|
+
function randomSalt() {
|
|
14043
|
+
return randomBytes(SALT_LEN);
|
|
14044
|
+
}
|
|
14045
|
+
async function loadStoredKey(filePath) {
|
|
14046
|
+
let raw;
|
|
14047
|
+
try {
|
|
14048
|
+
raw = await readFile(filePath, "utf-8");
|
|
14049
|
+
} catch {
|
|
14050
|
+
return null;
|
|
14051
|
+
}
|
|
14052
|
+
try {
|
|
14053
|
+
const parsed = JSON.parse(raw);
|
|
14054
|
+
if (parsed?.version !== 1 || typeof parsed.salt !== "string" || typeof parsed.hash !== "string" || parsed.salt.length === 0 || parsed.hash.length === 0) return null;
|
|
14055
|
+
return parsed;
|
|
14056
|
+
} catch {
|
|
14057
|
+
return null;
|
|
14058
|
+
}
|
|
14059
|
+
}
|
|
14060
|
+
/** Atomic persist: temp file (0600) + rename, then verify the mode. */
|
|
14061
|
+
async function persistKey(filePath, record) {
|
|
14062
|
+
const tmp = `${filePath}.tmp`;
|
|
14063
|
+
await writeFile(tmp, `${JSON.stringify(record, null, 2)}\n`, {
|
|
14064
|
+
encoding: "utf-8",
|
|
14065
|
+
mode: 384
|
|
14066
|
+
});
|
|
14067
|
+
await chmod(tmp, 384);
|
|
14068
|
+
await rename(tmp, filePath);
|
|
14069
|
+
}
|
|
14070
|
+
//#endregion
|
|
14071
|
+
//#region src/web/rateLimit.ts
|
|
14072
|
+
function createFixedWindowLimiter(opts) {
|
|
14073
|
+
const { windowMs, max } = opts;
|
|
14074
|
+
const windows = /* @__PURE__ */ new Map();
|
|
14075
|
+
return {
|
|
14076
|
+
hit(key) {
|
|
14077
|
+
const now = Date.now();
|
|
14078
|
+
const entry = windows.get(key);
|
|
14079
|
+
if (entry === void 0 || now - entry.start >= windowMs) {
|
|
14080
|
+
windows.set(key, {
|
|
14081
|
+
start: now,
|
|
14082
|
+
count: 1
|
|
14083
|
+
});
|
|
14084
|
+
if (windows.size > 1024) {
|
|
14085
|
+
for (const [k, e] of windows) if (now - e.start >= windowMs) windows.delete(k);
|
|
14086
|
+
}
|
|
14087
|
+
return {
|
|
14088
|
+
ok: true,
|
|
14089
|
+
retryAfterSeconds: 0
|
|
14090
|
+
};
|
|
14091
|
+
}
|
|
14092
|
+
entry.count += 1;
|
|
14093
|
+
if (entry.count > max) return {
|
|
14094
|
+
ok: false,
|
|
14095
|
+
retryAfterSeconds: Math.max(1, Math.ceil((entry.start + windowMs - now) / 1e3))
|
|
14096
|
+
};
|
|
14097
|
+
return {
|
|
14098
|
+
ok: true,
|
|
14099
|
+
retryAfterSeconds: 0
|
|
14100
|
+
};
|
|
14101
|
+
},
|
|
14102
|
+
reset(key) {
|
|
14103
|
+
windows.delete(key);
|
|
14104
|
+
}
|
|
14105
|
+
};
|
|
14106
|
+
}
|
|
14107
|
+
//#endregion
|
|
14108
|
+
//#region src/web/lan.ts
|
|
14109
|
+
/**
|
|
14110
|
+
* LAN address enumeration for `scream web --lan`.
|
|
14111
|
+
*
|
|
14112
|
+
* Collects IPv4 addresses that LAN peers can actually reach. Excludes the
|
|
14113
|
+
* loopback interface, IPv6 (the first version targets IPv4 LANs), link-local
|
|
14114
|
+
* autoconfiguration (169.254.0.0/16) and carrier-grade NAT space
|
|
14115
|
+
* (100.64.0.0/10, where inbound routing is not possible). Addresses outside
|
|
14116
|
+
* the private ranges are kept — a machine with a directly routed public IPv4
|
|
14117
|
+
* is reachable as-is.
|
|
14118
|
+
*/
|
|
14119
|
+
function getLanAddresses(interfaces = networkInterfaces()) {
|
|
14120
|
+
const seen = /* @__PURE__ */ new Set();
|
|
14121
|
+
for (const infos of Object.values(interfaces)) for (const info of infos ?? []) {
|
|
14122
|
+
if (info.family !== "IPv4" || info.internal) continue;
|
|
14123
|
+
const ip = info.address;
|
|
14124
|
+
if (inCidr(ip, "169.254.0.0", 16)) continue;
|
|
14125
|
+
if (inCidr(ip, "100.64.0.0", 10)) continue;
|
|
14126
|
+
if (inCidr(ip, "198.18.0.0", 15)) continue;
|
|
14127
|
+
seen.add(ip);
|
|
14128
|
+
}
|
|
14129
|
+
return [...seen];
|
|
14130
|
+
}
|
|
14131
|
+
function inCidr(ip, base, bits) {
|
|
14132
|
+
const ipInt = ipv4ToInt(ip);
|
|
14133
|
+
const baseInt = ipv4ToInt(base);
|
|
14134
|
+
if (ipInt === null || baseInt === null) return false;
|
|
14135
|
+
if (bits <= 0) return true;
|
|
14136
|
+
if (bits >= 32) return ipInt === baseInt;
|
|
14137
|
+
const mask = 4294967295 << 32 - bits;
|
|
14138
|
+
return (ipInt & mask) === (baseInt & mask);
|
|
14139
|
+
}
|
|
14140
|
+
function ipv4ToInt(ip) {
|
|
14141
|
+
const parts = ip.split(".");
|
|
14142
|
+
if (parts.length !== 4) return null;
|
|
14143
|
+
let value = 0;
|
|
14144
|
+
for (const part of parts) {
|
|
14145
|
+
const octet = Number(part);
|
|
14146
|
+
if (!Number.isInteger(octet) || octet < 0 || octet > 255) return null;
|
|
14147
|
+
value = value * 256 + octet;
|
|
14148
|
+
}
|
|
14149
|
+
return value;
|
|
14150
|
+
}
|
|
14151
|
+
//#endregion
|
|
14152
|
+
//#region src/web/files.ts
|
|
14153
|
+
/** File-access error carrying an HTTP status code for REST handlers. */
|
|
14154
|
+
var FileAccessError = class extends Error {
|
|
14155
|
+
statusCode;
|
|
14156
|
+
constructor(statusCode, message) {
|
|
14157
|
+
super(message);
|
|
14158
|
+
this.statusCode = statusCode;
|
|
14159
|
+
}
|
|
14160
|
+
};
|
|
14161
|
+
const MAX_FILE_READ = 256 * 1024;
|
|
14162
|
+
const IGNORED_DIRS = new Set([
|
|
14163
|
+
"node_modules",
|
|
14164
|
+
".git",
|
|
14165
|
+
"dist",
|
|
14166
|
+
".next",
|
|
14167
|
+
"target"
|
|
14168
|
+
]);
|
|
14169
|
+
/**
|
|
14170
|
+
* Read-only filesystem gate for the web UI.
|
|
14171
|
+
*
|
|
14172
|
+
* Design (roots + realpath containment model, simplified):
|
|
14173
|
+
* - a dynamic roots set (all session workDirs ∪ current workDir) bounds every access;
|
|
14174
|
+
* - lexical containment check resolves the candidate inside a root FIRST;
|
|
14175
|
+
* - then realpath() is compared against the realpath of the root so symlinks
|
|
14176
|
+
* cannot escape the boundary;
|
|
14177
|
+
* - every operation is read-only: no create/write/delete is exposed.
|
|
14178
|
+
*/
|
|
14179
|
+
var FileGate = class {
|
|
14180
|
+
roots;
|
|
14181
|
+
constructor(roots) {
|
|
14182
|
+
this.roots = roots;
|
|
14183
|
+
}
|
|
14184
|
+
/** Current allowed roots (session workdirs) — used by the REST /files/root route. */
|
|
14185
|
+
getRoots() {
|
|
14186
|
+
return this.roots();
|
|
14187
|
+
}
|
|
14188
|
+
/** Resolve a client-supplied path against the gate. Throws 403/404 on violation. */
|
|
14189
|
+
async resolve(candidate) {
|
|
14190
|
+
const roots = this.roots().filter(Boolean);
|
|
14191
|
+
if (roots.length === 0) throw new FileAccessError(409, "没有可访问的目录(尚无会话)");
|
|
14192
|
+
const normalized = candidate.replaceAll("\\", "/");
|
|
14193
|
+
const absCandidate = resolve(isAbsolute(normalized) ? normalized : join(resolve(roots[0]), normalized));
|
|
14194
|
+
for (const root of roots) {
|
|
14195
|
+
const rootLex = resolve(root);
|
|
14196
|
+
const relLex = relative(rootLex, absCandidate);
|
|
14197
|
+
if (relLex === "" || !relLex.startsWith("..") && !isAbsolute(relLex)) {
|
|
14198
|
+
const candidateReal = await this.tryRealpath(absCandidate);
|
|
14199
|
+
if (!candidateReal) throw new FileAccessError(404, `文件不存在:${candidate}`);
|
|
14200
|
+
const relReal = relative(await this.tryRealpath(rootLex) ?? rootLex, candidateReal);
|
|
14201
|
+
if (relReal === "" || !relReal.startsWith("..") && !isAbsolute(relReal)) return absCandidate;
|
|
14202
|
+
}
|
|
14203
|
+
}
|
|
14204
|
+
throw new FileAccessError(403, "目录或路径不属于任何会话工作目录");
|
|
14205
|
+
}
|
|
14206
|
+
async tryRealpath(p) {
|
|
14207
|
+
try {
|
|
14208
|
+
return await realpath(p);
|
|
14209
|
+
} catch {
|
|
14210
|
+
return null;
|
|
14211
|
+
}
|
|
14212
|
+
}
|
|
14213
|
+
async list(candidate) {
|
|
14214
|
+
const abs = await this.resolve(candidate);
|
|
14215
|
+
let entries = [];
|
|
14216
|
+
try {
|
|
14217
|
+
entries = await readdir(abs, { withFileTypes: true });
|
|
14218
|
+
} catch (error) {
|
|
14219
|
+
throw new FileAccessError(404, `无法读取目录:${candidate}`);
|
|
14220
|
+
}
|
|
14221
|
+
const out = [];
|
|
14222
|
+
for (const entry of entries) {
|
|
14223
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
14224
|
+
const full = join(abs, entry.name);
|
|
14225
|
+
try {
|
|
14226
|
+
if (entry.isSymbolicLink()) {
|
|
14227
|
+
const st = await stat(await this.resolve(full));
|
|
14228
|
+
out.push({
|
|
14229
|
+
name: entry.name,
|
|
14230
|
+
path: full,
|
|
14231
|
+
type: st.isDirectory() ? "dir" : "file",
|
|
14232
|
+
size: st.size,
|
|
14233
|
+
mtime: st.mtimeMs
|
|
14234
|
+
});
|
|
14235
|
+
} else if (entry.isDirectory()) {
|
|
14236
|
+
const st = await stat(full);
|
|
14237
|
+
out.push({
|
|
14238
|
+
name: entry.name,
|
|
14239
|
+
path: full,
|
|
14240
|
+
type: "dir",
|
|
14241
|
+
size: 0,
|
|
14242
|
+
mtime: st.mtimeMs
|
|
14243
|
+
});
|
|
14244
|
+
} else if (entry.isFile()) {
|
|
14245
|
+
const st = await stat(full);
|
|
14246
|
+
out.push({
|
|
14247
|
+
name: entry.name,
|
|
14248
|
+
path: full,
|
|
14249
|
+
type: "file",
|
|
14250
|
+
size: st.size,
|
|
14251
|
+
mtime: st.mtimeMs
|
|
14252
|
+
});
|
|
14253
|
+
}
|
|
14254
|
+
} catch {}
|
|
14255
|
+
}
|
|
14256
|
+
out.sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1);
|
|
14257
|
+
return out;
|
|
14258
|
+
}
|
|
14259
|
+
async read(candidate) {
|
|
14260
|
+
const abs = await this.resolve(candidate);
|
|
14261
|
+
const st = await stat(abs).catch(() => null);
|
|
14262
|
+
if (!st) throw new FileAccessError(404, `文件不存在:${candidate}`);
|
|
14263
|
+
if (st.isDirectory()) throw new FileAccessError(400, `是目录:${candidate}`);
|
|
14264
|
+
if (st.size > 262144) return {
|
|
14265
|
+
path: abs,
|
|
14266
|
+
content: (await readFile(abs, { encoding: "utf-8" })).slice(0, MAX_FILE_READ),
|
|
14267
|
+
truncated: true,
|
|
14268
|
+
encoding: "utf-8"
|
|
14269
|
+
};
|
|
14270
|
+
return {
|
|
14271
|
+
path: abs,
|
|
14272
|
+
content: await readFile(abs, { encoding: "utf-8" }),
|
|
14273
|
+
truncated: false,
|
|
14274
|
+
encoding: "utf-8"
|
|
14275
|
+
};
|
|
14276
|
+
}
|
|
14277
|
+
/** Mime type guess used by the raw/preview endpoint. */
|
|
14278
|
+
contentType(candidate) {
|
|
14279
|
+
return {
|
|
14280
|
+
png: "image/png",
|
|
14281
|
+
jpg: "image/jpeg",
|
|
14282
|
+
jpeg: "image/jpeg",
|
|
14283
|
+
gif: "image/gif",
|
|
14284
|
+
webp: "image/webp",
|
|
14285
|
+
svg: "image/svg+xml",
|
|
14286
|
+
ico: "image/x-icon",
|
|
14287
|
+
avif: "image/avif",
|
|
14288
|
+
mp3: "audio/mpeg",
|
|
14289
|
+
wav: "audio/wav",
|
|
14290
|
+
ogg: "audio/ogg",
|
|
14291
|
+
m4a: "audio/mp4",
|
|
14292
|
+
pdf: "application/pdf",
|
|
14293
|
+
md: "text/markdown; charset=utf-8",
|
|
14294
|
+
txt: "text/plain; charset=utf-8",
|
|
14295
|
+
json: "application/json; charset=utf-8",
|
|
14296
|
+
yml: "text/yaml; charset=utf-8",
|
|
14297
|
+
yaml: "text/yaml; charset=utf-8",
|
|
14298
|
+
csv: "text/csv; charset=utf-8",
|
|
14299
|
+
log: "text/plain; charset=utf-8",
|
|
14300
|
+
ts: "text/plain; charset=utf-8",
|
|
14301
|
+
tsx: "text/plain; charset=utf-8",
|
|
14302
|
+
js: "text/plain; charset=utf-8",
|
|
14303
|
+
jsx: "text/plain; charset=utf-8",
|
|
14304
|
+
py: "text/plain; charset=utf-8",
|
|
14305
|
+
rs: "text/plain; charset=utf-8",
|
|
14306
|
+
go: "text/plain; charset=utf-8",
|
|
14307
|
+
java: "text/plain; charset=utf-8",
|
|
14308
|
+
c: "text/plain; charset=utf-8",
|
|
14309
|
+
h: "text/plain; charset=utf-8",
|
|
14310
|
+
cpp: "text/plain; charset=utf-8",
|
|
14311
|
+
hpp: "text/plain; charset=utf-8",
|
|
14312
|
+
sh: "text/plain; charset=utf-8",
|
|
14313
|
+
bash: "text/plain; charset=utf-8",
|
|
14314
|
+
zsh: "text/plain; charset=utf-8",
|
|
14315
|
+
css: "text/plain; charset=utf-8",
|
|
14316
|
+
html: "text/plain; charset=utf-8",
|
|
14317
|
+
vue: "text/plain; charset=utf-8",
|
|
14318
|
+
toml: "text/plain; charset=utf-8",
|
|
14319
|
+
lock: "text/plain; charset=utf-8",
|
|
14320
|
+
env: "text/plain; charset=utf-8",
|
|
14321
|
+
patch: "text/plain; charset=utf-8",
|
|
14322
|
+
diff: "text/plain; charset=utf-8"
|
|
14323
|
+
}[basename(candidate).split(".").pop()?.toLowerCase() ?? ""] ?? "application/octet-stream";
|
|
14324
|
+
}
|
|
14325
|
+
/** MIME for read-only raw preview; returns null when the type is not inherently printable. */
|
|
14326
|
+
previewKind(candidate) {
|
|
14327
|
+
const ct = this.contentType(candidate);
|
|
14328
|
+
if (ct.startsWith("image/")) return "image";
|
|
14329
|
+
if (ct.startsWith("audio/")) return "audio";
|
|
14330
|
+
if (ct === "application/pdf") return "pdf";
|
|
14331
|
+
if (ct.startsWith("text/")) return "text";
|
|
14332
|
+
return null;
|
|
14333
|
+
}
|
|
14334
|
+
/** Directory of a resolved path, used for the breadcrumb. */
|
|
14335
|
+
dirOf(absPath) {
|
|
14336
|
+
return dirname(absPath);
|
|
14337
|
+
}
|
|
14338
|
+
/** Path-friendly display name for the breadcrumb. */
|
|
14339
|
+
nameOf(absPath) {
|
|
14340
|
+
return basename(absPath);
|
|
14341
|
+
}
|
|
14342
|
+
};
|
|
14343
|
+
//#endregion
|
|
13695
14344
|
//#region src/web/server.ts
|
|
13696
14345
|
/**
|
|
13697
14346
|
* Scream Web UI server.
|
|
@@ -13709,6 +14358,14 @@ function installStreamJsonTerminationHandlers(cleanup) {
|
|
|
13709
14358
|
* consumer of the same SDK (alongside run-shell TUI and run-stream-json),
|
|
13710
14359
|
* not a separate engine. Zero changes to agent-core or node-sdk.
|
|
13711
14360
|
*/
|
|
14361
|
+
/** Thinking bodies longer than this are delivered as a tail excerpt + on-demand full text. */
|
|
14362
|
+
const TRUNCATE_THINKING_LEN = 8 * 1024;
|
|
14363
|
+
function truncateThinking(messages) {
|
|
14364
|
+
for (const m of messages) for (const t of m.tools) if (t.name === "thinking" && typeof t.output === "string" && t.output.length > TRUNCATE_THINKING_LEN) {
|
|
14365
|
+
t.output = `…(前 ${t.output.length - 2048} 字符已省略)\n${t.output.slice(-2048)}`;
|
|
14366
|
+
t.truncated = true;
|
|
14367
|
+
}
|
|
14368
|
+
}
|
|
13712
14369
|
/** Error carrying an HTTP status code for REST handlers. */
|
|
13713
14370
|
var HttpError = class extends Error {
|
|
13714
14371
|
statusCode;
|
|
@@ -13879,6 +14536,7 @@ function toHttpError(error) {
|
|
|
13879
14536
|
case ErrorCodes.TURN_AGENT_BUSY: return new HttpError(409, error.message, error.code);
|
|
13880
14537
|
default: return new HttpError(500, error.message, error.code);
|
|
13881
14538
|
}
|
|
14539
|
+
if (error instanceof FileAccessError) return new HttpError(error.statusCode, error.message);
|
|
13882
14540
|
return new HttpError(500, errorMessage(error));
|
|
13883
14541
|
}
|
|
13884
14542
|
function sendHttpError(res, error) {
|
|
@@ -13888,6 +14546,57 @@ function sendHttpError(res, error) {
|
|
|
13888
14546
|
message: mapped.message
|
|
13889
14547
|
});
|
|
13890
14548
|
}
|
|
14549
|
+
async function handleFilesRoutes(req, res, url, method, apiPrefix, gate) {
|
|
14550
|
+
if (method !== "GET") return false;
|
|
14551
|
+
const [pathOnly, queryPart] = url.split("?");
|
|
14552
|
+
const query = new URLSearchParams(queryPart ?? "");
|
|
14553
|
+
const fail = (error) => {
|
|
14554
|
+
const code = error instanceof FileAccessError ? error.statusCode : 500;
|
|
14555
|
+
res.writeHead(code, { "Content-Type": "application/json" });
|
|
14556
|
+
res.end(JSON.stringify({
|
|
14557
|
+
code,
|
|
14558
|
+
message: error instanceof Error ? error.message : String(error)
|
|
14559
|
+
}));
|
|
14560
|
+
};
|
|
14561
|
+
if (pathOnly === `${apiPrefix}/files/root`) {
|
|
14562
|
+
sendJson(res, 200, { roots: gate.getRoots() });
|
|
14563
|
+
return true;
|
|
14564
|
+
}
|
|
14565
|
+
if (pathOnly === `${apiPrefix}/files/list`) {
|
|
14566
|
+
try {
|
|
14567
|
+
sendJson(res, 200, {
|
|
14568
|
+
path: query.get("path") ?? "",
|
|
14569
|
+
entries: await gate.list(query.get("path") ?? "")
|
|
14570
|
+
});
|
|
14571
|
+
} catch (error) {
|
|
14572
|
+
fail(error);
|
|
14573
|
+
}
|
|
14574
|
+
return true;
|
|
14575
|
+
}
|
|
14576
|
+
if (pathOnly === `${apiPrefix}/files/read`) {
|
|
14577
|
+
try {
|
|
14578
|
+
sendJson(res, 200, await gate.read(query.get("path") ?? ""));
|
|
14579
|
+
} catch (error) {
|
|
14580
|
+
fail(error);
|
|
14581
|
+
}
|
|
14582
|
+
return true;
|
|
14583
|
+
}
|
|
14584
|
+
if (pathOnly === `${apiPrefix}/files/raw`) {
|
|
14585
|
+
try {
|
|
14586
|
+
const abs = await gate.resolve(query.get("path") ?? "");
|
|
14587
|
+
const data = await readFile(abs);
|
|
14588
|
+
res.writeHead(200, {
|
|
14589
|
+
"Content-Type": gate.contentType(abs),
|
|
14590
|
+
"Cache-Control": "no-store"
|
|
14591
|
+
});
|
|
14592
|
+
res.end(data);
|
|
14593
|
+
} catch (error) {
|
|
14594
|
+
fail(error);
|
|
14595
|
+
}
|
|
14596
|
+
return true;
|
|
14597
|
+
}
|
|
14598
|
+
return false;
|
|
14599
|
+
}
|
|
13891
14600
|
function isVolatileEvent(event) {
|
|
13892
14601
|
switch (event.type) {
|
|
13893
14602
|
case "assistant.delta":
|
|
@@ -13978,6 +14687,25 @@ function deriveTitle(messages) {
|
|
|
13978
14687
|
return text.length > 40 ? text.slice(0, 40) + "..." : text;
|
|
13979
14688
|
}
|
|
13980
14689
|
const execFileAsync = promisify(execFile);
|
|
14690
|
+
/** Raw porcelain rows: repo-root-relative path + XY status. `getGitStatus`
|
|
14691
|
+
* enriches these into full `GitFileChange` records. */
|
|
14692
|
+
function parsePorcelainFiles(lines) {
|
|
14693
|
+
const files = [];
|
|
14694
|
+
for (const line of lines) {
|
|
14695
|
+
if (line.startsWith("##")) continue;
|
|
14696
|
+
const status = line.slice(0, 2).replaceAll(" ", "");
|
|
14697
|
+
let p = line.slice(3).trim();
|
|
14698
|
+
if (p.includes(" -> ")) p = p.split(" -> ").pop();
|
|
14699
|
+
if (p.startsWith("\"") && p.endsWith("\"")) try {
|
|
14700
|
+
p = JSON.parse(p);
|
|
14701
|
+
} catch {}
|
|
14702
|
+
files.push({
|
|
14703
|
+
path: p,
|
|
14704
|
+
status
|
|
14705
|
+
});
|
|
14706
|
+
}
|
|
14707
|
+
return files;
|
|
14708
|
+
}
|
|
13981
14709
|
async function git(workDir, args) {
|
|
13982
14710
|
const { stdout } = await execFileAsync("git", [
|
|
13983
14711
|
"-C",
|
|
@@ -13999,13 +14727,21 @@ async function getGitStatus(workDir) {
|
|
|
13999
14727
|
let ahead;
|
|
14000
14728
|
let behind;
|
|
14001
14729
|
let changed = 0;
|
|
14730
|
+
let gitFiles = [];
|
|
14002
14731
|
try {
|
|
14003
|
-
const [branchOut, statusOut] = await Promise.all([
|
|
14004
|
-
"
|
|
14005
|
-
|
|
14006
|
-
|
|
14007
|
-
|
|
14732
|
+
const [branchOut, statusOut, repoTopOut] = await Promise.all([
|
|
14733
|
+
git(workDir, ["branch", "--show-current"]),
|
|
14734
|
+
git(workDir, [
|
|
14735
|
+
"status",
|
|
14736
|
+
"--porcelain",
|
|
14737
|
+
"-b"
|
|
14738
|
+
]),
|
|
14739
|
+
git(workDir, ["rev-parse", "--show-toplevel"]).catch(() => workDir)
|
|
14740
|
+
]);
|
|
14008
14741
|
branch = branchOut.trim() || void 0;
|
|
14742
|
+
const repoTop = repoTopOut.trim() || workDir;
|
|
14743
|
+
const prefix = repoTop !== workDir ? `${relative(repoTop, workDir).replaceAll("\\", "/")}/` : "";
|
|
14744
|
+
const fixPath = (p) => prefix && p.startsWith(prefix) ? p.slice(prefix.length) : p;
|
|
14009
14745
|
const statusLines = statusOut.split("\n").filter((l) => l.length > 0);
|
|
14010
14746
|
const header = statusLines[0] ?? "";
|
|
14011
14747
|
const aheadMatch = /ahead (\d+)/.exec(header);
|
|
@@ -14013,10 +14749,18 @@ async function getGitStatus(workDir) {
|
|
|
14013
14749
|
if (aheadMatch) ahead = Number(aheadMatch[1]);
|
|
14014
14750
|
if (behindMatch) behind = Number(behindMatch[1]);
|
|
14015
14751
|
changed = statusLines.filter((l) => !l.startsWith("##")).length;
|
|
14752
|
+
const statusFiles = parsePorcelainFiles(statusLines).map((f) => ({
|
|
14753
|
+
path: f.path,
|
|
14754
|
+
displayPath: fixPath(f.path),
|
|
14755
|
+
status: f.status
|
|
14756
|
+
}));
|
|
14757
|
+
if (statusFiles.length > 0) gitFiles = statusFiles;
|
|
14016
14758
|
} catch {}
|
|
14017
14759
|
let adds;
|
|
14018
14760
|
let dels;
|
|
14019
14761
|
let diffStat;
|
|
14762
|
+
/** Repo-root-relative → per-file line counts, from `git diff --numstat`. */
|
|
14763
|
+
const perFile = /* @__PURE__ */ new Map();
|
|
14020
14764
|
try {
|
|
14021
14765
|
const [numstatOut, diffStatOut] = await Promise.all([git(workDir, [
|
|
14022
14766
|
"diff",
|
|
@@ -14030,14 +14774,41 @@ async function getGitStatus(workDir) {
|
|
|
14030
14774
|
adds = 0;
|
|
14031
14775
|
dels = 0;
|
|
14032
14776
|
for (const line of numstatOut.split("\n")) {
|
|
14033
|
-
const
|
|
14034
|
-
|
|
14035
|
-
|
|
14036
|
-
|
|
14037
|
-
|
|
14777
|
+
const parts = line.split(" ");
|
|
14778
|
+
const m = parts.length >= 3 ? /^(\d+)\t(\d+)\t/.exec(line) : null;
|
|
14779
|
+
if (!m) continue;
|
|
14780
|
+
adds += Number(m[1]);
|
|
14781
|
+
dels += Number(m[2]);
|
|
14782
|
+
perFile.set(normalizeNumstatPath(parts.slice(2).join(" ")), {
|
|
14783
|
+
adds: Number(m[1]),
|
|
14784
|
+
dels: Number(m[2])
|
|
14785
|
+
});
|
|
14038
14786
|
}
|
|
14039
14787
|
diffStat = diffStatOut.trim().split("\n").slice(0, 200).join("\n") || void 0;
|
|
14040
14788
|
} catch {}
|
|
14789
|
+
const untracked = /* @__PURE__ */ new Set();
|
|
14790
|
+
try {
|
|
14791
|
+
const others = await git(workDir, [
|
|
14792
|
+
"ls-files",
|
|
14793
|
+
"--others",
|
|
14794
|
+
"--exclude-standard",
|
|
14795
|
+
"-z"
|
|
14796
|
+
]);
|
|
14797
|
+
for (const p of others.split("\0")) if (p.trim() !== "") untracked.add(p.replaceAll("\\", "/"));
|
|
14798
|
+
} catch {}
|
|
14799
|
+
gitFiles = gitFiles.map((file) => {
|
|
14800
|
+
const stats = perFile.get(file.path);
|
|
14801
|
+
const isUntracked = untracked.has(file.path) || file.status.includes("?");
|
|
14802
|
+
return {
|
|
14803
|
+
...file,
|
|
14804
|
+
status: isUntracked ? "?" : file.status,
|
|
14805
|
+
...isUntracked ? { untracked: true } : {},
|
|
14806
|
+
...stats ? {
|
|
14807
|
+
adds: stats.adds,
|
|
14808
|
+
dels: stats.dels
|
|
14809
|
+
} : {}
|
|
14810
|
+
};
|
|
14811
|
+
});
|
|
14041
14812
|
return {
|
|
14042
14813
|
isRepo: true,
|
|
14043
14814
|
branch,
|
|
@@ -14046,9 +14817,97 @@ async function getGitStatus(workDir) {
|
|
|
14046
14817
|
changed,
|
|
14047
14818
|
adds,
|
|
14048
14819
|
dels,
|
|
14049
|
-
diffStat
|
|
14820
|
+
diffStat,
|
|
14821
|
+
files: gitFiles.length > 0 ? gitFiles : void 0
|
|
14050
14822
|
};
|
|
14051
14823
|
}
|
|
14824
|
+
/**
|
|
14825
|
+
* numstat keys for renames come back as `a/{old => new}/b.ts` or
|
|
14826
|
+
* `old => new`; resolve them to the post-rename path so they match porcelain.
|
|
14827
|
+
*/
|
|
14828
|
+
function normalizeNumstatPath(raw) {
|
|
14829
|
+
let p = raw.trim().replaceAll("\\", "/");
|
|
14830
|
+
const brace = /\{([^{}]*) => ([^{}]*)\}/.exec(p);
|
|
14831
|
+
if (brace) p = p.replace(brace[0], `${brace[1]}${brace[2]}`);
|
|
14832
|
+
if (p.includes(" => ")) p = p.split(" => ").pop().trim();
|
|
14833
|
+
return p.replaceAll("//", "/").replace(/^\.\//, "").replace(/^\/+/, "");
|
|
14834
|
+
}
|
|
14835
|
+
/** Cap for the inlined content of an untracked file. */
|
|
14836
|
+
const MAX_INLINE_DIFF_BYTES = 512 * 1024;
|
|
14837
|
+
/**
|
|
14838
|
+
* Single-file patch. `repoPath` is **repository-root relative** — the same base
|
|
14839
|
+
* `git status` reports, so the UI can hand it straight back.
|
|
14840
|
+
*
|
|
14841
|
+
* Two cases used to return an empty patch and look like "点了没反应":
|
|
14842
|
+
* nested files (the old code re-prefixed workdir-relative paths with `../..`,
|
|
14843
|
+
* resolving them outside the repo) and untracked files (`git diff` has nothing
|
|
14844
|
+
* to say about them). The second case now gets a synthesised added-file patch.
|
|
14845
|
+
*/
|
|
14846
|
+
async function getGitFileDiff(workDir, repoPath) {
|
|
14847
|
+
const rel = normalizeNumstatPath(repoPath);
|
|
14848
|
+
if (rel === "") throw new FileAccessError(403, "路径不能为空");
|
|
14849
|
+
let repoTop = "";
|
|
14850
|
+
try {
|
|
14851
|
+
repoTop = (await git(workDir, ["rev-parse", "--show-toplevel"])).trim();
|
|
14852
|
+
} catch {
|
|
14853
|
+
return null;
|
|
14854
|
+
}
|
|
14855
|
+
if (!repoTop) throw new FileAccessError(403, "不在 git 仓库内");
|
|
14856
|
+
const abs = resolve(repoTop, rel);
|
|
14857
|
+
const inside = relative(repoTop, abs);
|
|
14858
|
+
if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) throw new FileAccessError(403, "路径不属于该仓库");
|
|
14859
|
+
const patch = await git(repoTop, [
|
|
14860
|
+
"diff",
|
|
14861
|
+
"--no-color",
|
|
14862
|
+
"--",
|
|
14863
|
+
rel
|
|
14864
|
+
]).catch(() => "");
|
|
14865
|
+
if (patch.trim()) return {
|
|
14866
|
+
path: rel,
|
|
14867
|
+
patch
|
|
14868
|
+
};
|
|
14869
|
+
if (!((await git(repoTop, [
|
|
14870
|
+
"ls-files",
|
|
14871
|
+
"--others",
|
|
14872
|
+
"--exclude-standard",
|
|
14873
|
+
"--",
|
|
14874
|
+
rel
|
|
14875
|
+
]).catch(() => "")).trim() !== "")) return {
|
|
14876
|
+
path: rel,
|
|
14877
|
+
patch: ""
|
|
14878
|
+
};
|
|
14879
|
+
const header = `--- /dev/null\n+++ b/${rel}\n`;
|
|
14880
|
+
try {
|
|
14881
|
+
const [realRoot, realAbs] = await Promise.all([realpath(repoTop), realpath(abs)]);
|
|
14882
|
+
const relCheck = relative(realRoot, realAbs);
|
|
14883
|
+
if (relCheck === "" || relCheck.startsWith("..") || isAbsolute(relCheck)) throw new FileAccessError(403, "路径不属于该仓库");
|
|
14884
|
+
const info = await stat(realAbs);
|
|
14885
|
+
if (!info.isFile()) return {
|
|
14886
|
+
path: rel,
|
|
14887
|
+
patch: `${header}@@ -0,0 +1 @@\n+(非普通文件)\n`
|
|
14888
|
+
};
|
|
14889
|
+
if (info.size > MAX_INLINE_DIFF_BYTES) return {
|
|
14890
|
+
path: rel,
|
|
14891
|
+
patch: `${header}@@ -0,0 +1 @@\n+(文件超过 ${Math.round(MAX_INLINE_DIFF_BYTES / 1024)}KB,未内联显示)\n`
|
|
14892
|
+
};
|
|
14893
|
+
const content = await readFile(realAbs, "utf8");
|
|
14894
|
+
if (content.includes("\0")) return {
|
|
14895
|
+
path: rel,
|
|
14896
|
+
patch: `${header}@@ -0,0 +1 @@\n+(二进制文件)\n`
|
|
14897
|
+
};
|
|
14898
|
+
const lines = content.replaceAll("\r\n", "\n").split("\n");
|
|
14899
|
+
if (lines.at(-1) === "") lines.pop();
|
|
14900
|
+
return {
|
|
14901
|
+
path: rel,
|
|
14902
|
+
patch: `${header}@@ -0,0 +1,${lines.length} @@\n${lines.map((l) => `+${l}`).join("\n")}\n`
|
|
14903
|
+
};
|
|
14904
|
+
} catch {
|
|
14905
|
+
return {
|
|
14906
|
+
path: rel,
|
|
14907
|
+
patch: `${header}@@ -0,0 +1 @@\n+(读取失败)\n`
|
|
14908
|
+
};
|
|
14909
|
+
}
|
|
14910
|
+
}
|
|
14052
14911
|
function exportToMarkdown(messages, title) {
|
|
14053
14912
|
let out = `# ${title}\n\n`;
|
|
14054
14913
|
for (const msg of messages) if (msg.role === "user") out += `## User\n\n${msg.content}\n\n`;
|
|
@@ -14088,6 +14947,14 @@ var WebSession = class {
|
|
|
14088
14947
|
heartbeatTimer = null;
|
|
14089
14948
|
unsubscribe = null;
|
|
14090
14949
|
busy = false;
|
|
14950
|
+
/**
|
|
14951
|
+
* Live assistant message under construction. Mirrors the same state machine
|
|
14952
|
+
* used by buildMessages() so turn.ended can persist a complete durable
|
|
14953
|
+
* snapshot (`web.message.finalized`) — the only source that survives a
|
|
14954
|
+
* server restart (deltas are volatile by design).
|
|
14955
|
+
*/
|
|
14956
|
+
liveAssistant = null;
|
|
14957
|
+
liveTurnCount = 0;
|
|
14091
14958
|
/** pendingMsgId awaiting compaction completion/cancellation event. */
|
|
14092
14959
|
pendingCompactionMsgId = null;
|
|
14093
14960
|
homeDir;
|
|
@@ -14218,14 +15085,104 @@ var WebSession = class {
|
|
|
14218
15085
|
if (!entry.volatile) this.persistEntry(entry);
|
|
14219
15086
|
return entry;
|
|
14220
15087
|
}
|
|
15088
|
+
/**
|
|
15089
|
+
* Append + persist a durable journal entry WITHOUT broadcasting it to
|
|
15090
|
+
* clients. Used for server-owned reconstruction material (finalized
|
|
15091
|
+
* assistant snapshots) that clients never consume as live events.
|
|
15092
|
+
*/
|
|
15093
|
+
appendDurableSilent(payload) {
|
|
15094
|
+
const entry = {
|
|
15095
|
+
seq: this.nextSeq++,
|
|
15096
|
+
epoch: this.epoch,
|
|
15097
|
+
volatile: false,
|
|
15098
|
+
payload
|
|
15099
|
+
};
|
|
15100
|
+
this.journal.push(entry);
|
|
15101
|
+
this.persistEntry(entry);
|
|
15102
|
+
return entry;
|
|
15103
|
+
}
|
|
14221
15104
|
subscribeEvents() {
|
|
14222
15105
|
if (!this.session) return;
|
|
14223
15106
|
this.unsubscribe = this.session.onEvent((event) => {
|
|
14224
15107
|
if ((event.type === "goal.updated" || event.type === "todo.updated") && event.agentId !== "main") return;
|
|
14225
|
-
if (event.type === "turn.started")
|
|
14226
|
-
|
|
15108
|
+
if (event.type === "turn.started") {
|
|
15109
|
+
this.busy = true;
|
|
15110
|
+
this.liveTurnCount += 1;
|
|
15111
|
+
this.liveAssistant = {
|
|
15112
|
+
role: "assistant",
|
|
15113
|
+
content: "",
|
|
15114
|
+
tools: [],
|
|
15115
|
+
ts: Date.now(),
|
|
15116
|
+
model: this.cachedStatus?.model,
|
|
15117
|
+
turnStats: {
|
|
15118
|
+
turn: this.liveTurnCount,
|
|
15119
|
+
step: 0,
|
|
15120
|
+
status: "running",
|
|
15121
|
+
firstTokenMs: null,
|
|
15122
|
+
llmMs: null,
|
|
15123
|
+
toolMs: null,
|
|
15124
|
+
tokens: null,
|
|
15125
|
+
tokensPerSec: null
|
|
15126
|
+
}
|
|
15127
|
+
};
|
|
15128
|
+
} else if (event.type === "turn.ended") {
|
|
14227
15129
|
this.busy = false;
|
|
15130
|
+
const live = this.liveAssistant;
|
|
15131
|
+
if (event.reason === "failed" && live) live.isError = true;
|
|
15132
|
+
if (live) {
|
|
15133
|
+
live.turnStats = {
|
|
15134
|
+
...live.turnStats ?? {
|
|
15135
|
+
turn: this.liveTurnCount,
|
|
15136
|
+
step: 0,
|
|
15137
|
+
firstTokenMs: null,
|
|
15138
|
+
llmMs: null,
|
|
15139
|
+
toolMs: null,
|
|
15140
|
+
tokens: null,
|
|
15141
|
+
tokensPerSec: null
|
|
15142
|
+
},
|
|
15143
|
+
status: "done"
|
|
15144
|
+
};
|
|
15145
|
+
this.appendDurableSilent({
|
|
15146
|
+
type: "web.message.finalized",
|
|
15147
|
+
message: live
|
|
15148
|
+
});
|
|
15149
|
+
}
|
|
15150
|
+
this.liveAssistant = null;
|
|
14228
15151
|
this.updateTitle();
|
|
15152
|
+
} else if (event.type === "assistant.delta") {
|
|
15153
|
+
const live = this.liveAssistant;
|
|
15154
|
+
if (live) live.content += event.delta;
|
|
15155
|
+
} else if (event.type === "thinking.delta") {
|
|
15156
|
+
const live = this.liveAssistant;
|
|
15157
|
+
if (live) {
|
|
15158
|
+
const t = live.tools.find((x) => x.name === "thinking");
|
|
15159
|
+
if (t) t.output = (t.output ?? "") + event.delta;
|
|
15160
|
+
else live.tools.push({
|
|
15161
|
+
toolCallId: "thinking",
|
|
15162
|
+
name: "thinking",
|
|
15163
|
+
args: {},
|
|
15164
|
+
output: event.delta
|
|
15165
|
+
});
|
|
15166
|
+
}
|
|
15167
|
+
} else if (event.type === "tool.call.started") {
|
|
15168
|
+
const live = this.liveAssistant;
|
|
15169
|
+
if (live) {
|
|
15170
|
+
live.tools.push({
|
|
15171
|
+
toolCallId: event.toolCallId,
|
|
15172
|
+
name: event.name,
|
|
15173
|
+
args: event.args
|
|
15174
|
+
});
|
|
15175
|
+
if (live.turnStats) live.turnStats.step += 1;
|
|
15176
|
+
}
|
|
15177
|
+
} else if (event.type === "tool.result") {
|
|
15178
|
+
const live = this.liveAssistant;
|
|
15179
|
+
if (live) {
|
|
15180
|
+
const tool = live.tools.find((t) => t.toolCallId === event.toolCallId);
|
|
15181
|
+
if (tool) {
|
|
15182
|
+
tool.output = String(event.output);
|
|
15183
|
+
tool.isError = event.isError;
|
|
15184
|
+
}
|
|
15185
|
+
}
|
|
14229
15186
|
} else if (event.type === "goal.updated") {
|
|
14230
15187
|
this.goalEventRevision += 1;
|
|
14231
15188
|
this.cachedGoal = cloneSnapshot(event.snapshot);
|
|
@@ -14630,7 +15587,17 @@ var WebSession = class {
|
|
|
14630
15587
|
}
|
|
14631
15588
|
}, HEARTBEAT_INTERVAL_MS);
|
|
14632
15589
|
}
|
|
14633
|
-
getSnapshot() {
|
|
15590
|
+
getSnapshot(options) {
|
|
15591
|
+
const all = this.buildMessages();
|
|
15592
|
+
let messages = all;
|
|
15593
|
+
let olderAvailable;
|
|
15594
|
+
let oldestSeq;
|
|
15595
|
+
if (options?.tail && all.length > options.tail) {
|
|
15596
|
+
messages = all.slice(-options.tail);
|
|
15597
|
+
olderAvailable = true;
|
|
15598
|
+
oldestSeq = messages[0]?.seq;
|
|
15599
|
+
}
|
|
15600
|
+
truncateThinking(messages);
|
|
14634
15601
|
return cloneSnapshot({
|
|
14635
15602
|
sessionId: this.sessionId,
|
|
14636
15603
|
workDir: this.workDir,
|
|
@@ -14638,7 +15605,9 @@ var WebSession = class {
|
|
|
14638
15605
|
epoch: this.epoch,
|
|
14639
15606
|
model: this.cachedStatus?.model ?? "unknown",
|
|
14640
15607
|
permission: this.cachedStatus?.permission ?? this.permission,
|
|
14641
|
-
messages
|
|
15608
|
+
messages,
|
|
15609
|
+
olderAvailable,
|
|
15610
|
+
oldestSeq,
|
|
14642
15611
|
pendingApprovals: Array.from(this.pendingApprovals.entries()).map(([id, approval]) => ({
|
|
14643
15612
|
id,
|
|
14644
15613
|
toolName: approval.toolName,
|
|
@@ -14663,6 +15632,22 @@ var WebSession = class {
|
|
|
14663
15632
|
todos: this.cachedTodos
|
|
14664
15633
|
});
|
|
14665
15634
|
}
|
|
15635
|
+
/** Older history page: messages strictly older than `beforeSeq`, newest-first window of `tail`. */
|
|
15636
|
+
getMessagesOlder(beforeSeq, tail) {
|
|
15637
|
+
const older = this.buildMessages().filter((m) => (m.seq ?? Number.MAX_SAFE_INTEGER) < beforeSeq);
|
|
15638
|
+
return {
|
|
15639
|
+
messages: older.slice(-tail),
|
|
15640
|
+
hasMore: older.length > tail
|
|
15641
|
+
};
|
|
15642
|
+
}
|
|
15643
|
+
/** Full thinking text for a truncated entry (loaded on demand). */
|
|
15644
|
+
getThinkingEntry(seq, toolCallId) {
|
|
15645
|
+
const msg = this.buildMessages().find((m) => m.seq === seq);
|
|
15646
|
+
const tool = msg?.tools.find((t) => t.toolCallId === toolCallId);
|
|
15647
|
+
if (tool && typeof tool.output === "string") return tool.output;
|
|
15648
|
+
const thinking = msg?.tools.find((t) => t.name === "thinking");
|
|
15649
|
+
return thinking && typeof thinking.output === "string" ? thinking.output : "";
|
|
15650
|
+
}
|
|
14666
15651
|
getListItem() {
|
|
14667
15652
|
return {
|
|
14668
15653
|
sessionId: this.sessionId,
|
|
@@ -14818,10 +15803,14 @@ var WebSession = class {
|
|
|
14818
15803
|
while (userIndex < this.userMessages.length) {
|
|
14819
15804
|
const item = this.userMessages[userIndex];
|
|
14820
15805
|
if (item === void 0 || item.beforeSeq > seq) break;
|
|
14821
|
-
messages.push(
|
|
15806
|
+
messages.push({
|
|
15807
|
+
...item.msg,
|
|
15808
|
+
seq: item.beforeSeq
|
|
15809
|
+
});
|
|
14822
15810
|
userIndex++;
|
|
14823
15811
|
}
|
|
14824
15812
|
};
|
|
15813
|
+
let turnFinalized = false;
|
|
14825
15814
|
for (const entry of this.journal) {
|
|
14826
15815
|
const event = entry.payload;
|
|
14827
15816
|
flushUserMessagesBefore(entry.seq);
|
|
@@ -14832,6 +15821,8 @@ var WebSession = class {
|
|
|
14832
15821
|
role: "assistant",
|
|
14833
15822
|
content: "",
|
|
14834
15823
|
tools: [],
|
|
15824
|
+
seq: entry.seq,
|
|
15825
|
+
model: this.cachedStatus?.model,
|
|
14835
15826
|
turnStats: {
|
|
14836
15827
|
turn: turnCount,
|
|
14837
15828
|
step: 0,
|
|
@@ -14850,6 +15841,18 @@ var WebSession = class {
|
|
|
14850
15841
|
case "assistant.delta":
|
|
14851
15842
|
if (currentAssistant) currentAssistant.content += event.delta;
|
|
14852
15843
|
break;
|
|
15844
|
+
case "thinking.delta":
|
|
15845
|
+
if (currentAssistant) {
|
|
15846
|
+
const t = currentAssistant.tools.find((x) => x.name === "thinking");
|
|
15847
|
+
if (t) t.output = (t.output ?? "") + event.delta;
|
|
15848
|
+
else currentAssistant.tools.push({
|
|
15849
|
+
toolCallId: "thinking",
|
|
15850
|
+
name: "thinking",
|
|
15851
|
+
args: {},
|
|
15852
|
+
output: event.delta
|
|
15853
|
+
});
|
|
15854
|
+
}
|
|
15855
|
+
break;
|
|
14853
15856
|
case "tool.call.started":
|
|
14854
15857
|
if (currentAssistant) {
|
|
14855
15858
|
currentAssistant.tools.push({
|
|
@@ -14869,8 +15872,19 @@ var WebSession = class {
|
|
|
14869
15872
|
}
|
|
14870
15873
|
}
|
|
14871
15874
|
break;
|
|
15875
|
+
case "web.message.finalized": {
|
|
15876
|
+
const finalized = event.message;
|
|
15877
|
+
finalized.seq = messages.length > 0 && messages.at(-1)?.role === "assistant" ? messages.at(-1).seq : entry.seq;
|
|
15878
|
+
if (messages.length > 0 && messages.at(-1)?.role === "assistant") messages[messages.length - 1] = finalized;
|
|
15879
|
+
else messages.push(finalized);
|
|
15880
|
+
currentAssistant = null;
|
|
15881
|
+
turnFinalized = true;
|
|
15882
|
+
break;
|
|
15883
|
+
}
|
|
14872
15884
|
case "turn.ended":
|
|
14873
15885
|
if (event.reason === "failed" && currentAssistant) currentAssistant.isError = true;
|
|
15886
|
+
if (currentAssistant && !turnFinalized && currentAssistant.content === "") currentAssistant.degraded = true;
|
|
15887
|
+
turnFinalized = false;
|
|
14874
15888
|
currentAssistant = null;
|
|
14875
15889
|
break;
|
|
14876
15890
|
default: break;
|
|
@@ -15248,21 +16262,66 @@ async function runWebServer(opts) {
|
|
|
15248
16262
|
yolo: opts.yolo
|
|
15249
16263
|
});
|
|
15250
16264
|
await manager.init();
|
|
16265
|
+
const lanMode = opts.lan === true;
|
|
16266
|
+
const host = lanMode ? "0.0.0.0" : "127.0.0.1";
|
|
16267
|
+
const gateway = lanMode ? await GatewayAuth.setup({
|
|
16268
|
+
homeDir,
|
|
16269
|
+
token: opts.token
|
|
16270
|
+
}) : null;
|
|
16271
|
+
const loginLimiter = createFixedWindowLimiter({
|
|
16272
|
+
windowMs: 5 * 6e4,
|
|
16273
|
+
max: 10
|
|
16274
|
+
});
|
|
15251
16275
|
if (!manager.list().some((s) => s.active)) {
|
|
15252
16276
|
const recent = manager.list().find((s) => s.workDir === workDir);
|
|
15253
|
-
if (recent)
|
|
16277
|
+
if (recent) try {
|
|
16278
|
+
await manager.activateSession(recent.sessionId);
|
|
16279
|
+
} catch (error) {
|
|
16280
|
+
log.warn("web: resume of most recent session failed", {
|
|
16281
|
+
sessionId: recent.sessionId,
|
|
16282
|
+
error: errorMessage(error)
|
|
16283
|
+
});
|
|
16284
|
+
}
|
|
15254
16285
|
}
|
|
15255
16286
|
const baseDir = import.meta.dirname;
|
|
15256
16287
|
const prodPublicDir = join(baseDir, "public");
|
|
15257
16288
|
const devPublicDir = join(baseDir, "frontend", "dist");
|
|
16289
|
+
const sourcePublicDir = join(baseDir, "frontend", "public");
|
|
15258
16290
|
let publicDir = prodPublicDir;
|
|
15259
16291
|
try {
|
|
15260
16292
|
await access(join(devPublicDir, "index.html"));
|
|
15261
16293
|
publicDir = devPublicDir;
|
|
15262
|
-
} catch {
|
|
16294
|
+
} catch {
|
|
16295
|
+
try {
|
|
16296
|
+
await access(join(sourcePublicDir, "gateway.html"));
|
|
16297
|
+
publicDir = sourcePublicDir;
|
|
16298
|
+
} catch {}
|
|
16299
|
+
}
|
|
15263
16300
|
const httpServer = createServer(async (req, res) => {
|
|
15264
16301
|
const url = req.url ?? "/";
|
|
15265
16302
|
const method = req.method ?? "GET";
|
|
16303
|
+
if (gateway) {
|
|
16304
|
+
const verdict = gatewayVerdict({
|
|
16305
|
+
loopback: isLoopbackAddress(req.socket.remoteAddress),
|
|
16306
|
+
authenticated: gateway.verifySession(req.headers.cookie),
|
|
16307
|
+
path: url.split("?")[0] ?? "/",
|
|
16308
|
+
method,
|
|
16309
|
+
apiPrefix: API_PREFIX
|
|
16310
|
+
});
|
|
16311
|
+
if (verdict === "redirect") {
|
|
16312
|
+
res.writeHead(302, { Location: `/gateway?next=${encodeURIComponent(url)}` });
|
|
16313
|
+
res.end();
|
|
16314
|
+
return;
|
|
16315
|
+
}
|
|
16316
|
+
if (verdict === "unauthorized") {
|
|
16317
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
16318
|
+
res.end(JSON.stringify({
|
|
16319
|
+
code: 40101,
|
|
16320
|
+
message: "unauthorized"
|
|
16321
|
+
}));
|
|
16322
|
+
return;
|
|
16323
|
+
}
|
|
16324
|
+
}
|
|
15266
16325
|
if (url === `${API_PREFIX}/sessions` && method === "GET") {
|
|
15267
16326
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
15268
16327
|
res.end(JSON.stringify(manager.list()));
|
|
@@ -15283,9 +16342,31 @@ async function runWebServer(opts) {
|
|
|
15283
16342
|
return;
|
|
15284
16343
|
}
|
|
15285
16344
|
if (await handleGoalRoute(req, res, url, (sessionId) => manager.get(sessionId))) return;
|
|
15286
|
-
const snapshotMatch = new RegExp(`^${API_PREFIX}/sessions/([^/]+)/snapshot
|
|
16345
|
+
const snapshotMatch = new RegExp(`^${API_PREFIX}/sessions/([^/]+)/snapshot(\\?|$)`).exec(url);
|
|
15287
16346
|
if (snapshotMatch && method === "GET") {
|
|
15288
|
-
const sessionId = snapshotMatch[1];
|
|
16347
|
+
const sessionId = decodeURIComponent(snapshotMatch[1]);
|
|
16348
|
+
const ws = manager.get(sessionId);
|
|
16349
|
+
if (!ws) {
|
|
16350
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
16351
|
+
res.end(JSON.stringify({
|
|
16352
|
+
code: 404,
|
|
16353
|
+
message: "Session not found"
|
|
16354
|
+
}));
|
|
16355
|
+
return;
|
|
16356
|
+
}
|
|
16357
|
+
try {
|
|
16358
|
+
await ws.ready;
|
|
16359
|
+
const query = new URLSearchParams(url.split("?")[1] ?? "");
|
|
16360
|
+
const tail = Number(query.get("tail") ?? "");
|
|
16361
|
+
sendJson(res, 200, Number.isFinite(tail) && tail > 0 ? ws.getSnapshot({ tail }) : ws.getSnapshot());
|
|
16362
|
+
} catch (error) {
|
|
16363
|
+
sendHttpError(res, error);
|
|
16364
|
+
}
|
|
16365
|
+
return;
|
|
16366
|
+
}
|
|
16367
|
+
const olderMatch = /^\/api\/v1\/sessions\/([^/?]+)\/messages(\?|$)/.exec(url);
|
|
16368
|
+
if (olderMatch && method === "GET") {
|
|
16369
|
+
const sessionId = decodeURIComponent(olderMatch[1]);
|
|
15289
16370
|
const ws = manager.get(sessionId);
|
|
15290
16371
|
if (!ws) {
|
|
15291
16372
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
@@ -15297,7 +16378,19 @@ async function runWebServer(opts) {
|
|
|
15297
16378
|
}
|
|
15298
16379
|
try {
|
|
15299
16380
|
await ws.ready;
|
|
15300
|
-
|
|
16381
|
+
const query = new URLSearchParams(url.split("?")[1] ?? "");
|
|
16382
|
+
const seq = Number(query.get("seq") ?? "0");
|
|
16383
|
+
const tool = query.get("tool") ?? "";
|
|
16384
|
+
if (Number.isFinite(seq) && seq > 0 && tool) {
|
|
16385
|
+
sendJson(res, 200, { output: ws.getThinkingEntry(seq, tool) });
|
|
16386
|
+
return;
|
|
16387
|
+
}
|
|
16388
|
+
const before = Number(query.get("before") ?? "0");
|
|
16389
|
+
const tail = Number(query.get("tail") ?? "50");
|
|
16390
|
+
sendJson(res, 200, Number.isFinite(before) && before > 0 ? ws.getMessagesOlder(before, Number.isFinite(tail) && tail > 0 ? tail : 50) : {
|
|
16391
|
+
messages: [],
|
|
16392
|
+
hasMore: false
|
|
16393
|
+
});
|
|
15301
16394
|
} catch (error) {
|
|
15302
16395
|
sendHttpError(res, error);
|
|
15303
16396
|
}
|
|
@@ -15309,6 +16402,24 @@ async function runWebServer(opts) {
|
|
|
15309
16402
|
res.end(JSON.stringify(gs));
|
|
15310
16403
|
return;
|
|
15311
16404
|
}
|
|
16405
|
+
if (url.startsWith(`${API_PREFIX}/git/diff?`) && method === "GET") {
|
|
16406
|
+
const relPath = new URLSearchParams(url.split("?")[1] ?? "").get("path") ?? "";
|
|
16407
|
+
try {
|
|
16408
|
+
const result = await getGitFileDiff(workDir, relPath);
|
|
16409
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
16410
|
+
res.end(JSON.stringify({
|
|
16411
|
+
path: relPath,
|
|
16412
|
+
patch: result?.patch ?? ""
|
|
16413
|
+
}));
|
|
16414
|
+
} catch (error) {
|
|
16415
|
+
sendHttpError(res, error);
|
|
16416
|
+
}
|
|
16417
|
+
return;
|
|
16418
|
+
}
|
|
16419
|
+
function makeFileGate() {
|
|
16420
|
+
return new FileGate(() => manager.list().map((s) => s.workDir));
|
|
16421
|
+
}
|
|
16422
|
+
if (await handleFilesRoutes(req, res, url, method, API_PREFIX, makeFileGate())) return;
|
|
15312
16423
|
if (url === `${API_PREFIX}/like` && method === "GET") {
|
|
15313
16424
|
try {
|
|
15314
16425
|
sendJson(res, 200, await loadLikePreferences());
|
|
@@ -15446,6 +16557,76 @@ async function runWebServer(opts) {
|
|
|
15446
16557
|
res.end();
|
|
15447
16558
|
return;
|
|
15448
16559
|
}
|
|
16560
|
+
if (gateway && url === `${API_PREFIX}/gateway/status` && method === "GET") {
|
|
16561
|
+
sendJson(res, 200, {
|
|
16562
|
+
authRequired: true,
|
|
16563
|
+
authenticated: isLoopbackAddress(req.socket.remoteAddress) || gateway.verifySession(req.headers.cookie)
|
|
16564
|
+
});
|
|
16565
|
+
return;
|
|
16566
|
+
}
|
|
16567
|
+
if (gateway && url === `${API_PREFIX}/gateway/login` && method === "POST") {
|
|
16568
|
+
const ip = req.socket.remoteAddress ?? "unknown";
|
|
16569
|
+
const attempt = loginLimiter.hit(ip);
|
|
16570
|
+
if (!attempt.ok) {
|
|
16571
|
+
res.writeHead(429, {
|
|
16572
|
+
"Content-Type": "application/json",
|
|
16573
|
+
"Retry-After": String(attempt.retryAfterSeconds)
|
|
16574
|
+
});
|
|
16575
|
+
res.end(JSON.stringify({
|
|
16576
|
+
code: 42903,
|
|
16577
|
+
message: `尝试过于频繁,请 ${attempt.retryAfterSeconds} 秒后重试`,
|
|
16578
|
+
retryAfter: attempt.retryAfterSeconds
|
|
16579
|
+
}));
|
|
16580
|
+
return;
|
|
16581
|
+
}
|
|
16582
|
+
try {
|
|
16583
|
+
const body = await readJsonBody(req);
|
|
16584
|
+
const key = typeof body["key"] === "string" ? body["key"] : "";
|
|
16585
|
+
if (!gateway.verifyKey(key)) {
|
|
16586
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
16587
|
+
res.end(JSON.stringify({
|
|
16588
|
+
code: 40101,
|
|
16589
|
+
message: "访问密钥错误"
|
|
16590
|
+
}));
|
|
16591
|
+
return;
|
|
16592
|
+
}
|
|
16593
|
+
loginLimiter.reset(ip);
|
|
16594
|
+
const sid = gateway.createSession();
|
|
16595
|
+
res.writeHead(200, {
|
|
16596
|
+
"Content-Type": "application/json",
|
|
16597
|
+
"Set-Cookie": gateway.sessionCookie(sid),
|
|
16598
|
+
"Cache-Control": "no-store"
|
|
16599
|
+
});
|
|
16600
|
+
res.end(JSON.stringify({ ok: true }));
|
|
16601
|
+
} catch (error) {
|
|
16602
|
+
sendHttpError(res, error);
|
|
16603
|
+
}
|
|
16604
|
+
return;
|
|
16605
|
+
}
|
|
16606
|
+
if (gateway && url === `${API_PREFIX}/gateway/logout` && method === "POST") {
|
|
16607
|
+
gateway.destroySession(req.headers.cookie);
|
|
16608
|
+
res.writeHead(200, {
|
|
16609
|
+
"Content-Type": "application/json",
|
|
16610
|
+
"Set-Cookie": gateway.clearCookie(),
|
|
16611
|
+
"Cache-Control": "no-store"
|
|
16612
|
+
});
|
|
16613
|
+
res.end(JSON.stringify({ ok: true }));
|
|
16614
|
+
return;
|
|
16615
|
+
}
|
|
16616
|
+
if (gateway && url === "/gateway" && method === "GET") {
|
|
16617
|
+
try {
|
|
16618
|
+
const html = await readFile(join(publicDir, "gateway.html"), "utf-8");
|
|
16619
|
+
res.writeHead(200, {
|
|
16620
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
16621
|
+
"Cache-Control": "no-store"
|
|
16622
|
+
});
|
|
16623
|
+
res.end(html);
|
|
16624
|
+
} catch {
|
|
16625
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
16626
|
+
res.end("gateway page missing — did you run pnpm web:build?");
|
|
16627
|
+
}
|
|
16628
|
+
return;
|
|
16629
|
+
}
|
|
15449
16630
|
if (url === "/" || url === "/index.html") {
|
|
15450
16631
|
try {
|
|
15451
16632
|
const html = await readFile(join(publicDir, "index.html"), "utf-8");
|
|
@@ -15471,6 +16652,10 @@ async function runWebServer(opts) {
|
|
|
15471
16652
|
});
|
|
15472
16653
|
const wss = new WebSocketServer({ server: httpServer });
|
|
15473
16654
|
wss.on("connection", (ws, req) => {
|
|
16655
|
+
if (gateway && !isLoopbackAddress(req.socket.remoteAddress) && !gateway.verifySession(req.headers.cookie)) {
|
|
16656
|
+
ws.close(1008, "unauthorized");
|
|
16657
|
+
return;
|
|
16658
|
+
}
|
|
15474
16659
|
const sessionId = new URL(req.url ?? "/", "http://localhost").searchParams.get("sessionId");
|
|
15475
16660
|
if (sessionId) {
|
|
15476
16661
|
const webSession = manager.get(sessionId);
|
|
@@ -15522,9 +16707,11 @@ async function runWebServer(opts) {
|
|
|
15522
16707
|
if (err.code === "EADDRINUSE") reject(/* @__PURE__ */ new Error(`端口 ${opts.port} 已被占用,请先关闭占用该端口的进程,或使用 --port <port> 指定其他端口。`));
|
|
15523
16708
|
else reject(err);
|
|
15524
16709
|
});
|
|
15525
|
-
httpServer.listen(opts.port,
|
|
16710
|
+
httpServer.listen(opts.port, host, resolve);
|
|
15526
16711
|
});
|
|
15527
|
-
const
|
|
16712
|
+
const address = httpServer.address();
|
|
16713
|
+
const realPort = typeof address === "object" && address !== null ? address.port : opts.port;
|
|
16714
|
+
const url = `http://localhost:${realPort}`;
|
|
15528
16715
|
if (opts.open) exec(`${process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"} ${url}`);
|
|
15529
16716
|
let cleaned = false;
|
|
15530
16717
|
const cleanup = async () => {
|
|
@@ -15545,7 +16732,27 @@ async function runWebServer(opts) {
|
|
|
15545
16732
|
process.on("SIGTERM", () => {
|
|
15546
16733
|
cleanup().finally(() => process.exit(0));
|
|
15547
16734
|
});
|
|
15548
|
-
|
|
16735
|
+
let banner = `\n Scream Web UI ready: ${url}\n Working directory: ${workDir}\n Sessions: ${manager.list().length} (${manager.list().filter((s) => s.active).length} active)\n Permission: ${opts.yolo ? "yolo" : opts.auto ? "auto" : "manual"}\n`;
|
|
16736
|
+
if (gateway) {
|
|
16737
|
+
const addrs = getLanAddresses();
|
|
16738
|
+
banner += ` LAN mode: on — non-local devices must present the access key\n`;
|
|
16739
|
+
if (addrs.length > 0) for (const addr of addrs) banner += ` http://${addr}:${realPort}\n`;
|
|
16740
|
+
else banner += ` (no reachable LAN IPv4 address found)\n`;
|
|
16741
|
+
if (gateway.plaintext !== null) banner += ` Access key: ${gateway.plaintext}${gateway.generated ? " (auto-generated, saved, persists across restarts)" : ""}\n`;
|
|
16742
|
+
else banner += ` Access key: (saved in a previous run — use --reset-password to change)\n`;
|
|
16743
|
+
if (addrs.length > 0) try {
|
|
16744
|
+
const qr = await toString(`http://${addrs[0]}:${realPort}`, {
|
|
16745
|
+
type: "terminal",
|
|
16746
|
+
small: true
|
|
16747
|
+
});
|
|
16748
|
+
banner += `\n${qr}\n`;
|
|
16749
|
+
} catch {}
|
|
16750
|
+
}
|
|
16751
|
+
console.log(banner);
|
|
16752
|
+
return {
|
|
16753
|
+
url,
|
|
16754
|
+
close: cleanup
|
|
16755
|
+
};
|
|
15549
16756
|
}
|
|
15550
16757
|
//#endregion
|
|
15551
16758
|
//#region src/cli/run-web.ts
|
|
@@ -15556,7 +16763,38 @@ async function runWebServer(opts) {
|
|
|
15556
16763
|
* connected to the ScreamCode agent. This is a third consumer of agent-core
|
|
15557
16764
|
* (alongside run-shell TUI and run-stream-json), not a separate engine.
|
|
15558
16765
|
*/
|
|
16766
|
+
function promptSecret(question) {
|
|
16767
|
+
if (!process.stdin.isTTY) return Promise.resolve(null);
|
|
16768
|
+
return new Promise((resolve) => {
|
|
16769
|
+
const muted = new Writable({ write(_chunk, _encoding, callback) {
|
|
16770
|
+
callback();
|
|
16771
|
+
} });
|
|
16772
|
+
const rl = createInterface$1({
|
|
16773
|
+
input: process.stdin,
|
|
16774
|
+
output: muted,
|
|
16775
|
+
terminal: true
|
|
16776
|
+
});
|
|
16777
|
+
process.stderr.write(question);
|
|
16778
|
+
rl.question("", (answer) => {
|
|
16779
|
+
rl.close();
|
|
16780
|
+
process.stderr.write("\n");
|
|
16781
|
+
resolve(answer.trim());
|
|
16782
|
+
});
|
|
16783
|
+
});
|
|
16784
|
+
}
|
|
15559
16785
|
async function runWeb(opts) {
|
|
16786
|
+
let token = opts.token;
|
|
16787
|
+
if (opts.resetPassword) {
|
|
16788
|
+
const key = await promptSecret("输入新的网关访问密钥(输入不会回显):");
|
|
16789
|
+
if (key === null) throw new Error("重设访问密钥需要交互终端。");
|
|
16790
|
+
if (key.length === 0) throw new Error("访问密钥不能为空。");
|
|
16791
|
+
if (await promptSecret("确认新的网关访问密钥:") !== key) throw new Error("两次输入的密钥不一致。");
|
|
16792
|
+
token = key;
|
|
16793
|
+
}
|
|
16794
|
+
if (!opts.lan && token !== void 0) await GatewayAuth.setup({
|
|
16795
|
+
homeDir: resolveScreamHome(),
|
|
16796
|
+
token
|
|
16797
|
+
});
|
|
15560
16798
|
await runWebServer({
|
|
15561
16799
|
port: opts.port,
|
|
15562
16800
|
workDir: process.cwd(),
|
|
@@ -15564,7 +16802,9 @@ async function runWeb(opts) {
|
|
|
15564
16802
|
yolo: opts.yolo,
|
|
15565
16803
|
auto: opts.auto,
|
|
15566
16804
|
open: opts.open,
|
|
15567
|
-
skillsDirs: opts.skillsDirs
|
|
16805
|
+
skillsDirs: opts.skillsDirs,
|
|
16806
|
+
lan: opts.lan,
|
|
16807
|
+
token
|
|
15568
16808
|
});
|
|
15569
16809
|
}
|
|
15570
16810
|
//#endregion
|