scream-code 0.14.9 → 0.15.1
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-2Dd57a5d.mjs → app-C3aR5fdI.mjs} +1156 -54
- package/dist/{dispatch-1ojfKMwf.mjs → dispatch-ATQMQq1p.mjs} +304 -168
- package/dist/{dispatch-CJPpI4gm.mjs → dispatch-Doe-DlpL.mjs} +1 -1
- 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/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 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-
|
|
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-ATQMQq1p.mjs";
|
|
8
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.1"),
|
|
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(() => {
|
|
@@ -7912,7 +7917,9 @@ const INITIAL_LIVE_PANE = {
|
|
|
7912
7917
|
* Platform mapping:
|
|
7913
7918
|
* macOS -> pbcopy
|
|
7914
7919
|
* Windows-> powershell Set-Clipboard (base64 payload, avoids quoting issues)
|
|
7915
|
-
* 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.
|
|
7916
7923
|
*/
|
|
7917
7924
|
var ClipboardTextError = class extends Error {
|
|
7918
7925
|
constructor(message) {
|
|
@@ -7938,9 +7945,36 @@ function detectPlatform() {
|
|
|
7938
7945
|
if (process.platform === "linux") return process.env["WAYLAND_DISPLAY"] ? "linux-wayland" : "linux-x11";
|
|
7939
7946
|
return "unsupported";
|
|
7940
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
|
+
}
|
|
7941
7975
|
/**
|
|
7942
7976
|
* Write `text` to the system clipboard. Throws ClipboardTextError when the
|
|
7943
|
-
* platform is unsupported or
|
|
7977
|
+
* platform is unsupported or every candidate command fails, so callers can
|
|
7944
7978
|
* surface a "Copy failed" indicator.
|
|
7945
7979
|
*/
|
|
7946
7980
|
async function writeTextClipboard(text, runCommand = defaultRunCommand) {
|
|
@@ -7966,19 +8000,15 @@ async function writeTextClipboard(text, runCommand = defaultRunCommand) {
|
|
|
7966
8000
|
break;
|
|
7967
8001
|
}
|
|
7968
8002
|
case "linux-wayland":
|
|
7969
|
-
|
|
7970
|
-
|
|
7971
|
-
|
|
7972
|
-
|
|
7973
|
-
|
|
7974
|
-
|
|
7975
|
-
|
|
7976
|
-
|
|
7977
|
-
|
|
7978
|
-
"-i"
|
|
7979
|
-
];
|
|
7980
|
-
input = text;
|
|
7981
|
-
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
|
+
}
|
|
7982
8012
|
default: throw new ClipboardTextError(`unsupported clipboard platform: ${process.platform}`);
|
|
7983
8013
|
}
|
|
7984
8014
|
const result = runCommand(command, args, input);
|
|
@@ -8834,6 +8864,7 @@ function detectFdPath() {
|
|
|
8834
8864
|
}
|
|
8835
8865
|
//#endregion
|
|
8836
8866
|
//#region src/tui/tui-state.ts
|
|
8867
|
+
const OSC52_MAX_TEXT_LENGTH = 64 * 1024;
|
|
8837
8868
|
function logRenderError(error) {
|
|
8838
8869
|
try {
|
|
8839
8870
|
const logDir = getLogDir();
|
|
@@ -8852,7 +8883,14 @@ function createTUIState(options) {
|
|
|
8852
8883
|
await writeTextClipboard(text);
|
|
8853
8884
|
return true;
|
|
8854
8885
|
} catch {
|
|
8855
|
-
|
|
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;
|
|
8856
8894
|
}
|
|
8857
8895
|
} });
|
|
8858
8896
|
const uiAny = ui;
|
|
@@ -12359,7 +12397,7 @@ var ScreamTUI = class {
|
|
|
12359
12397
|
const card = new OnboardingCardComponent({
|
|
12360
12398
|
colors: this.state.theme.colors,
|
|
12361
12399
|
onConfigure: () => {
|
|
12362
|
-
import("./dispatch-
|
|
12400
|
+
import("./dispatch-Doe-DlpL.mjs").then(({ handleConnectCommand }) => handleConnectCommand(this, "")).finally(() => {
|
|
12363
12401
|
this.restoreEditor();
|
|
12364
12402
|
onDone?.();
|
|
12365
12403
|
});
|
|
@@ -13844,6 +13882,465 @@ function installStreamJsonTerminationHandlers(cleanup) {
|
|
|
13844
13882
|
};
|
|
13845
13883
|
}
|
|
13846
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
|
|
13847
14344
|
//#region src/web/server.ts
|
|
13848
14345
|
/**
|
|
13849
14346
|
* Scream Web UI server.
|
|
@@ -13861,6 +14358,14 @@ function installStreamJsonTerminationHandlers(cleanup) {
|
|
|
13861
14358
|
* consumer of the same SDK (alongside run-shell TUI and run-stream-json),
|
|
13862
14359
|
* not a separate engine. Zero changes to agent-core or node-sdk.
|
|
13863
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
|
+
}
|
|
13864
14369
|
/** Error carrying an HTTP status code for REST handlers. */
|
|
13865
14370
|
var HttpError = class extends Error {
|
|
13866
14371
|
statusCode;
|
|
@@ -14031,6 +14536,7 @@ function toHttpError(error) {
|
|
|
14031
14536
|
case ErrorCodes.TURN_AGENT_BUSY: return new HttpError(409, error.message, error.code);
|
|
14032
14537
|
default: return new HttpError(500, error.message, error.code);
|
|
14033
14538
|
}
|
|
14539
|
+
if (error instanceof FileAccessError) return new HttpError(error.statusCode, error.message);
|
|
14034
14540
|
return new HttpError(500, errorMessage(error));
|
|
14035
14541
|
}
|
|
14036
14542
|
function sendHttpError(res, error) {
|
|
@@ -14040,6 +14546,57 @@ function sendHttpError(res, error) {
|
|
|
14040
14546
|
message: mapped.message
|
|
14041
14547
|
});
|
|
14042
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
|
+
}
|
|
14043
14600
|
function isVolatileEvent(event) {
|
|
14044
14601
|
switch (event.type) {
|
|
14045
14602
|
case "assistant.delta":
|
|
@@ -14072,9 +14629,13 @@ async function appendJournalLine(homeDir, sessionId, line) {
|
|
|
14072
14629
|
});
|
|
14073
14630
|
}
|
|
14074
14631
|
}
|
|
14632
|
+
let metaWriteSeq = 0;
|
|
14075
14633
|
async function saveMetadata(homeDir, meta) {
|
|
14076
14634
|
try {
|
|
14077
|
-
|
|
14635
|
+
const path = getMetaPath(homeDir, meta.sessionId);
|
|
14636
|
+
const tmp = `${path}.${process.pid}.${metaWriteSeq++}.tmp`;
|
|
14637
|
+
await writeFile(tmp, JSON.stringify(meta, null, 2));
|
|
14638
|
+
await rename(tmp, path);
|
|
14078
14639
|
} catch (error) {
|
|
14079
14640
|
log.warn("web: failed to save metadata", {
|
|
14080
14641
|
sessionId: meta.sessionId,
|
|
@@ -14130,6 +14691,25 @@ function deriveTitle(messages) {
|
|
|
14130
14691
|
return text.length > 40 ? text.slice(0, 40) + "..." : text;
|
|
14131
14692
|
}
|
|
14132
14693
|
const execFileAsync = promisify(execFile);
|
|
14694
|
+
/** Raw porcelain rows: repo-root-relative path + XY status. `getGitStatus`
|
|
14695
|
+
* enriches these into full `GitFileChange` records. */
|
|
14696
|
+
function parsePorcelainFiles(lines) {
|
|
14697
|
+
const files = [];
|
|
14698
|
+
for (const line of lines) {
|
|
14699
|
+
if (line.startsWith("##")) continue;
|
|
14700
|
+
const status = line.slice(0, 2).replaceAll(" ", "");
|
|
14701
|
+
let p = line.slice(3).trim();
|
|
14702
|
+
if (p.includes(" -> ")) p = p.split(" -> ").pop();
|
|
14703
|
+
if (p.startsWith("\"") && p.endsWith("\"")) try {
|
|
14704
|
+
p = JSON.parse(p);
|
|
14705
|
+
} catch {}
|
|
14706
|
+
files.push({
|
|
14707
|
+
path: p,
|
|
14708
|
+
status
|
|
14709
|
+
});
|
|
14710
|
+
}
|
|
14711
|
+
return files;
|
|
14712
|
+
}
|
|
14133
14713
|
async function git(workDir, args) {
|
|
14134
14714
|
const { stdout } = await execFileAsync("git", [
|
|
14135
14715
|
"-C",
|
|
@@ -14151,13 +14731,21 @@ async function getGitStatus(workDir) {
|
|
|
14151
14731
|
let ahead;
|
|
14152
14732
|
let behind;
|
|
14153
14733
|
let changed = 0;
|
|
14734
|
+
let gitFiles = [];
|
|
14154
14735
|
try {
|
|
14155
|
-
const [branchOut, statusOut] = await Promise.all([
|
|
14156
|
-
"
|
|
14157
|
-
|
|
14158
|
-
|
|
14159
|
-
|
|
14736
|
+
const [branchOut, statusOut, repoTopOut] = await Promise.all([
|
|
14737
|
+
git(workDir, ["branch", "--show-current"]),
|
|
14738
|
+
git(workDir, [
|
|
14739
|
+
"status",
|
|
14740
|
+
"--porcelain",
|
|
14741
|
+
"-b"
|
|
14742
|
+
]),
|
|
14743
|
+
git(workDir, ["rev-parse", "--show-toplevel"]).catch(() => workDir)
|
|
14744
|
+
]);
|
|
14160
14745
|
branch = branchOut.trim() || void 0;
|
|
14746
|
+
const repoTop = repoTopOut.trim() || workDir;
|
|
14747
|
+
const prefix = repoTop !== workDir ? `${relative(repoTop, workDir).replaceAll("\\", "/")}/` : "";
|
|
14748
|
+
const fixPath = (p) => prefix && p.startsWith(prefix) ? p.slice(prefix.length) : p;
|
|
14161
14749
|
const statusLines = statusOut.split("\n").filter((l) => l.length > 0);
|
|
14162
14750
|
const header = statusLines[0] ?? "";
|
|
14163
14751
|
const aheadMatch = /ahead (\d+)/.exec(header);
|
|
@@ -14165,10 +14753,18 @@ async function getGitStatus(workDir) {
|
|
|
14165
14753
|
if (aheadMatch) ahead = Number(aheadMatch[1]);
|
|
14166
14754
|
if (behindMatch) behind = Number(behindMatch[1]);
|
|
14167
14755
|
changed = statusLines.filter((l) => !l.startsWith("##")).length;
|
|
14756
|
+
const statusFiles = parsePorcelainFiles(statusLines).map((f) => ({
|
|
14757
|
+
path: f.path,
|
|
14758
|
+
displayPath: fixPath(f.path),
|
|
14759
|
+
status: f.status
|
|
14760
|
+
}));
|
|
14761
|
+
if (statusFiles.length > 0) gitFiles = statusFiles;
|
|
14168
14762
|
} catch {}
|
|
14169
14763
|
let adds;
|
|
14170
14764
|
let dels;
|
|
14171
14765
|
let diffStat;
|
|
14766
|
+
/** Repo-root-relative → per-file line counts, from `git diff --numstat`. */
|
|
14767
|
+
const perFile = /* @__PURE__ */ new Map();
|
|
14172
14768
|
try {
|
|
14173
14769
|
const [numstatOut, diffStatOut] = await Promise.all([git(workDir, [
|
|
14174
14770
|
"diff",
|
|
@@ -14182,14 +14778,41 @@ async function getGitStatus(workDir) {
|
|
|
14182
14778
|
adds = 0;
|
|
14183
14779
|
dels = 0;
|
|
14184
14780
|
for (const line of numstatOut.split("\n")) {
|
|
14185
|
-
const
|
|
14186
|
-
|
|
14187
|
-
|
|
14188
|
-
|
|
14189
|
-
|
|
14781
|
+
const parts = line.split(" ");
|
|
14782
|
+
const m = parts.length >= 3 ? /^(\d+)\t(\d+)\t/.exec(line) : null;
|
|
14783
|
+
if (!m) continue;
|
|
14784
|
+
adds += Number(m[1]);
|
|
14785
|
+
dels += Number(m[2]);
|
|
14786
|
+
perFile.set(normalizeNumstatPath(parts.slice(2).join(" ")), {
|
|
14787
|
+
adds: Number(m[1]),
|
|
14788
|
+
dels: Number(m[2])
|
|
14789
|
+
});
|
|
14190
14790
|
}
|
|
14191
14791
|
diffStat = diffStatOut.trim().split("\n").slice(0, 200).join("\n") || void 0;
|
|
14192
14792
|
} catch {}
|
|
14793
|
+
const untracked = /* @__PURE__ */ new Set();
|
|
14794
|
+
try {
|
|
14795
|
+
const others = await git(workDir, [
|
|
14796
|
+
"ls-files",
|
|
14797
|
+
"--others",
|
|
14798
|
+
"--exclude-standard",
|
|
14799
|
+
"-z"
|
|
14800
|
+
]);
|
|
14801
|
+
for (const p of others.split("\0")) if (p.trim() !== "") untracked.add(p.replaceAll("\\", "/"));
|
|
14802
|
+
} catch {}
|
|
14803
|
+
gitFiles = gitFiles.map((file) => {
|
|
14804
|
+
const stats = perFile.get(file.path);
|
|
14805
|
+
const isUntracked = untracked.has(file.path) || file.status.includes("?");
|
|
14806
|
+
return {
|
|
14807
|
+
...file,
|
|
14808
|
+
status: isUntracked ? "?" : file.status,
|
|
14809
|
+
...isUntracked ? { untracked: true } : {},
|
|
14810
|
+
...stats ? {
|
|
14811
|
+
adds: stats.adds,
|
|
14812
|
+
dels: stats.dels
|
|
14813
|
+
} : {}
|
|
14814
|
+
};
|
|
14815
|
+
});
|
|
14193
14816
|
return {
|
|
14194
14817
|
isRepo: true,
|
|
14195
14818
|
branch,
|
|
@@ -14198,9 +14821,97 @@ async function getGitStatus(workDir) {
|
|
|
14198
14821
|
changed,
|
|
14199
14822
|
adds,
|
|
14200
14823
|
dels,
|
|
14201
|
-
diffStat
|
|
14824
|
+
diffStat,
|
|
14825
|
+
files: gitFiles.length > 0 ? gitFiles : void 0
|
|
14202
14826
|
};
|
|
14203
14827
|
}
|
|
14828
|
+
/**
|
|
14829
|
+
* numstat keys for renames come back as `a/{old => new}/b.ts` or
|
|
14830
|
+
* `old => new`; resolve them to the post-rename path so they match porcelain.
|
|
14831
|
+
*/
|
|
14832
|
+
function normalizeNumstatPath(raw) {
|
|
14833
|
+
let p = raw.trim().replaceAll("\\", "/");
|
|
14834
|
+
const brace = /\{([^{}]*) => ([^{}]*)\}/.exec(p);
|
|
14835
|
+
if (brace) p = p.replace(brace[0], `${brace[1]}${brace[2]}`);
|
|
14836
|
+
if (p.includes(" => ")) p = p.split(" => ").pop().trim();
|
|
14837
|
+
return p.replaceAll("//", "/").replace(/^\.\//, "").replace(/^\/+/, "");
|
|
14838
|
+
}
|
|
14839
|
+
/** Cap for the inlined content of an untracked file. */
|
|
14840
|
+
const MAX_INLINE_DIFF_BYTES = 512 * 1024;
|
|
14841
|
+
/**
|
|
14842
|
+
* Single-file patch. `repoPath` is **repository-root relative** — the same base
|
|
14843
|
+
* `git status` reports, so the UI can hand it straight back.
|
|
14844
|
+
*
|
|
14845
|
+
* Two cases used to return an empty patch and look like "点了没反应":
|
|
14846
|
+
* nested files (the old code re-prefixed workdir-relative paths with `../..`,
|
|
14847
|
+
* resolving them outside the repo) and untracked files (`git diff` has nothing
|
|
14848
|
+
* to say about them). The second case now gets a synthesised added-file patch.
|
|
14849
|
+
*/
|
|
14850
|
+
async function getGitFileDiff(workDir, repoPath) {
|
|
14851
|
+
const rel = normalizeNumstatPath(repoPath);
|
|
14852
|
+
if (rel === "") throw new FileAccessError(403, "路径不能为空");
|
|
14853
|
+
let repoTop = "";
|
|
14854
|
+
try {
|
|
14855
|
+
repoTop = (await git(workDir, ["rev-parse", "--show-toplevel"])).trim();
|
|
14856
|
+
} catch {
|
|
14857
|
+
return null;
|
|
14858
|
+
}
|
|
14859
|
+
if (!repoTop) throw new FileAccessError(403, "不在 git 仓库内");
|
|
14860
|
+
const abs = resolve(repoTop, rel);
|
|
14861
|
+
const inside = relative(repoTop, abs);
|
|
14862
|
+
if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) throw new FileAccessError(403, "路径不属于该仓库");
|
|
14863
|
+
const patch = await git(repoTop, [
|
|
14864
|
+
"diff",
|
|
14865
|
+
"--no-color",
|
|
14866
|
+
"--",
|
|
14867
|
+
rel
|
|
14868
|
+
]).catch(() => "");
|
|
14869
|
+
if (patch.trim()) return {
|
|
14870
|
+
path: rel,
|
|
14871
|
+
patch
|
|
14872
|
+
};
|
|
14873
|
+
if (!((await git(repoTop, [
|
|
14874
|
+
"ls-files",
|
|
14875
|
+
"--others",
|
|
14876
|
+
"--exclude-standard",
|
|
14877
|
+
"--",
|
|
14878
|
+
rel
|
|
14879
|
+
]).catch(() => "")).trim() !== "")) return {
|
|
14880
|
+
path: rel,
|
|
14881
|
+
patch: ""
|
|
14882
|
+
};
|
|
14883
|
+
const header = `--- /dev/null\n+++ b/${rel}\n`;
|
|
14884
|
+
try {
|
|
14885
|
+
const [realRoot, realAbs] = await Promise.all([realpath(repoTop), realpath(abs)]);
|
|
14886
|
+
const relCheck = relative(realRoot, realAbs);
|
|
14887
|
+
if (relCheck === "" || relCheck.startsWith("..") || isAbsolute(relCheck)) throw new FileAccessError(403, "路径不属于该仓库");
|
|
14888
|
+
const info = await stat(realAbs);
|
|
14889
|
+
if (!info.isFile()) return {
|
|
14890
|
+
path: rel,
|
|
14891
|
+
patch: `${header}@@ -0,0 +1 @@\n+(非普通文件)\n`
|
|
14892
|
+
};
|
|
14893
|
+
if (info.size > MAX_INLINE_DIFF_BYTES) return {
|
|
14894
|
+
path: rel,
|
|
14895
|
+
patch: `${header}@@ -0,0 +1 @@\n+(文件超过 ${Math.round(MAX_INLINE_DIFF_BYTES / 1024)}KB,未内联显示)\n`
|
|
14896
|
+
};
|
|
14897
|
+
const content = await readFile(realAbs, "utf8");
|
|
14898
|
+
if (content.includes("\0")) return {
|
|
14899
|
+
path: rel,
|
|
14900
|
+
patch: `${header}@@ -0,0 +1 @@\n+(二进制文件)\n`
|
|
14901
|
+
};
|
|
14902
|
+
const lines = content.replaceAll("\r\n", "\n").split("\n");
|
|
14903
|
+
if (lines.at(-1) === "") lines.pop();
|
|
14904
|
+
return {
|
|
14905
|
+
path: rel,
|
|
14906
|
+
patch: `${header}@@ -0,0 +1,${lines.length} @@\n${lines.map((l) => `+${l}`).join("\n")}\n`
|
|
14907
|
+
};
|
|
14908
|
+
} catch {
|
|
14909
|
+
return {
|
|
14910
|
+
path: rel,
|
|
14911
|
+
patch: `${header}@@ -0,0 +1 @@\n+(读取失败)\n`
|
|
14912
|
+
};
|
|
14913
|
+
}
|
|
14914
|
+
}
|
|
14204
14915
|
function exportToMarkdown(messages, title) {
|
|
14205
14916
|
let out = `# ${title}\n\n`;
|
|
14206
14917
|
for (const msg of messages) if (msg.role === "user") out += `## User\n\n${msg.content}\n\n`;
|
|
@@ -14240,6 +14951,14 @@ var WebSession = class {
|
|
|
14240
14951
|
heartbeatTimer = null;
|
|
14241
14952
|
unsubscribe = null;
|
|
14242
14953
|
busy = false;
|
|
14954
|
+
/**
|
|
14955
|
+
* Live assistant message under construction. Mirrors the same state machine
|
|
14956
|
+
* used by buildMessages() so turn.ended can persist a complete durable
|
|
14957
|
+
* snapshot (`web.message.finalized`) — the only source that survives a
|
|
14958
|
+
* server restart (deltas are volatile by design).
|
|
14959
|
+
*/
|
|
14960
|
+
liveAssistant = null;
|
|
14961
|
+
liveTurnCount = 0;
|
|
14243
14962
|
/** pendingMsgId awaiting compaction completion/cancellation event. */
|
|
14244
14963
|
pendingCompactionMsgId = null;
|
|
14245
14964
|
homeDir;
|
|
@@ -14317,6 +15036,15 @@ var WebSession = class {
|
|
|
14317
15036
|
});
|
|
14318
15037
|
await appendJournalLine(this.homeDir, this.sessionId, line);
|
|
14319
15038
|
}
|
|
15039
|
+
/** In-flight fire-and-forget persistence writes, drained by close(). */
|
|
15040
|
+
pendingWrites = /* @__PURE__ */ new Set();
|
|
15041
|
+
/** Register a background persistence promise so close() can await it. */
|
|
15042
|
+
trackPending(promise) {
|
|
15043
|
+
const tracked = promise.catch(() => {}).then(() => {
|
|
15044
|
+
this.pendingWrites.delete(tracked);
|
|
15045
|
+
});
|
|
15046
|
+
this.pendingWrites.add(tracked);
|
|
15047
|
+
}
|
|
14320
15048
|
loadFromPersisted(entries) {
|
|
14321
15049
|
for (const entry of entries) if ("type" in entry && entry.type === "user_message") {
|
|
14322
15050
|
const um = entry;
|
|
@@ -14367,17 +15095,107 @@ var WebSession = class {
|
|
|
14367
15095
|
payload
|
|
14368
15096
|
};
|
|
14369
15097
|
this.journal.push(entry);
|
|
14370
|
-
if (!entry.volatile) this.persistEntry(entry);
|
|
15098
|
+
if (!entry.volatile) this.trackPending(this.persistEntry(entry));
|
|
15099
|
+
return entry;
|
|
15100
|
+
}
|
|
15101
|
+
/**
|
|
15102
|
+
* Append + persist a durable journal entry WITHOUT broadcasting it to
|
|
15103
|
+
* clients. Used for server-owned reconstruction material (finalized
|
|
15104
|
+
* assistant snapshots) that clients never consume as live events.
|
|
15105
|
+
*/
|
|
15106
|
+
appendDurableSilent(payload) {
|
|
15107
|
+
const entry = {
|
|
15108
|
+
seq: this.nextSeq++,
|
|
15109
|
+
epoch: this.epoch,
|
|
15110
|
+
volatile: false,
|
|
15111
|
+
payload
|
|
15112
|
+
};
|
|
15113
|
+
this.journal.push(entry);
|
|
15114
|
+
this.trackPending(this.persistEntry(entry));
|
|
14371
15115
|
return entry;
|
|
14372
15116
|
}
|
|
14373
15117
|
subscribeEvents() {
|
|
14374
15118
|
if (!this.session) return;
|
|
14375
15119
|
this.unsubscribe = this.session.onEvent((event) => {
|
|
14376
15120
|
if ((event.type === "goal.updated" || event.type === "todo.updated") && event.agentId !== "main") return;
|
|
14377
|
-
if (event.type === "turn.started")
|
|
14378
|
-
|
|
15121
|
+
if (event.type === "turn.started") {
|
|
15122
|
+
this.busy = true;
|
|
15123
|
+
this.liveTurnCount += 1;
|
|
15124
|
+
this.liveAssistant = {
|
|
15125
|
+
role: "assistant",
|
|
15126
|
+
content: "",
|
|
15127
|
+
tools: [],
|
|
15128
|
+
ts: Date.now(),
|
|
15129
|
+
model: this.cachedStatus?.model,
|
|
15130
|
+
turnStats: {
|
|
15131
|
+
turn: this.liveTurnCount,
|
|
15132
|
+
step: 0,
|
|
15133
|
+
status: "running",
|
|
15134
|
+
firstTokenMs: null,
|
|
15135
|
+
llmMs: null,
|
|
15136
|
+
toolMs: null,
|
|
15137
|
+
tokens: null,
|
|
15138
|
+
tokensPerSec: null
|
|
15139
|
+
}
|
|
15140
|
+
};
|
|
15141
|
+
} else if (event.type === "turn.ended") {
|
|
14379
15142
|
this.busy = false;
|
|
14380
|
-
this.
|
|
15143
|
+
const live = this.liveAssistant;
|
|
15144
|
+
if (event.reason === "failed" && live) live.isError = true;
|
|
15145
|
+
if (live) {
|
|
15146
|
+
live.turnStats = {
|
|
15147
|
+
...live.turnStats ?? {
|
|
15148
|
+
turn: this.liveTurnCount,
|
|
15149
|
+
step: 0,
|
|
15150
|
+
firstTokenMs: null,
|
|
15151
|
+
llmMs: null,
|
|
15152
|
+
toolMs: null,
|
|
15153
|
+
tokens: null,
|
|
15154
|
+
tokensPerSec: null
|
|
15155
|
+
},
|
|
15156
|
+
status: "done"
|
|
15157
|
+
};
|
|
15158
|
+
this.appendDurableSilent({
|
|
15159
|
+
type: "web.message.finalized",
|
|
15160
|
+
message: live
|
|
15161
|
+
});
|
|
15162
|
+
}
|
|
15163
|
+
this.liveAssistant = null;
|
|
15164
|
+
this.trackPending(this.updateTitle());
|
|
15165
|
+
} else if (event.type === "assistant.delta") {
|
|
15166
|
+
const live = this.liveAssistant;
|
|
15167
|
+
if (live) live.content += event.delta;
|
|
15168
|
+
} else if (event.type === "thinking.delta") {
|
|
15169
|
+
const live = this.liveAssistant;
|
|
15170
|
+
if (live) {
|
|
15171
|
+
const t = live.tools.find((x) => x.name === "thinking");
|
|
15172
|
+
if (t) t.output = (t.output ?? "") + event.delta;
|
|
15173
|
+
else live.tools.push({
|
|
15174
|
+
toolCallId: "thinking",
|
|
15175
|
+
name: "thinking",
|
|
15176
|
+
args: {},
|
|
15177
|
+
output: event.delta
|
|
15178
|
+
});
|
|
15179
|
+
}
|
|
15180
|
+
} else if (event.type === "tool.call.started") {
|
|
15181
|
+
const live = this.liveAssistant;
|
|
15182
|
+
if (live) {
|
|
15183
|
+
live.tools.push({
|
|
15184
|
+
toolCallId: event.toolCallId,
|
|
15185
|
+
name: event.name,
|
|
15186
|
+
args: event.args
|
|
15187
|
+
});
|
|
15188
|
+
if (live.turnStats) live.turnStats.step += 1;
|
|
15189
|
+
}
|
|
15190
|
+
} else if (event.type === "tool.result") {
|
|
15191
|
+
const live = this.liveAssistant;
|
|
15192
|
+
if (live) {
|
|
15193
|
+
const tool = live.tools.find((t) => t.toolCallId === event.toolCallId);
|
|
15194
|
+
if (tool) {
|
|
15195
|
+
tool.output = String(event.output);
|
|
15196
|
+
tool.isError = event.isError;
|
|
15197
|
+
}
|
|
15198
|
+
}
|
|
14381
15199
|
} else if (event.type === "goal.updated") {
|
|
14382
15200
|
this.goalEventRevision += 1;
|
|
14383
15201
|
this.cachedGoal = cloneSnapshot(event.snapshot);
|
|
@@ -14531,7 +15349,7 @@ var WebSession = class {
|
|
|
14531
15349
|
return;
|
|
14532
15350
|
}
|
|
14533
15351
|
this.busy = true;
|
|
14534
|
-
this.persistUserMessage(text, this.nextSeq, clientMessageId);
|
|
15352
|
+
this.trackPending(this.persistUserMessage(text, this.nextSeq, clientMessageId));
|
|
14535
15353
|
this.broadcast({
|
|
14536
15354
|
type: "user_message",
|
|
14537
15355
|
clientMessageId,
|
|
@@ -14782,7 +15600,17 @@ var WebSession = class {
|
|
|
14782
15600
|
}
|
|
14783
15601
|
}, HEARTBEAT_INTERVAL_MS);
|
|
14784
15602
|
}
|
|
14785
|
-
getSnapshot() {
|
|
15603
|
+
getSnapshot(options) {
|
|
15604
|
+
const all = this.buildMessages();
|
|
15605
|
+
let messages = all;
|
|
15606
|
+
let olderAvailable;
|
|
15607
|
+
let oldestSeq;
|
|
15608
|
+
if (options?.tail && all.length > options.tail) {
|
|
15609
|
+
messages = all.slice(-options.tail);
|
|
15610
|
+
olderAvailable = true;
|
|
15611
|
+
oldestSeq = messages[0]?.seq;
|
|
15612
|
+
}
|
|
15613
|
+
truncateThinking(messages);
|
|
14786
15614
|
return cloneSnapshot({
|
|
14787
15615
|
sessionId: this.sessionId,
|
|
14788
15616
|
workDir: this.workDir,
|
|
@@ -14790,7 +15618,9 @@ var WebSession = class {
|
|
|
14790
15618
|
epoch: this.epoch,
|
|
14791
15619
|
model: this.cachedStatus?.model ?? "unknown",
|
|
14792
15620
|
permission: this.cachedStatus?.permission ?? this.permission,
|
|
14793
|
-
messages
|
|
15621
|
+
messages,
|
|
15622
|
+
olderAvailable,
|
|
15623
|
+
oldestSeq,
|
|
14794
15624
|
pendingApprovals: Array.from(this.pendingApprovals.entries()).map(([id, approval]) => ({
|
|
14795
15625
|
id,
|
|
14796
15626
|
toolName: approval.toolName,
|
|
@@ -14815,6 +15645,22 @@ var WebSession = class {
|
|
|
14815
15645
|
todos: this.cachedTodos
|
|
14816
15646
|
});
|
|
14817
15647
|
}
|
|
15648
|
+
/** Older history page: messages strictly older than `beforeSeq`, newest-first window of `tail`. */
|
|
15649
|
+
getMessagesOlder(beforeSeq, tail) {
|
|
15650
|
+
const older = this.buildMessages().filter((m) => (m.seq ?? Number.MAX_SAFE_INTEGER) < beforeSeq);
|
|
15651
|
+
return {
|
|
15652
|
+
messages: older.slice(-tail),
|
|
15653
|
+
hasMore: older.length > tail
|
|
15654
|
+
};
|
|
15655
|
+
}
|
|
15656
|
+
/** Full thinking text for a truncated entry (loaded on demand). */
|
|
15657
|
+
getThinkingEntry(seq, toolCallId) {
|
|
15658
|
+
const msg = this.buildMessages().find((m) => m.seq === seq);
|
|
15659
|
+
const tool = msg?.tools.find((t) => t.toolCallId === toolCallId);
|
|
15660
|
+
if (tool && typeof tool.output === "string") return tool.output;
|
|
15661
|
+
const thinking = msg?.tools.find((t) => t.name === "thinking");
|
|
15662
|
+
return thinking && typeof thinking.output === "string" ? thinking.output : "";
|
|
15663
|
+
}
|
|
14818
15664
|
getListItem() {
|
|
14819
15665
|
return {
|
|
14820
15666
|
sessionId: this.sessionId,
|
|
@@ -14970,10 +15816,14 @@ var WebSession = class {
|
|
|
14970
15816
|
while (userIndex < this.userMessages.length) {
|
|
14971
15817
|
const item = this.userMessages[userIndex];
|
|
14972
15818
|
if (item === void 0 || item.beforeSeq > seq) break;
|
|
14973
|
-
messages.push(
|
|
15819
|
+
messages.push({
|
|
15820
|
+
...item.msg,
|
|
15821
|
+
seq: item.beforeSeq
|
|
15822
|
+
});
|
|
14974
15823
|
userIndex++;
|
|
14975
15824
|
}
|
|
14976
15825
|
};
|
|
15826
|
+
let turnFinalized = false;
|
|
14977
15827
|
for (const entry of this.journal) {
|
|
14978
15828
|
const event = entry.payload;
|
|
14979
15829
|
flushUserMessagesBefore(entry.seq);
|
|
@@ -14984,6 +15834,8 @@ var WebSession = class {
|
|
|
14984
15834
|
role: "assistant",
|
|
14985
15835
|
content: "",
|
|
14986
15836
|
tools: [],
|
|
15837
|
+
seq: entry.seq,
|
|
15838
|
+
model: this.cachedStatus?.model,
|
|
14987
15839
|
turnStats: {
|
|
14988
15840
|
turn: turnCount,
|
|
14989
15841
|
step: 0,
|
|
@@ -15002,6 +15854,18 @@ var WebSession = class {
|
|
|
15002
15854
|
case "assistant.delta":
|
|
15003
15855
|
if (currentAssistant) currentAssistant.content += event.delta;
|
|
15004
15856
|
break;
|
|
15857
|
+
case "thinking.delta":
|
|
15858
|
+
if (currentAssistant) {
|
|
15859
|
+
const t = currentAssistant.tools.find((x) => x.name === "thinking");
|
|
15860
|
+
if (t) t.output = (t.output ?? "") + event.delta;
|
|
15861
|
+
else currentAssistant.tools.push({
|
|
15862
|
+
toolCallId: "thinking",
|
|
15863
|
+
name: "thinking",
|
|
15864
|
+
args: {},
|
|
15865
|
+
output: event.delta
|
|
15866
|
+
});
|
|
15867
|
+
}
|
|
15868
|
+
break;
|
|
15005
15869
|
case "tool.call.started":
|
|
15006
15870
|
if (currentAssistant) {
|
|
15007
15871
|
currentAssistant.tools.push({
|
|
@@ -15021,8 +15885,19 @@ var WebSession = class {
|
|
|
15021
15885
|
}
|
|
15022
15886
|
}
|
|
15023
15887
|
break;
|
|
15888
|
+
case "web.message.finalized": {
|
|
15889
|
+
const finalized = event.message;
|
|
15890
|
+
finalized.seq = messages.length > 0 && messages.at(-1)?.role === "assistant" ? messages.at(-1).seq : entry.seq;
|
|
15891
|
+
if (messages.length > 0 && messages.at(-1)?.role === "assistant") messages[messages.length - 1] = finalized;
|
|
15892
|
+
else messages.push(finalized);
|
|
15893
|
+
currentAssistant = null;
|
|
15894
|
+
turnFinalized = true;
|
|
15895
|
+
break;
|
|
15896
|
+
}
|
|
15024
15897
|
case "turn.ended":
|
|
15025
15898
|
if (event.reason === "failed" && currentAssistant) currentAssistant.isError = true;
|
|
15899
|
+
if (currentAssistant && !turnFinalized && currentAssistant.content === "") currentAssistant.degraded = true;
|
|
15900
|
+
turnFinalized = false;
|
|
15026
15901
|
currentAssistant = null;
|
|
15027
15902
|
break;
|
|
15028
15903
|
default: break;
|
|
@@ -15048,6 +15923,7 @@ var WebSession = class {
|
|
|
15048
15923
|
});
|
|
15049
15924
|
this.pendingApprovals.clear();
|
|
15050
15925
|
this.unsubscribe?.();
|
|
15926
|
+
await Promise.allSettled(this.pendingWrites);
|
|
15051
15927
|
for (const [ws] of this.connections) ws.terminate();
|
|
15052
15928
|
this.connections.clear();
|
|
15053
15929
|
if (this.session) {
|
|
@@ -15400,21 +16276,66 @@ async function runWebServer(opts) {
|
|
|
15400
16276
|
yolo: opts.yolo
|
|
15401
16277
|
});
|
|
15402
16278
|
await manager.init();
|
|
16279
|
+
const lanMode = opts.lan === true;
|
|
16280
|
+
const host = lanMode ? "0.0.0.0" : "127.0.0.1";
|
|
16281
|
+
const gateway = lanMode ? await GatewayAuth.setup({
|
|
16282
|
+
homeDir,
|
|
16283
|
+
token: opts.token
|
|
16284
|
+
}) : null;
|
|
16285
|
+
const loginLimiter = createFixedWindowLimiter({
|
|
16286
|
+
windowMs: 5 * 6e4,
|
|
16287
|
+
max: 10
|
|
16288
|
+
});
|
|
15403
16289
|
if (!manager.list().some((s) => s.active)) {
|
|
15404
16290
|
const recent = manager.list().find((s) => s.workDir === workDir);
|
|
15405
|
-
if (recent)
|
|
16291
|
+
if (recent) try {
|
|
16292
|
+
await manager.activateSession(recent.sessionId);
|
|
16293
|
+
} catch (error) {
|
|
16294
|
+
log.warn("web: resume of most recent session failed", {
|
|
16295
|
+
sessionId: recent.sessionId,
|
|
16296
|
+
error: errorMessage(error)
|
|
16297
|
+
});
|
|
16298
|
+
}
|
|
15406
16299
|
}
|
|
15407
16300
|
const baseDir = import.meta.dirname;
|
|
15408
16301
|
const prodPublicDir = join(baseDir, "public");
|
|
15409
16302
|
const devPublicDir = join(baseDir, "frontend", "dist");
|
|
16303
|
+
const sourcePublicDir = join(baseDir, "frontend", "public");
|
|
15410
16304
|
let publicDir = prodPublicDir;
|
|
15411
16305
|
try {
|
|
15412
16306
|
await access(join(devPublicDir, "index.html"));
|
|
15413
16307
|
publicDir = devPublicDir;
|
|
15414
|
-
} catch {
|
|
16308
|
+
} catch {
|
|
16309
|
+
try {
|
|
16310
|
+
await access(join(sourcePublicDir, "gateway.html"));
|
|
16311
|
+
publicDir = sourcePublicDir;
|
|
16312
|
+
} catch {}
|
|
16313
|
+
}
|
|
15415
16314
|
const httpServer = createServer(async (req, res) => {
|
|
15416
16315
|
const url = req.url ?? "/";
|
|
15417
16316
|
const method = req.method ?? "GET";
|
|
16317
|
+
if (gateway) {
|
|
16318
|
+
const verdict = gatewayVerdict({
|
|
16319
|
+
loopback: isLoopbackAddress(req.socket.remoteAddress),
|
|
16320
|
+
authenticated: gateway.verifySession(req.headers.cookie),
|
|
16321
|
+
path: url.split("?")[0] ?? "/",
|
|
16322
|
+
method,
|
|
16323
|
+
apiPrefix: API_PREFIX
|
|
16324
|
+
});
|
|
16325
|
+
if (verdict === "redirect") {
|
|
16326
|
+
res.writeHead(302, { Location: `/gateway?next=${encodeURIComponent(url)}` });
|
|
16327
|
+
res.end();
|
|
16328
|
+
return;
|
|
16329
|
+
}
|
|
16330
|
+
if (verdict === "unauthorized") {
|
|
16331
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
16332
|
+
res.end(JSON.stringify({
|
|
16333
|
+
code: 40101,
|
|
16334
|
+
message: "unauthorized"
|
|
16335
|
+
}));
|
|
16336
|
+
return;
|
|
16337
|
+
}
|
|
16338
|
+
}
|
|
15418
16339
|
if (url === `${API_PREFIX}/sessions` && method === "GET") {
|
|
15419
16340
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
15420
16341
|
res.end(JSON.stringify(manager.list()));
|
|
@@ -15435,9 +16356,31 @@ async function runWebServer(opts) {
|
|
|
15435
16356
|
return;
|
|
15436
16357
|
}
|
|
15437
16358
|
if (await handleGoalRoute(req, res, url, (sessionId) => manager.get(sessionId))) return;
|
|
15438
|
-
const snapshotMatch = new RegExp(`^${API_PREFIX}/sessions/([^/]+)/snapshot
|
|
16359
|
+
const snapshotMatch = new RegExp(`^${API_PREFIX}/sessions/([^/]+)/snapshot(\\?|$)`).exec(url);
|
|
15439
16360
|
if (snapshotMatch && method === "GET") {
|
|
15440
|
-
const sessionId = snapshotMatch[1];
|
|
16361
|
+
const sessionId = decodeURIComponent(snapshotMatch[1]);
|
|
16362
|
+
const ws = manager.get(sessionId);
|
|
16363
|
+
if (!ws) {
|
|
16364
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
16365
|
+
res.end(JSON.stringify({
|
|
16366
|
+
code: 404,
|
|
16367
|
+
message: "Session not found"
|
|
16368
|
+
}));
|
|
16369
|
+
return;
|
|
16370
|
+
}
|
|
16371
|
+
try {
|
|
16372
|
+
await ws.ready;
|
|
16373
|
+
const query = new URLSearchParams(url.split("?")[1] ?? "");
|
|
16374
|
+
const tail = Number(query.get("tail") ?? "");
|
|
16375
|
+
sendJson(res, 200, Number.isFinite(tail) && tail > 0 ? ws.getSnapshot({ tail }) : ws.getSnapshot());
|
|
16376
|
+
} catch (error) {
|
|
16377
|
+
sendHttpError(res, error);
|
|
16378
|
+
}
|
|
16379
|
+
return;
|
|
16380
|
+
}
|
|
16381
|
+
const olderMatch = /^\/api\/v1\/sessions\/([^/?]+)\/messages(\?|$)/.exec(url);
|
|
16382
|
+
if (olderMatch && method === "GET") {
|
|
16383
|
+
const sessionId = decodeURIComponent(olderMatch[1]);
|
|
15441
16384
|
const ws = manager.get(sessionId);
|
|
15442
16385
|
if (!ws) {
|
|
15443
16386
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
@@ -15449,7 +16392,19 @@ async function runWebServer(opts) {
|
|
|
15449
16392
|
}
|
|
15450
16393
|
try {
|
|
15451
16394
|
await ws.ready;
|
|
15452
|
-
|
|
16395
|
+
const query = new URLSearchParams(url.split("?")[1] ?? "");
|
|
16396
|
+
const seq = Number(query.get("seq") ?? "0");
|
|
16397
|
+
const tool = query.get("tool") ?? "";
|
|
16398
|
+
if (Number.isFinite(seq) && seq > 0 && tool) {
|
|
16399
|
+
sendJson(res, 200, { output: ws.getThinkingEntry(seq, tool) });
|
|
16400
|
+
return;
|
|
16401
|
+
}
|
|
16402
|
+
const before = Number(query.get("before") ?? "0");
|
|
16403
|
+
const tail = Number(query.get("tail") ?? "50");
|
|
16404
|
+
sendJson(res, 200, Number.isFinite(before) && before > 0 ? ws.getMessagesOlder(before, Number.isFinite(tail) && tail > 0 ? tail : 50) : {
|
|
16405
|
+
messages: [],
|
|
16406
|
+
hasMore: false
|
|
16407
|
+
});
|
|
15453
16408
|
} catch (error) {
|
|
15454
16409
|
sendHttpError(res, error);
|
|
15455
16410
|
}
|
|
@@ -15461,6 +16416,24 @@ async function runWebServer(opts) {
|
|
|
15461
16416
|
res.end(JSON.stringify(gs));
|
|
15462
16417
|
return;
|
|
15463
16418
|
}
|
|
16419
|
+
if (url.startsWith(`${API_PREFIX}/git/diff?`) && method === "GET") {
|
|
16420
|
+
const relPath = new URLSearchParams(url.split("?")[1] ?? "").get("path") ?? "";
|
|
16421
|
+
try {
|
|
16422
|
+
const result = await getGitFileDiff(workDir, relPath);
|
|
16423
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
16424
|
+
res.end(JSON.stringify({
|
|
16425
|
+
path: relPath,
|
|
16426
|
+
patch: result?.patch ?? ""
|
|
16427
|
+
}));
|
|
16428
|
+
} catch (error) {
|
|
16429
|
+
sendHttpError(res, error);
|
|
16430
|
+
}
|
|
16431
|
+
return;
|
|
16432
|
+
}
|
|
16433
|
+
function makeFileGate() {
|
|
16434
|
+
return new FileGate(() => manager.list().map((s) => s.workDir));
|
|
16435
|
+
}
|
|
16436
|
+
if (await handleFilesRoutes(req, res, url, method, API_PREFIX, makeFileGate())) return;
|
|
15464
16437
|
if (url === `${API_PREFIX}/like` && method === "GET") {
|
|
15465
16438
|
try {
|
|
15466
16439
|
sendJson(res, 200, await loadLikePreferences());
|
|
@@ -15598,6 +16571,76 @@ async function runWebServer(opts) {
|
|
|
15598
16571
|
res.end();
|
|
15599
16572
|
return;
|
|
15600
16573
|
}
|
|
16574
|
+
if (gateway && url === `${API_PREFIX}/gateway/status` && method === "GET") {
|
|
16575
|
+
sendJson(res, 200, {
|
|
16576
|
+
authRequired: true,
|
|
16577
|
+
authenticated: isLoopbackAddress(req.socket.remoteAddress) || gateway.verifySession(req.headers.cookie)
|
|
16578
|
+
});
|
|
16579
|
+
return;
|
|
16580
|
+
}
|
|
16581
|
+
if (gateway && url === `${API_PREFIX}/gateway/login` && method === "POST") {
|
|
16582
|
+
const ip = req.socket.remoteAddress ?? "unknown";
|
|
16583
|
+
const attempt = loginLimiter.hit(ip);
|
|
16584
|
+
if (!attempt.ok) {
|
|
16585
|
+
res.writeHead(429, {
|
|
16586
|
+
"Content-Type": "application/json",
|
|
16587
|
+
"Retry-After": String(attempt.retryAfterSeconds)
|
|
16588
|
+
});
|
|
16589
|
+
res.end(JSON.stringify({
|
|
16590
|
+
code: 42903,
|
|
16591
|
+
message: `尝试过于频繁,请 ${attempt.retryAfterSeconds} 秒后重试`,
|
|
16592
|
+
retryAfter: attempt.retryAfterSeconds
|
|
16593
|
+
}));
|
|
16594
|
+
return;
|
|
16595
|
+
}
|
|
16596
|
+
try {
|
|
16597
|
+
const body = await readJsonBody(req);
|
|
16598
|
+
const key = typeof body["key"] === "string" ? body["key"] : "";
|
|
16599
|
+
if (!gateway.verifyKey(key)) {
|
|
16600
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
16601
|
+
res.end(JSON.stringify({
|
|
16602
|
+
code: 40101,
|
|
16603
|
+
message: "访问密钥错误"
|
|
16604
|
+
}));
|
|
16605
|
+
return;
|
|
16606
|
+
}
|
|
16607
|
+
loginLimiter.reset(ip);
|
|
16608
|
+
const sid = gateway.createSession();
|
|
16609
|
+
res.writeHead(200, {
|
|
16610
|
+
"Content-Type": "application/json",
|
|
16611
|
+
"Set-Cookie": gateway.sessionCookie(sid),
|
|
16612
|
+
"Cache-Control": "no-store"
|
|
16613
|
+
});
|
|
16614
|
+
res.end(JSON.stringify({ ok: true }));
|
|
16615
|
+
} catch (error) {
|
|
16616
|
+
sendHttpError(res, error);
|
|
16617
|
+
}
|
|
16618
|
+
return;
|
|
16619
|
+
}
|
|
16620
|
+
if (gateway && url === `${API_PREFIX}/gateway/logout` && method === "POST") {
|
|
16621
|
+
gateway.destroySession(req.headers.cookie);
|
|
16622
|
+
res.writeHead(200, {
|
|
16623
|
+
"Content-Type": "application/json",
|
|
16624
|
+
"Set-Cookie": gateway.clearCookie(),
|
|
16625
|
+
"Cache-Control": "no-store"
|
|
16626
|
+
});
|
|
16627
|
+
res.end(JSON.stringify({ ok: true }));
|
|
16628
|
+
return;
|
|
16629
|
+
}
|
|
16630
|
+
if (gateway && url === "/gateway" && method === "GET") {
|
|
16631
|
+
try {
|
|
16632
|
+
const html = await readFile(join(publicDir, "gateway.html"), "utf-8");
|
|
16633
|
+
res.writeHead(200, {
|
|
16634
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
16635
|
+
"Cache-Control": "no-store"
|
|
16636
|
+
});
|
|
16637
|
+
res.end(html);
|
|
16638
|
+
} catch {
|
|
16639
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
16640
|
+
res.end("gateway page missing — did you run pnpm web:build?");
|
|
16641
|
+
}
|
|
16642
|
+
return;
|
|
16643
|
+
}
|
|
15601
16644
|
if (url === "/" || url === "/index.html") {
|
|
15602
16645
|
try {
|
|
15603
16646
|
const html = await readFile(join(publicDir, "index.html"), "utf-8");
|
|
@@ -15623,6 +16666,10 @@ async function runWebServer(opts) {
|
|
|
15623
16666
|
});
|
|
15624
16667
|
const wss = new WebSocketServer({ server: httpServer });
|
|
15625
16668
|
wss.on("connection", (ws, req) => {
|
|
16669
|
+
if (gateway && !isLoopbackAddress(req.socket.remoteAddress) && !gateway.verifySession(req.headers.cookie)) {
|
|
16670
|
+
ws.close(1008, "unauthorized");
|
|
16671
|
+
return;
|
|
16672
|
+
}
|
|
15626
16673
|
const sessionId = new URL(req.url ?? "/", "http://localhost").searchParams.get("sessionId");
|
|
15627
16674
|
if (sessionId) {
|
|
15628
16675
|
const webSession = manager.get(sessionId);
|
|
@@ -15674,9 +16721,11 @@ async function runWebServer(opts) {
|
|
|
15674
16721
|
if (err.code === "EADDRINUSE") reject(/* @__PURE__ */ new Error(`端口 ${opts.port} 已被占用,请先关闭占用该端口的进程,或使用 --port <port> 指定其他端口。`));
|
|
15675
16722
|
else reject(err);
|
|
15676
16723
|
});
|
|
15677
|
-
httpServer.listen(opts.port,
|
|
16724
|
+
httpServer.listen(opts.port, host, resolve);
|
|
15678
16725
|
});
|
|
15679
|
-
const
|
|
16726
|
+
const address = httpServer.address();
|
|
16727
|
+
const realPort = typeof address === "object" && address !== null ? address.port : opts.port;
|
|
16728
|
+
const url = `http://localhost:${realPort}`;
|
|
15680
16729
|
if (opts.open) exec(`${process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"} ${url}`);
|
|
15681
16730
|
let cleaned = false;
|
|
15682
16731
|
const cleanup = async () => {
|
|
@@ -15697,7 +16746,27 @@ async function runWebServer(opts) {
|
|
|
15697
16746
|
process.on("SIGTERM", () => {
|
|
15698
16747
|
cleanup().finally(() => process.exit(0));
|
|
15699
16748
|
});
|
|
15700
|
-
|
|
16749
|
+
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`;
|
|
16750
|
+
if (gateway) {
|
|
16751
|
+
const addrs = getLanAddresses();
|
|
16752
|
+
banner += ` LAN mode: on — non-local devices must present the access key\n`;
|
|
16753
|
+
if (addrs.length > 0) for (const addr of addrs) banner += ` http://${addr}:${realPort}\n`;
|
|
16754
|
+
else banner += ` (no reachable LAN IPv4 address found)\n`;
|
|
16755
|
+
if (gateway.plaintext !== null) banner += ` Access key: ${gateway.plaintext}${gateway.generated ? " (auto-generated, saved, persists across restarts)" : ""}\n`;
|
|
16756
|
+
else banner += ` Access key: (saved in a previous run — use --reset-password to change)\n`;
|
|
16757
|
+
if (addrs.length > 0) try {
|
|
16758
|
+
const qr = await toString(`http://${addrs[0]}:${realPort}`, {
|
|
16759
|
+
type: "terminal",
|
|
16760
|
+
small: true
|
|
16761
|
+
});
|
|
16762
|
+
banner += `\n${qr}\n`;
|
|
16763
|
+
} catch {}
|
|
16764
|
+
}
|
|
16765
|
+
console.log(banner);
|
|
16766
|
+
return {
|
|
16767
|
+
url,
|
|
16768
|
+
close: cleanup
|
|
16769
|
+
};
|
|
15701
16770
|
}
|
|
15702
16771
|
//#endregion
|
|
15703
16772
|
//#region src/cli/run-web.ts
|
|
@@ -15708,7 +16777,38 @@ async function runWebServer(opts) {
|
|
|
15708
16777
|
* connected to the ScreamCode agent. This is a third consumer of agent-core
|
|
15709
16778
|
* (alongside run-shell TUI and run-stream-json), not a separate engine.
|
|
15710
16779
|
*/
|
|
16780
|
+
function promptSecret(question) {
|
|
16781
|
+
if (!process.stdin.isTTY) return Promise.resolve(null);
|
|
16782
|
+
return new Promise((resolve) => {
|
|
16783
|
+
const muted = new Writable({ write(_chunk, _encoding, callback) {
|
|
16784
|
+
callback();
|
|
16785
|
+
} });
|
|
16786
|
+
const rl = createInterface$1({
|
|
16787
|
+
input: process.stdin,
|
|
16788
|
+
output: muted,
|
|
16789
|
+
terminal: true
|
|
16790
|
+
});
|
|
16791
|
+
process.stderr.write(question);
|
|
16792
|
+
rl.question("", (answer) => {
|
|
16793
|
+
rl.close();
|
|
16794
|
+
process.stderr.write("\n");
|
|
16795
|
+
resolve(answer.trim());
|
|
16796
|
+
});
|
|
16797
|
+
});
|
|
16798
|
+
}
|
|
15711
16799
|
async function runWeb(opts) {
|
|
16800
|
+
let token = opts.token;
|
|
16801
|
+
if (opts.resetPassword) {
|
|
16802
|
+
const key = await promptSecret("输入新的网关访问密钥(输入不会回显):");
|
|
16803
|
+
if (key === null) throw new Error("重设访问密钥需要交互终端。");
|
|
16804
|
+
if (key.length === 0) throw new Error("访问密钥不能为空。");
|
|
16805
|
+
if (await promptSecret("确认新的网关访问密钥:") !== key) throw new Error("两次输入的密钥不一致。");
|
|
16806
|
+
token = key;
|
|
16807
|
+
}
|
|
16808
|
+
if (!opts.lan && token !== void 0) await GatewayAuth.setup({
|
|
16809
|
+
homeDir: resolveScreamHome(),
|
|
16810
|
+
token
|
|
16811
|
+
});
|
|
15712
16812
|
await runWebServer({
|
|
15713
16813
|
port: opts.port,
|
|
15714
16814
|
workDir: process.cwd(),
|
|
@@ -15716,7 +16816,9 @@ async function runWeb(opts) {
|
|
|
15716
16816
|
yolo: opts.yolo,
|
|
15717
16817
|
auto: opts.auto,
|
|
15718
16818
|
open: opts.open,
|
|
15719
|
-
skillsDirs: opts.skillsDirs
|
|
16819
|
+
skillsDirs: opts.skillsDirs,
|
|
16820
|
+
lan: opts.lan,
|
|
16821
|
+
token
|
|
15720
16822
|
});
|
|
15721
16823
|
}
|
|
15722
16824
|
//#endregion
|