@co0ontty/wand 1.26.0 → 1.29.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/server.js CHANGED
@@ -151,6 +151,63 @@ async function resolveLatestApkVersion(configDir, config) {
151
151
  }
152
152
  return null;
153
153
  }
154
+ let cachedGitHubDmg = null;
155
+ let gitHubDmgCacheTs = 0;
156
+ const GITHUB_DMG_CACHE_TTL = 10 * 60 * 1000; // 10 minutes
157
+ async function fetchGitHubLatestDmg(forceRefresh = false) {
158
+ const now = Date.now();
159
+ if (!forceRefresh && cachedGitHubDmg && (now - gitHubDmgCacheTs < GITHUB_DMG_CACHE_TTL)) {
160
+ return cachedGitHubDmg;
161
+ }
162
+ try {
163
+ const apiUrl = PKG_REPO_URL.replace("github.com", "api.github.com/repos") + "/releases/latest";
164
+ const resp = await fetch(apiUrl, {
165
+ headers: { "Accept": "application/vnd.github.v3+json", "User-Agent": "wand-server" },
166
+ signal: AbortSignal.timeout(10000),
167
+ });
168
+ if (!resp.ok)
169
+ return cachedGitHubDmg ?? null;
170
+ const release = await resp.json();
171
+ const dmgAsset = release.assets.find(a => a.name.toLowerCase().endsWith(".dmg"));
172
+ if (!dmgAsset)
173
+ return cachedGitHubDmg ?? null;
174
+ const version = extractMacosDmgVersion(release.tag_name) ?? release.tag_name.replace(/^v/, "");
175
+ cachedGitHubDmg = {
176
+ version,
177
+ downloadUrl: dmgAsset.browser_download_url,
178
+ fileName: dmgAsset.name,
179
+ size: dmgAsset.size,
180
+ };
181
+ gitHubDmgCacheTs = now;
182
+ return cachedGitHubDmg;
183
+ }
184
+ catch {
185
+ return cachedGitHubDmg ?? null;
186
+ }
187
+ }
188
+ async function resolveLatestDmgVersion(configDir, config) {
189
+ const localDmg = await resolveMacosDmgAsset(configDir, config);
190
+ if (localDmg && localDmg.version) {
191
+ return {
192
+ version: localDmg.version,
193
+ downloadUrl: localDmg.downloadUrl,
194
+ fileName: localDmg.fileName,
195
+ size: localDmg.size,
196
+ source: "local",
197
+ };
198
+ }
199
+ const ghDmg = await fetchGitHubLatestDmg();
200
+ if (ghDmg) {
201
+ return {
202
+ version: ghDmg.version,
203
+ downloadUrl: ghDmg.downloadUrl,
204
+ fileName: ghDmg.fileName,
205
+ size: ghDmg.size,
206
+ source: "github",
207
+ };
208
+ }
209
+ return null;
210
+ }
154
211
  function isExternalAvatarSource(value) {
155
212
  return /^(https?:|data:)/i.test(value);
156
213
  }
@@ -354,6 +411,11 @@ function decodeConnectCode(code) {
354
411
  function normalizeMode(input, fallback) {
355
412
  return isExecutionMode(input) ? input : fallback;
356
413
  }
414
+ /** Match a semver-looking token in a file name (with optional pre-release / build metadata). */
415
+ function extractSemverFromName(name) {
416
+ const match = name.match(/(\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?)/);
417
+ return match ? match[1] : null;
418
+ }
357
419
  function resolveAndroidApkDir(configDir, config) {
358
420
  const configuredDir = config.android?.apkDir?.trim();
359
421
  if (!configuredDir) {
@@ -362,9 +424,7 @@ function resolveAndroidApkDir(configDir, config) {
362
424
  return path.isAbsolute(configuredDir) ? configuredDir : path.resolve(configDir, configuredDir);
363
425
  }
364
426
  function extractAndroidApkVersion(fileName) {
365
- const nameWithoutExt = fileName.replace(/\.apk$/i, "");
366
- const match = nameWithoutExt.match(/(\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?)/);
367
- return match ? match[1] : null;
427
+ return extractSemverFromName(fileName.replace(/\.apk$/i, ""));
368
428
  }
369
429
  async function resolveAndroidApkAsset(configDir, config) {
370
430
  if (config.android?.enabled !== true)
@@ -417,6 +477,67 @@ async function resolveAndroidApkAsset(configDir, config) {
417
477
  source: "local",
418
478
  };
419
479
  }
480
+ function resolveMacosDmgDir(configDir, config) {
481
+ const configuredDir = config.macos?.dmgDir?.trim();
482
+ if (!configuredDir) {
483
+ return path.join(configDir, "macos");
484
+ }
485
+ return path.isAbsolute(configuredDir) ? configuredDir : path.resolve(configDir, configuredDir);
486
+ }
487
+ function extractMacosDmgVersion(fileName) {
488
+ return extractSemverFromName(fileName.replace(/\.dmg$/i, ""));
489
+ }
490
+ async function resolveMacosDmgAsset(configDir, config) {
491
+ if (config.macos?.enabled !== true)
492
+ return null;
493
+ const dmgDir = resolveMacosDmgDir(configDir, config);
494
+ await mkdir(dmgDir, { recursive: true });
495
+ const configuredFile = config.macos?.currentDmgFile?.trim();
496
+ if (configuredFile) {
497
+ const filePath = path.join(dmgDir, path.basename(configuredFile));
498
+ try {
499
+ const fileStat = await stat(filePath);
500
+ if (!fileStat.isFile())
501
+ return null;
502
+ return {
503
+ fileName: path.basename(filePath),
504
+ filePath,
505
+ size: fileStat.size,
506
+ updatedAt: fileStat.mtime.toISOString(),
507
+ version: extractMacosDmgVersion(path.basename(filePath)),
508
+ downloadUrl: "/macos/download",
509
+ source: "local",
510
+ };
511
+ }
512
+ catch {
513
+ return null;
514
+ }
515
+ }
516
+ const entries = await readdir(dmgDir, { withFileTypes: true });
517
+ const dmgFiles = entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".dmg"));
518
+ if (dmgFiles.length === 0)
519
+ return null;
520
+ const candidates = await Promise.all(dmgFiles.map(async (entry) => {
521
+ const filePath = path.join(dmgDir, entry.name);
522
+ const fileStat = await stat(filePath);
523
+ return {
524
+ entry,
525
+ filePath,
526
+ fileStat,
527
+ };
528
+ }));
529
+ candidates.sort((a, b) => b.fileStat.mtimeMs - a.fileStat.mtimeMs);
530
+ const selected = candidates[0];
531
+ return {
532
+ fileName: selected.entry.name,
533
+ filePath: selected.filePath,
534
+ size: selected.fileStat.size,
535
+ updatedAt: selected.fileStat.mtime.toISOString(),
536
+ version: extractMacosDmgVersion(selected.entry.name),
537
+ downloadUrl: "/macos/download",
538
+ source: "local",
539
+ };
540
+ }
420
541
  async function listPathSuggestions(input, fallbackCwd) {
421
542
  const normalizedInput = input.trim();
422
543
  const baseInput = normalizedInput || fallbackCwd;
@@ -817,6 +938,44 @@ export async function startServer(config, configPath) {
817
938
  res.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(androidApk.fileName)}"`);
818
939
  createReadStream(androidApk.filePath).pipe(res);
819
940
  });
941
+ // ── macOS DMG update & download (no auth required) ──
942
+ app.get("/api/macos-dmg-update", async (req, res) => {
943
+ const currentVersion = req.query.currentVersion?.trim();
944
+ if (!currentVersion) {
945
+ res.status(400).json({ error: "Missing currentVersion query parameter." });
946
+ return;
947
+ }
948
+ const latest = await resolveLatestDmgVersion(configDir, config);
949
+ if (!latest) {
950
+ res.json({ updateAvailable: false, currentVersion, latestVersion: null, downloadUrl: null, source: null });
951
+ return;
952
+ }
953
+ const updateAvailable = compareSemver(latest.version, currentVersion) > 0;
954
+ res.json({
955
+ updateAvailable,
956
+ currentVersion,
957
+ latestVersion: latest.version,
958
+ downloadUrl: updateAvailable ? latest.downloadUrl : null,
959
+ fileName: updateAvailable ? latest.fileName : null,
960
+ size: updateAvailable ? latest.size : null,
961
+ source: latest.source,
962
+ });
963
+ });
964
+ app.get("/macos/download", async (_req, res) => {
965
+ if (config.macos?.enabled !== true) {
966
+ res.status(404).json({ error: "macOS DMG 下载未启用。" });
967
+ return;
968
+ }
969
+ const macosDmg = await resolveMacosDmgAsset(configDir, config);
970
+ if (!macosDmg) {
971
+ res.status(404).json({ error: "当前没有可下载的 DMG 文件。" });
972
+ return;
973
+ }
974
+ res.setHeader("Content-Type", "application/x-apple-diskimage");
975
+ res.setHeader("Content-Length", String(macosDmg.size));
976
+ res.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(macosDmg.fileName)}"`);
977
+ createReadStream(macosDmg.filePath).pipe(res);
978
+ });
820
979
  app.use("/api", requireAuth);
821
980
  // ── Config & Session info ──
822
981
  app.get("/api/config", async (_req, res) => {
@@ -855,6 +1014,14 @@ export async function startServer(config, configPath) {
855
1014
  : ghApk
856
1015
  ? { hasApk: true, fileName: ghApk.fileName, version: ghApk.version, size: ghApk.size, updatedAt: null, downloadUrl: ghApk.downloadUrl, source: "github" }
857
1016
  : null;
1017
+ const localDmg = await resolveMacosDmgAsset(configDir, config);
1018
+ const ghDmg = await fetchGitHubLatestDmg();
1019
+ const dmgDir = resolveMacosDmgDir(configDir, config);
1020
+ const resolvedDmg = localDmg
1021
+ ? { hasDmg: true, fileName: localDmg.fileName, version: localDmg.version, size: localDmg.size, updatedAt: localDmg.updatedAt, downloadUrl: localDmg.downloadUrl, source: "local" }
1022
+ : ghDmg
1023
+ ? { hasDmg: true, fileName: ghDmg.fileName, version: ghDmg.version, size: ghDmg.size, updatedAt: null, downloadUrl: ghDmg.downloadUrl, source: "github" }
1024
+ : null;
858
1025
  res.json({
859
1026
  version: PKG_VERSION,
860
1027
  packageName: PKG_NAME,
@@ -867,6 +1034,7 @@ export async function startServer(config, configPath) {
867
1034
  autoUpdate: {
868
1035
  web: storage.getConfigValue("autoUpdateWeb") === "true",
869
1036
  apk: storage.getConfigValue("autoUpdateApk") === "true",
1037
+ dmg: storage.getConfigValue("autoUpdateDmg") === "true",
870
1038
  },
871
1039
  androidApk: {
872
1040
  enabled: config.android?.enabled === true,
@@ -881,6 +1049,19 @@ export async function startServer(config, configPath) {
881
1049
  local: localApk ? { fileName: localApk.fileName, version: localApk.version, size: localApk.size, updatedAt: localApk.updatedAt, downloadUrl: localApk.downloadUrl } : null,
882
1050
  github: ghApk ? { fileName: ghApk.fileName, version: ghApk.version, size: ghApk.size, downloadUrl: ghApk.downloadUrl } : null,
883
1051
  },
1052
+ macosDmg: {
1053
+ enabled: config.macos?.enabled === true,
1054
+ dmgDir,
1055
+ hasDmg: resolvedDmg?.hasDmg ?? false,
1056
+ fileName: resolvedDmg?.fileName ?? null,
1057
+ version: resolvedDmg?.version ?? null,
1058
+ size: resolvedDmg?.size ?? null,
1059
+ updatedAt: resolvedDmg?.updatedAt ?? null,
1060
+ downloadUrl: resolvedDmg?.downloadUrl ?? null,
1061
+ source: resolvedDmg?.source ?? null,
1062
+ local: localDmg ? { fileName: localDmg.fileName, version: localDmg.version, size: localDmg.size, updatedAt: localDmg.updatedAt, downloadUrl: localDmg.downloadUrl } : null,
1063
+ github: ghDmg ? { fileName: ghDmg.fileName, version: ghDmg.version, size: ghDmg.size, downloadUrl: ghDmg.downloadUrl } : null,
1064
+ },
884
1065
  });
885
1066
  });
886
1067
  app.get("/api/android-apk", async (_req, res) => {
@@ -906,6 +1087,29 @@ export async function startServer(config, configPath) {
906
1087
  github: ghApk ? { fileName: ghApk.fileName, version: ghApk.version, size: ghApk.size, downloadUrl: ghApk.downloadUrl } : null,
907
1088
  });
908
1089
  });
1090
+ app.get("/api/macos-dmg", async (_req, res) => {
1091
+ const localDmg = await resolveMacosDmgAsset(configDir, config);
1092
+ const ghDmg = await fetchGitHubLatestDmg();
1093
+ const dmgDir = resolveMacosDmgDir(configDir, config);
1094
+ const resolvedDmg = localDmg
1095
+ ? { hasDmg: true, fileName: localDmg.fileName, version: localDmg.version, size: localDmg.size, updatedAt: localDmg.updatedAt, downloadUrl: localDmg.downloadUrl, source: "local" }
1096
+ : ghDmg
1097
+ ? { hasDmg: true, fileName: ghDmg.fileName, version: ghDmg.version, size: ghDmg.size, updatedAt: null, downloadUrl: ghDmg.downloadUrl, source: "github" }
1098
+ : null;
1099
+ res.json({
1100
+ enabled: config.macos?.enabled === true,
1101
+ dmgDir,
1102
+ hasDmg: resolvedDmg?.hasDmg ?? false,
1103
+ fileName: resolvedDmg?.fileName ?? null,
1104
+ version: resolvedDmg?.version ?? null,
1105
+ size: resolvedDmg?.size ?? null,
1106
+ updatedAt: resolvedDmg?.updatedAt ?? null,
1107
+ downloadUrl: resolvedDmg?.downloadUrl ?? null,
1108
+ source: resolvedDmg?.source ?? null,
1109
+ local: localDmg ? { fileName: localDmg.fileName, version: localDmg.version, size: localDmg.size, updatedAt: localDmg.updatedAt, downloadUrl: localDmg.downloadUrl } : null,
1110
+ github: ghDmg ? { fileName: ghDmg.fileName, version: ghDmg.version, size: ghDmg.size, downloadUrl: ghDmg.downloadUrl } : null,
1111
+ });
1112
+ });
909
1113
  // 返回当前 inheritEnv 配置下,wand 启动 PTY / 结构化子进程时实际会传给
910
1114
  // claude / codex 的环境变量集合。值会按下面的规则做掩码:
911
1115
  // - 名字里含 KEY/TOKEN/SECRET/PASSWORD/AUTH/CREDENTIAL/COOKIE/SESSION 的视为敏感
@@ -1697,19 +1901,24 @@ export async function startServer(config, configPath) {
1697
1901
  app.get("/api/auto-update", (_req, res) => {
1698
1902
  const web = storage.getConfigValue("autoUpdateWeb") === "true";
1699
1903
  const apk = storage.getConfigValue("autoUpdateApk") === "true";
1700
- res.json({ web, apk });
1904
+ const dmg = storage.getConfigValue("autoUpdateDmg") === "true";
1905
+ res.json({ web, apk, dmg });
1701
1906
  });
1702
1907
  app.post("/api/auto-update", express.json(), (req, res) => {
1703
- const { web, apk } = req.body;
1908
+ const { web, apk, dmg } = req.body;
1704
1909
  if (typeof web === "boolean") {
1705
1910
  storage.setConfigValue("autoUpdateWeb", String(web));
1706
1911
  }
1707
1912
  if (typeof apk === "boolean") {
1708
1913
  storage.setConfigValue("autoUpdateApk", String(apk));
1709
1914
  }
1915
+ if (typeof dmg === "boolean") {
1916
+ storage.setConfigValue("autoUpdateDmg", String(dmg));
1917
+ }
1710
1918
  res.json({
1711
1919
  web: storage.getConfigValue("autoUpdateWeb") === "true",
1712
1920
  apk: storage.getConfigValue("autoUpdateApk") === "true",
1921
+ dmg: storage.getConfigValue("autoUpdateDmg") === "true",
1713
1922
  });
1714
1923
  });
1715
1924
  // ── Auto-update logic ──
package/dist/storage.d.ts CHANGED
@@ -28,6 +28,11 @@ export declare class WandStorage {
28
28
  setPassword(password: string): void;
29
29
  /** Check if password has been set (not default) */
30
30
  hasCustomPassword(): boolean;
31
+ /** Get appSecret from database (used to mint Android appTokens) */
32
+ getAppSecret(): string | null;
33
+ /** Persist appSecret in database (DB is the authoritative source after first migration) */
34
+ setAppSecret(value: string): void;
35
+ hasAppSecret(): boolean;
31
36
  saveAuthSession(token: string, expiresAt: number): void;
32
37
  getAuthSession(token: string): PersistedAuthSession | null;
33
38
  deleteAuthSession(token: string): void;
package/dist/storage.js CHANGED
@@ -302,6 +302,17 @@ export class WandStorage {
302
302
  hasCustomPassword() {
303
303
  return this.getPassword() !== null;
304
304
  }
305
+ /** Get appSecret from database (used to mint Android appTokens) */
306
+ getAppSecret() {
307
+ return this.getConfigValue("appSecret");
308
+ }
309
+ /** Persist appSecret in database (DB is the authoritative source after first migration) */
310
+ setAppSecret(value) {
311
+ this.setConfigValue("appSecret", value);
312
+ }
313
+ hasAppSecret() {
314
+ return this.getAppSecret() !== null;
315
+ }
305
316
  // ============ Auth Session Methods ============
306
317
  saveAuthSession(token, expiresAt) {
307
318
  this.db
@@ -11,6 +11,20 @@ interface CreateStructuredSessionOptions {
11
11
  /** 用户指定的 Claude 模型(别名或完整 ID)。留空则 spawn 时不加 --model。 */
12
12
  model?: string;
13
13
  }
14
+ /**
15
+ * 把任意外部输入收敛到合法的 thinkingEffort 枚举值。`null` / 非法值都视为
16
+ * "未设置"——上层调用方再根据 provider 决定是否填默认值。
17
+ */
18
+ export declare function normalizeThinkingEffort(value: unknown): SessionSnapshot["thinkingEffort"];
19
+ /** Claude SDK 用:把 thinkingEffort 映射成 `thinking.budget_tokens`。off / 空 → 0(不启用)。 */
20
+ export declare function thinkingEffortToSdkBudget(effort: SessionSnapshot["thinkingEffort"]): number;
21
+ /**
22
+ * Claude CLI 用:在 prompt 前注入魔法词,让 claude code 自动识别为思考请求。
23
+ * off → 原 prompt 不变。
24
+ */
25
+ export declare function applyThinkingEffortToPrompt(prompt: string, effort: SessionSnapshot["thinkingEffort"]): string;
26
+ /** Codex CLI 用:把 thinkingEffort 映射到 --reasoning-effort 参数。off → minimal(不显式思考)。 */
27
+ export declare function thinkingEffortToCodexFlag(effort: SessionSnapshot["thinkingEffort"]): string | null;
14
28
  export declare class StructuredSessionManager {
15
29
  private readonly storage;
16
30
  private readonly config;
@@ -62,6 +76,12 @@ export declare class StructuredSessionManager {
62
76
  denyPermission(sessionId: string): SessionSnapshot;
63
77
  /** Update the selected model for a structured session. Takes effect on the next spawn. */
64
78
  setSessionModel(sessionId: string, model: string | null): SessionSnapshot;
79
+ /**
80
+ * Update the thinking-effort level for a structured session. Takes effect on
81
+ * the next spawn / next message (SDK runner injects `thinking`, CLI runner
82
+ * prepends magic words, codex runner adds --reasoning-effort).
83
+ */
84
+ setSessionThinkingEffort(sessionId: string, effort: SessionSnapshot["thinkingEffort"]): SessionSnapshot;
65
85
  /** Toggle auto-approve for the session. */
66
86
  toggleAutoApprove(sessionId: string): SessionSnapshot;
67
87
  /** Resolve a specific escalation by requestId. */
@@ -1,7 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { createRequire } from "node:module";
4
- import { existsSync } from "node:fs";
4
+ import { existsSync, readFileSync, statSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import path from "node:path";
5
7
  import { query as sdkQuery } from "@anthropic-ai/claude-agent-sdk";
6
8
  import { prepareSessionWorktree } from "./git-worktree.js";
7
9
  import { truncateMessagesForTransport } from "./message-truncator.js";
@@ -18,6 +20,69 @@ function defaultStructuredState(provider, runner = defaultStructuredRunner(provi
18
20
  activeRequestId: null,
19
21
  };
20
22
  }
23
+ /**
24
+ * 把任意外部输入收敛到合法的 thinkingEffort 枚举值。`null` / 非法值都视为
25
+ * "未设置"——上层调用方再根据 provider 决定是否填默认值。
26
+ */
27
+ export function normalizeThinkingEffort(value) {
28
+ if (typeof value !== "string")
29
+ return null;
30
+ const v = value.trim().toLowerCase();
31
+ if (v === "off" || v === "standard" || v === "deep" || v === "max")
32
+ return v;
33
+ return null;
34
+ }
35
+ /** Claude SDK 用:把 thinkingEffort 映射成 `thinking.budget_tokens`。off / 空 → 0(不启用)。 */
36
+ export function thinkingEffortToSdkBudget(effort) {
37
+ switch (effort) {
38
+ case "standard": return 4096;
39
+ case "deep": return 16000;
40
+ case "max": return 31999;
41
+ case "off":
42
+ default: return 0;
43
+ }
44
+ }
45
+ /**
46
+ * Claude CLI 用:在 prompt 前注入魔法词,让 claude code 自动识别为思考请求。
47
+ * off → 原 prompt 不变。
48
+ */
49
+ export function applyThinkingEffortToPrompt(prompt, effort) {
50
+ const trimmed = prompt.trimStart();
51
+ if (!trimmed)
52
+ return prompt;
53
+ let prefix = "";
54
+ switch (effort) {
55
+ case "standard":
56
+ prefix = "think. ";
57
+ break;
58
+ case "deep":
59
+ prefix = "think hard. ";
60
+ break;
61
+ case "max":
62
+ prefix = "ultrathink. ";
63
+ break;
64
+ case "off":
65
+ default: return prompt;
66
+ }
67
+ // 用户已经手写了相同强度的指令时不重复加,避免把 "ultrathink. ultrathink." 喂给模型。
68
+ const lower = trimmed.toLowerCase();
69
+ if (lower.startsWith("ultrathink") || lower.startsWith("think hard") || lower.startsWith("think very") || lower.startsWith("think harder")) {
70
+ return prompt;
71
+ }
72
+ if (effort === "standard" && lower.startsWith("think"))
73
+ return prompt;
74
+ return prefix + trimmed;
75
+ }
76
+ /** Codex CLI 用:把 thinkingEffort 映射到 --reasoning-effort 参数。off → minimal(不显式思考)。 */
77
+ export function thinkingEffortToCodexFlag(effort) {
78
+ switch (effort) {
79
+ case "standard": return "low";
80
+ case "deep": return "medium";
81
+ case "max": return "high";
82
+ case "off": return "minimal";
83
+ default: return null;
84
+ }
85
+ }
21
86
  const STREAM_EMIT_DEBOUNCE_MS = 16;
22
87
  /** Min interval between full saveSession() calls for an in-progress streaming turn.
23
88
  * saveSession serializes the entire messages array, so doing it on every NDJSON
@@ -131,25 +196,104 @@ function shouldAutoApproveForMode(mode) {
131
196
  const ROOT_FALLBACK_ALLOWED_TOOLS = [
132
197
  "Bash", "Edit", "Write", "Read", "Glob", "Grep", "NotebookEdit", "WebFetch", "WebSearch",
133
198
  ];
199
+ /**
200
+ * 收集当前会话可见的 MCP server 名字。
201
+ * claude -p / SDK runner 没有交互式权限弹窗,碰到 mcp__* 工具会直接 fail with
202
+ * "haven't granted"。用户已经在 claude 这边配过的 MCP server 视为可信,
203
+ * 在 --allowedTools 里加 `mcp__<server>` 放行整台 server 的所有工具。
204
+ *
205
+ * 来源(取并集):
206
+ * - ~/.claude.json 顶层 mcpServers
207
+ * - ~/.claude.json projects[<cwd>].mcpServers(仅当前 cwd 精确匹配)
208
+ * - <cwd>/.mcp.json mcpServers
209
+ *
210
+ * 结果按 (cwd, 各文件 mtime) 缓存,避免每次 spawn 都重读。
211
+ */
212
+ const mcpServerCache = new Map();
213
+ function readJsonSafe(filePath) {
214
+ try {
215
+ const raw = readFileSync(filePath, "utf-8");
216
+ const parsed = JSON.parse(raw);
217
+ if (parsed && typeof parsed === "object")
218
+ return parsed;
219
+ }
220
+ catch { /* missing/invalid — return null */ }
221
+ return null;
222
+ }
223
+ function mtimeOf(filePath) {
224
+ try {
225
+ return statSync(filePath).mtimeMs;
226
+ }
227
+ catch {
228
+ return 0;
229
+ }
230
+ }
231
+ function extractMcpServerKeys(node) {
232
+ if (!node || typeof node !== "object")
233
+ return [];
234
+ const mcpServers = node.mcpServers;
235
+ if (!mcpServers || typeof mcpServers !== "object")
236
+ return [];
237
+ return Object.keys(mcpServers);
238
+ }
239
+ function collectMcpServerNames(cwd) {
240
+ const userConfigPath = path.join(homedir(), ".claude.json");
241
+ const projectMcpPath = path.join(cwd, ".mcp.json");
242
+ const fingerprint = `${mtimeOf(userConfigPath)}:${mtimeOf(projectMcpPath)}`;
243
+ const cached = mcpServerCache.get(cwd);
244
+ if (cached && cached.mtimeFingerprint === fingerprint)
245
+ return cached.names;
246
+ const names = new Set();
247
+ const userConfig = readJsonSafe(userConfigPath);
248
+ if (userConfig) {
249
+ for (const k of extractMcpServerKeys(userConfig))
250
+ names.add(k);
251
+ const projects = userConfig.projects;
252
+ if (projects && typeof projects === "object") {
253
+ const entry = projects[cwd];
254
+ for (const k of extractMcpServerKeys(entry))
255
+ names.add(k);
256
+ }
257
+ }
258
+ const projectMcp = readJsonSafe(projectMcpPath);
259
+ for (const k of extractMcpServerKeys(projectMcp))
260
+ names.add(k);
261
+ const result = Array.from(names);
262
+ mcpServerCache.set(cwd, { mtimeFingerprint: fingerprint, names: result });
263
+ return result;
264
+ }
265
+ function mcpAllowEntries(cwd) {
266
+ // `mcp__<server>` 形式放行该 server 的所有工具,等价于 `mcp__<server>__*`。
267
+ return collectMcpServerNames(cwd).map((name) => `mcp__${name}`);
268
+ }
134
269
  /**
135
270
  * 把 (执行模式, 自动批准开关) 映射成 Claude CLI / SDK 的权限决策。
136
271
  * CLI runner 把它转成 --permission-mode / --allowedTools flag,
137
272
  * SDK runner 直接塞进 Options。两边的决策规则保持一字不差。
273
+ *
274
+ * cwd 用来枚举该会话能看到的 MCP server,把 `mcp__<server>` 加进 allowedTools;
275
+ * bypassPermissions 模式下整个白名单都没意义,不附加。
138
276
  */
139
- function derivePermissionPolicy(mode, autoApprove) {
277
+ function derivePermissionPolicy(mode, autoApprove, cwd) {
140
278
  const shouldBypass = autoApprove || mode === "full-access" || mode === "managed";
141
279
  const shouldAcceptEdits = mode === "auto-edit";
280
+ const mcpAllow = shouldBypass ? [] : mcpAllowEntries(cwd);
281
+ const withMcp = (base) => {
282
+ if (!mcpAllow.length)
283
+ return base;
284
+ return base ? [...base, ...mcpAllow] : [...mcpAllow];
285
+ };
142
286
  if (!isRunningAsRoot()) {
143
287
  if (shouldBypass)
144
288
  return { permissionMode: "bypassPermissions", allowedTools: undefined };
145
289
  if (shouldAcceptEdits)
146
- return { permissionMode: "acceptEdits", allowedTools: undefined };
147
- return { permissionMode: "default", allowedTools: undefined };
290
+ return { permissionMode: "acceptEdits", allowedTools: withMcp(undefined) };
291
+ return { permissionMode: "default", allowedTools: withMcp(undefined) };
148
292
  }
149
293
  if (shouldBypass || shouldAcceptEdits) {
150
- return { permissionMode: "acceptEdits", allowedTools: ROOT_FALLBACK_ALLOWED_TOOLS };
294
+ return { permissionMode: "acceptEdits", allowedTools: withMcp(ROOT_FALLBACK_ALLOWED_TOOLS) };
151
295
  }
152
- return { permissionMode: "default", allowedTools: undefined };
296
+ return { permissionMode: "default", allowedTools: withMcp(undefined) };
153
297
  }
154
298
  /**
155
299
  * 拼装要追加到系统提示词里的片段:托管模式的自主决策提示 + 用户配置的语言偏好。
@@ -566,6 +710,27 @@ export class StructuredSessionManager {
566
710
  });
567
711
  return updated;
568
712
  }
713
+ /**
714
+ * Update the thinking-effort level for a structured session. Takes effect on
715
+ * the next spawn / next message (SDK runner injects `thinking`, CLI runner
716
+ * prepends magic words, codex runner adds --reasoning-effort).
717
+ */
718
+ setSessionThinkingEffort(sessionId, effort) {
719
+ const session = this.requireSession(sessionId);
720
+ const normalized = normalizeThinkingEffort(effort);
721
+ const updated = {
722
+ ...session,
723
+ thinkingEffort: normalized,
724
+ };
725
+ this.sessions.set(sessionId, updated);
726
+ this.storage.saveSession(updated);
727
+ this.emit({
728
+ type: "status",
729
+ sessionId,
730
+ data: { sessionKind: "structured", thinkingEffort: normalized },
731
+ });
732
+ return updated;
733
+ }
569
734
  /** Toggle auto-approve for the session. */
570
735
  toggleAutoApprove(sessionId) {
571
736
  const session = this.requireSession(sessionId);
@@ -1113,7 +1278,7 @@ export class StructuredSessionManager {
1113
1278
  // 紧跟其后的所有非 flag 形 token 都会被吞进工具列表,因此后面任何位置参数
1114
1279
  // 都得是 -- 开头的 flag——下面追加 --append-system-prompt / --model / --resume
1115
1280
  // 都满足这个条件。
1116
- const permPolicy = derivePermissionPolicy(session.mode, session.autoApprovePermissions ?? false);
1281
+ const permPolicy = derivePermissionPolicy(session.mode, session.autoApprovePermissions ?? false, session.cwd);
1117
1282
  if (permPolicy.permissionMode !== "default") {
1118
1283
  args.push("--permission-mode", permPolicy.permissionMode);
1119
1284
  }
@@ -1207,15 +1372,23 @@ export class StructuredSessionManager {
1207
1372
  blocksByKey.set(key, blocks);
1208
1373
  return;
1209
1374
  }
1210
- // claude -p 在同一 message.id 的多次 assistant 事件里**应当**每次发出累加内容。
1211
- // 但偶发会观察到某次重发只带其中一部分 block(典型场景:text 后又开始一段
1212
- // tool_use,下一帧 text 字段意外为空字符串),导致先前已渲染的文本被覆盖
1213
- // 为空——刷新页面后从持久化又恢复出来,就是用户反馈的"显示了又消失"。
1214
- // 这里按 index 逐块取较"重"的版本,类型一致则取信息量大者,否则信任 incoming
1215
- // 但**绝不**让 incoming 比 prev 的总块数少(短数组拼接 prev 的尾部)。
1375
+ // claude -p 在同一 message.id 的多次 assistant 事件里是**累积**协议:
1376
+ // 每次 event 的 content 总是包含之前所有 blocks + 可能的新 block
1377
+ // 长度只增不减、同位置类型不变。两条额外的防御性规则:
1378
+ //
1379
+ // 1) blocks.length < prev.length —— 短数组覆盖。上游异常 frame,直接拒绝
1380
+ // 本次更新,下一帧正常累积 emit 会自然修正。
1381
+ //
1382
+ // 2) 同 index 类型不一致 —— 比如 prev[0]=text 而 incoming[0]=tool_use。
1383
+ // 正常累积下不会发生;一旦发生,**保留 prev[i]**。早期版本走"取
1384
+ // volume 大者",会让 tool_use(input JSON 通常更长)抢占 text 位,
1385
+ // 导致流式过程中已经渲染的文字突然消失,只剩工具卡片——直到 result
1386
+ // event 给出最终 turnState.result,compactContentBlocks 的 fallback
1387
+ // 才补回 text。用户反馈"文字消失,回复完成后又出现"就是这条路径。
1388
+ if (blocks.length < prev.length)
1389
+ return;
1216
1390
  const merged = [];
1217
- const maxLen = Math.max(prev.length, blocks.length);
1218
- for (let i = 0; i < maxLen; i++) {
1391
+ for (let i = 0; i < blocks.length; i++) {
1219
1392
  const a = prev[i];
1220
1393
  const b = blocks[i];
1221
1394
  if (a && !b) {
@@ -1228,11 +1401,12 @@ export class StructuredSessionManager {
1228
1401
  }
1229
1402
  if (a && b) {
1230
1403
  if (a.type === b.type) {
1404
+ // 同类型:取信息量大者,避免短回退覆盖已经累积的内容。
1231
1405
  merged.push(blockVolume(b) >= blockVolume(a) ? b : a);
1232
1406
  }
1233
1407
  else {
1234
- // 类型变了(极少见,多半是上游 bug)——保留信息量大者,不丢字。
1235
- merged.push(blockVolume(b) >= blockVolume(a) ? b : a);
1408
+ // 类型变了:保留 prev,不让 tool_use 等抢占 text 位。
1409
+ merged.push(a);
1236
1410
  }
1237
1411
  }
1238
1412
  }
@@ -1605,7 +1779,7 @@ export class StructuredSessionManager {
1605
1779
  const isManaged = session.mode === "managed";
1606
1780
  let killedForAskUserQuestion = false;
1607
1781
  // 权限策略 + 系统提示词都通过共享 helper 派生,与 CLI runner 一字不差。
1608
- const permPolicy = derivePermissionPolicy(session.mode, session.autoApprovePermissions ?? false);
1782
+ const permPolicy = derivePermissionPolicy(session.mode, session.autoApprovePermissions ?? false, session.cwd);
1609
1783
  const systemPromptParts = buildAppendSystemPromptParts(this.config.language, session.mode);
1610
1784
  const sdkClaudeBinary = resolveSdkClaudeBinary();
1611
1785
  // SDK 默认会把整个 process.env 透传给 claude 子进程;这里显式按 inheritEnv 配置组装,