@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/cli.js +20 -4
- package/dist/config.js +99 -4
- package/dist/git-quick-commit.d.ts +3 -2
- package/dist/git-quick-commit.js +170 -133
- package/dist/server.js +214 -5
- package/dist/storage.d.ts +5 -0
- package/dist/storage.js +11 -0
- package/dist/structured-session-manager.d.ts +20 -0
- package/dist/structured-session-manager.js +192 -18
- package/dist/types.d.ts +25 -7
- package/dist/web-ui/content/scripts.js +366 -79
- package/dist/ws-broadcast.d.ts +10 -0
- package/dist/ws-broadcast.js +75 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1212
|
-
//
|
|
1213
|
-
//
|
|
1214
|
-
//
|
|
1215
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
1235
|
-
merged.push(
|
|
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 配置组装,
|