scream-code 0.14.9 → 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.
@@ -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-1ojfKMwf.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-D8ovLbmr.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.14.9"),
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(() => {
@@ -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 -selection clipboard -i (X11)
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 the underlying command fails, so callers can
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
- command = "wl-copy";
7970
- args = [];
7971
- input = text;
7972
- break;
7973
- case "linux-x11":
7974
- command = "xclip";
7975
- args = [
7976
- "-selection",
7977
- "clipboard",
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
- return false;
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-CJPpI4gm.mjs").then(({ handleConnectCommand }) => handleConnectCommand(this, "")).finally(() => {
12400
+ import("./dispatch-BPcn43Vw.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":
@@ -14130,6 +14687,25 @@ function deriveTitle(messages) {
14130
14687
  return text.length > 40 ? text.slice(0, 40) + "..." : text;
14131
14688
  }
14132
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
+ }
14133
14709
  async function git(workDir, args) {
14134
14710
  const { stdout } = await execFileAsync("git", [
14135
14711
  "-C",
@@ -14151,13 +14727,21 @@ async function getGitStatus(workDir) {
14151
14727
  let ahead;
14152
14728
  let behind;
14153
14729
  let changed = 0;
14730
+ let gitFiles = [];
14154
14731
  try {
14155
- const [branchOut, statusOut] = await Promise.all([git(workDir, ["branch", "--show-current"]), git(workDir, [
14156
- "status",
14157
- "--porcelain",
14158
- "-b"
14159
- ])]);
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
+ ]);
14160
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;
14161
14745
  const statusLines = statusOut.split("\n").filter((l) => l.length > 0);
14162
14746
  const header = statusLines[0] ?? "";
14163
14747
  const aheadMatch = /ahead (\d+)/.exec(header);
@@ -14165,10 +14749,18 @@ async function getGitStatus(workDir) {
14165
14749
  if (aheadMatch) ahead = Number(aheadMatch[1]);
14166
14750
  if (behindMatch) behind = Number(behindMatch[1]);
14167
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;
14168
14758
  } catch {}
14169
14759
  let adds;
14170
14760
  let dels;
14171
14761
  let diffStat;
14762
+ /** Repo-root-relative → per-file line counts, from `git diff --numstat`. */
14763
+ const perFile = /* @__PURE__ */ new Map();
14172
14764
  try {
14173
14765
  const [numstatOut, diffStatOut] = await Promise.all([git(workDir, [
14174
14766
  "diff",
@@ -14182,14 +14774,41 @@ async function getGitStatus(workDir) {
14182
14774
  adds = 0;
14183
14775
  dels = 0;
14184
14776
  for (const line of numstatOut.split("\n")) {
14185
- const m = /^(\d+)\t(\d+)\t/.exec(line);
14186
- if (m) {
14187
- adds += Number(m[1]);
14188
- dels += Number(m[2]);
14189
- }
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
+ });
14190
14786
  }
14191
14787
  diffStat = diffStatOut.trim().split("\n").slice(0, 200).join("\n") || void 0;
14192
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
+ });
14193
14812
  return {
14194
14813
  isRepo: true,
14195
14814
  branch,
@@ -14198,8 +14817,96 @@ async function getGitStatus(workDir) {
14198
14817
  changed,
14199
14818
  adds,
14200
14819
  dels,
14201
- diffStat
14820
+ diffStat,
14821
+ files: gitFiles.length > 0 ? gitFiles : void 0
14822
+ };
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: ""
14202
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
+ }
14203
14910
  }
14204
14911
  function exportToMarkdown(messages, title) {
14205
14912
  let out = `# ${title}\n\n`;
@@ -14240,6 +14947,14 @@ var WebSession = class {
14240
14947
  heartbeatTimer = null;
14241
14948
  unsubscribe = null;
14242
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;
14243
14958
  /** pendingMsgId awaiting compaction completion/cancellation event. */
14244
14959
  pendingCompactionMsgId = null;
14245
14960
  homeDir;
@@ -14370,14 +15085,104 @@ var WebSession = class {
14370
15085
  if (!entry.volatile) this.persistEntry(entry);
14371
15086
  return entry;
14372
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
+ }
14373
15104
  subscribeEvents() {
14374
15105
  if (!this.session) return;
14375
15106
  this.unsubscribe = this.session.onEvent((event) => {
14376
15107
  if ((event.type === "goal.updated" || event.type === "todo.updated") && event.agentId !== "main") return;
14377
- if (event.type === "turn.started") this.busy = true;
14378
- else if (event.type === "turn.ended") {
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") {
14379
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;
14380
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
+ }
14381
15186
  } else if (event.type === "goal.updated") {
14382
15187
  this.goalEventRevision += 1;
14383
15188
  this.cachedGoal = cloneSnapshot(event.snapshot);
@@ -14782,7 +15587,17 @@ var WebSession = class {
14782
15587
  }
14783
15588
  }, HEARTBEAT_INTERVAL_MS);
14784
15589
  }
14785
- 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);
14786
15601
  return cloneSnapshot({
14787
15602
  sessionId: this.sessionId,
14788
15603
  workDir: this.workDir,
@@ -14790,7 +15605,9 @@ var WebSession = class {
14790
15605
  epoch: this.epoch,
14791
15606
  model: this.cachedStatus?.model ?? "unknown",
14792
15607
  permission: this.cachedStatus?.permission ?? this.permission,
14793
- messages: this.buildMessages(),
15608
+ messages,
15609
+ olderAvailable,
15610
+ oldestSeq,
14794
15611
  pendingApprovals: Array.from(this.pendingApprovals.entries()).map(([id, approval]) => ({
14795
15612
  id,
14796
15613
  toolName: approval.toolName,
@@ -14815,6 +15632,22 @@ var WebSession = class {
14815
15632
  todos: this.cachedTodos
14816
15633
  });
14817
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
+ }
14818
15651
  getListItem() {
14819
15652
  return {
14820
15653
  sessionId: this.sessionId,
@@ -14970,10 +15803,14 @@ var WebSession = class {
14970
15803
  while (userIndex < this.userMessages.length) {
14971
15804
  const item = this.userMessages[userIndex];
14972
15805
  if (item === void 0 || item.beforeSeq > seq) break;
14973
- messages.push(item.msg);
15806
+ messages.push({
15807
+ ...item.msg,
15808
+ seq: item.beforeSeq
15809
+ });
14974
15810
  userIndex++;
14975
15811
  }
14976
15812
  };
15813
+ let turnFinalized = false;
14977
15814
  for (const entry of this.journal) {
14978
15815
  const event = entry.payload;
14979
15816
  flushUserMessagesBefore(entry.seq);
@@ -14984,6 +15821,8 @@ var WebSession = class {
14984
15821
  role: "assistant",
14985
15822
  content: "",
14986
15823
  tools: [],
15824
+ seq: entry.seq,
15825
+ model: this.cachedStatus?.model,
14987
15826
  turnStats: {
14988
15827
  turn: turnCount,
14989
15828
  step: 0,
@@ -15002,6 +15841,18 @@ var WebSession = class {
15002
15841
  case "assistant.delta":
15003
15842
  if (currentAssistant) currentAssistant.content += event.delta;
15004
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;
15005
15856
  case "tool.call.started":
15006
15857
  if (currentAssistant) {
15007
15858
  currentAssistant.tools.push({
@@ -15021,8 +15872,19 @@ var WebSession = class {
15021
15872
  }
15022
15873
  }
15023
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
+ }
15024
15884
  case "turn.ended":
15025
15885
  if (event.reason === "failed" && currentAssistant) currentAssistant.isError = true;
15886
+ if (currentAssistant && !turnFinalized && currentAssistant.content === "") currentAssistant.degraded = true;
15887
+ turnFinalized = false;
15026
15888
  currentAssistant = null;
15027
15889
  break;
15028
15890
  default: break;
@@ -15400,21 +16262,66 @@ async function runWebServer(opts) {
15400
16262
  yolo: opts.yolo
15401
16263
  });
15402
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
+ });
15403
16275
  if (!manager.list().some((s) => s.active)) {
15404
16276
  const recent = manager.list().find((s) => s.workDir === workDir);
15405
- if (recent) await manager.activateSession(recent.sessionId);
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
+ }
15406
16285
  }
15407
16286
  const baseDir = import.meta.dirname;
15408
16287
  const prodPublicDir = join(baseDir, "public");
15409
16288
  const devPublicDir = join(baseDir, "frontend", "dist");
16289
+ const sourcePublicDir = join(baseDir, "frontend", "public");
15410
16290
  let publicDir = prodPublicDir;
15411
16291
  try {
15412
16292
  await access(join(devPublicDir, "index.html"));
15413
16293
  publicDir = devPublicDir;
15414
- } catch {}
16294
+ } catch {
16295
+ try {
16296
+ await access(join(sourcePublicDir, "gateway.html"));
16297
+ publicDir = sourcePublicDir;
16298
+ } catch {}
16299
+ }
15415
16300
  const httpServer = createServer(async (req, res) => {
15416
16301
  const url = req.url ?? "/";
15417
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
+ }
15418
16325
  if (url === `${API_PREFIX}/sessions` && method === "GET") {
15419
16326
  res.writeHead(200, { "Content-Type": "application/json" });
15420
16327
  res.end(JSON.stringify(manager.list()));
@@ -15435,9 +16342,31 @@ async function runWebServer(opts) {
15435
16342
  return;
15436
16343
  }
15437
16344
  if (await handleGoalRoute(req, res, url, (sessionId) => manager.get(sessionId))) return;
15438
- const snapshotMatch = new RegExp(`^${API_PREFIX}/sessions/([^/]+)/snapshot$`).exec(url);
16345
+ const snapshotMatch = new RegExp(`^${API_PREFIX}/sessions/([^/]+)/snapshot(\\?|$)`).exec(url);
15439
16346
  if (snapshotMatch && method === "GET") {
15440
- 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]);
15441
16370
  const ws = manager.get(sessionId);
15442
16371
  if (!ws) {
15443
16372
  res.writeHead(404, { "Content-Type": "application/json" });
@@ -15449,7 +16378,19 @@ async function runWebServer(opts) {
15449
16378
  }
15450
16379
  try {
15451
16380
  await ws.ready;
15452
- sendJson(res, 200, ws.getSnapshot());
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
+ });
15453
16394
  } catch (error) {
15454
16395
  sendHttpError(res, error);
15455
16396
  }
@@ -15461,6 +16402,24 @@ async function runWebServer(opts) {
15461
16402
  res.end(JSON.stringify(gs));
15462
16403
  return;
15463
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;
15464
16423
  if (url === `${API_PREFIX}/like` && method === "GET") {
15465
16424
  try {
15466
16425
  sendJson(res, 200, await loadLikePreferences());
@@ -15598,6 +16557,76 @@ async function runWebServer(opts) {
15598
16557
  res.end();
15599
16558
  return;
15600
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
+ }
15601
16630
  if (url === "/" || url === "/index.html") {
15602
16631
  try {
15603
16632
  const html = await readFile(join(publicDir, "index.html"), "utf-8");
@@ -15623,6 +16652,10 @@ async function runWebServer(opts) {
15623
16652
  });
15624
16653
  const wss = new WebSocketServer({ server: httpServer });
15625
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
+ }
15626
16659
  const sessionId = new URL(req.url ?? "/", "http://localhost").searchParams.get("sessionId");
15627
16660
  if (sessionId) {
15628
16661
  const webSession = manager.get(sessionId);
@@ -15674,9 +16707,11 @@ async function runWebServer(opts) {
15674
16707
  if (err.code === "EADDRINUSE") reject(/* @__PURE__ */ new Error(`端口 ${opts.port} 已被占用,请先关闭占用该端口的进程,或使用 --port <port> 指定其他端口。`));
15675
16708
  else reject(err);
15676
16709
  });
15677
- httpServer.listen(opts.port, "127.0.0.1", resolve);
16710
+ httpServer.listen(opts.port, host, resolve);
15678
16711
  });
15679
- const url = `http://localhost:${opts.port}`;
16712
+ const address = httpServer.address();
16713
+ const realPort = typeof address === "object" && address !== null ? address.port : opts.port;
16714
+ const url = `http://localhost:${realPort}`;
15680
16715
  if (opts.open) exec(`${process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"} ${url}`);
15681
16716
  let cleaned = false;
15682
16717
  const cleanup = async () => {
@@ -15697,7 +16732,27 @@ async function runWebServer(opts) {
15697
16732
  process.on("SIGTERM", () => {
15698
16733
  cleanup().finally(() => process.exit(0));
15699
16734
  });
15700
- console.log(`\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`);
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
+ };
15701
16756
  }
15702
16757
  //#endregion
15703
16758
  //#region src/cli/run-web.ts
@@ -15708,7 +16763,38 @@ async function runWebServer(opts) {
15708
16763
  * connected to the ScreamCode agent. This is a third consumer of agent-core
15709
16764
  * (alongside run-shell TUI and run-stream-json), not a separate engine.
15710
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
+ }
15711
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
+ });
15712
16798
  await runWebServer({
15713
16799
  port: opts.port,
15714
16800
  workDir: process.cwd(),
@@ -15716,7 +16802,9 @@ async function runWeb(opts) {
15716
16802
  yolo: opts.yolo,
15717
16803
  auto: opts.auto,
15718
16804
  open: opts.open,
15719
- skillsDirs: opts.skillsDirs
16805
+ skillsDirs: opts.skillsDirs,
16806
+ lan: opts.lan,
16807
+ token
15720
16808
  });
15721
16809
  }
15722
16810
  //#endregion