@co0ontty/wand 1.36.0 → 1.39.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.
@@ -6,6 +6,13 @@ export declare class ClaudeRunError extends Error {
6
6
  export interface RunClaudePrintOptions {
7
7
  cwd?: string;
8
8
  timeoutMs: number;
9
+ /**
10
+ * 用户偏好的回复语言(取自 config.language)。传进来时会以
11
+ * `appendSystemPrompt` 形式灌给 Claude,保证 quick-commit / prompt-optimizer
12
+ * 这种一次性调用也跟用户主会话同语言——之前 wand 的 git commit message 会
13
+ * 莫名其妙变中英混搭,根因就在这。
14
+ */
15
+ language?: string;
9
16
  }
10
17
  /**
11
18
  * 用 `@anthropic-ai/claude-agent-sdk` 跑一次"prompt → 单段纯文本"调用,
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import { query as sdkQuery, } from "@anthropic-ai/claude-agent-sdk";
4
+ import { buildLanguageDirective } from "./language-prompt.js";
4
5
  export class ClaudeRunError extends Error {
5
6
  code;
6
7
  constructor(message, code) {
@@ -79,12 +80,14 @@ export async function runClaudePrint(prompt, options) {
79
80
  const abortController = new AbortController();
80
81
  const timeoutHandle = setTimeout(() => abortController.abort(), options.timeoutMs);
81
82
  const sdkClaudeBinary = resolveSdkClaudeBinary();
83
+ const languageDirective = options.language ? buildLanguageDirective(options.language) : "";
82
84
  const sdkOptions = {
83
85
  abortController,
84
86
  tools: [],
85
87
  persistSession: false,
86
88
  ...(cwd ? { cwd } : {}),
87
89
  ...(sdkClaudeBinary ? { pathToClaudeCodeExecutable: sdkClaudeBinary } : {}),
90
+ ...(languageDirective ? { appendSystemPrompt: languageDirective } : {}),
88
91
  };
89
92
  // 单条 user message → AsyncGenerator,SDK 的 streaming input 协议要求。
90
93
  async function* singleShot() {
@@ -240,9 +240,9 @@ export class QuickCommitError extends Error {
240
240
  }
241
241
  }
242
242
  // ── AI commit message generation ──
243
- async function callClaudeText(prompt, cwd) {
243
+ async function callClaudeText(prompt, cwd, language) {
244
244
  try {
245
- return await runClaudePrint(prompt, { cwd, timeoutMs: CLAUDE_MESSAGE_TIMEOUT_MS });
245
+ return await runClaudePrint(prompt, { cwd, timeoutMs: CLAUDE_MESSAGE_TIMEOUT_MS, language });
246
246
  }
247
247
  catch (error) {
248
248
  if (error instanceof ClaudeRunError) {
@@ -283,7 +283,7 @@ async function generateCommitMessage(cwd, language) {
283
283
  const diff = collectStagedDiff(cwd);
284
284
  const lang = language.trim() || "中文";
285
285
  const prompt = `阅读以下 git diff,用${lang}写一条简洁的 commit message。要求:祈使句,不超过 50 字,描述「做了什么」。只输出 message 本身,不要引号、不要 Markdown 格式、不要任何额外说明。\n\n${diff}`;
286
- const raw = await callClaudeText(prompt, cwd);
286
+ const raw = await callClaudeText(prompt, cwd, language);
287
287
  const message = raw.replace(/^["'`]+|["'`]+$/g, "").trim();
288
288
  if (!message) {
289
289
  throw new QuickCommitError("Claude 返回了空的 commit message。", "EMPTY_AI_MESSAGE");
@@ -339,7 +339,7 @@ async function generateCommitMessageWithTag(cwd, language) {
339
339
 
340
340
  git diff:
341
341
  ${diff}`;
342
- const raw = await callClaudeText(prompt, cwd);
342
+ const raw = await callClaudeText(prompt, cwd, language);
343
343
  const parsed = tryParseJson(raw);
344
344
  let message;
345
345
  let suggestedTag;
@@ -413,7 +413,7 @@ commit message:${commitMessage}
413
413
 
414
414
  git diff:
415
415
  ${diff}`;
416
- const raw = await callClaudeText(prompt, cwd);
416
+ const raw = await callClaudeText(prompt, cwd, language);
417
417
  const parsed = tryParseJson(raw);
418
418
  let suggested;
419
419
  if (parsed && typeof parsed.tag === "string") {
@@ -0,0 +1,17 @@
1
+ /**
2
+ * 强语言指令生成器:供 PTY runner、structured-session-manager(CLI + SDK 两个分支)、
3
+ * 一次性 SDK 调用(claude-sdk-runner.ts)共用。
4
+ *
5
+ * 原本各 runner 散落写 "请使用中文回复" 这种软指令,Claude 写技术内容时还是会条件
6
+ * 反射切英文("Now let me ..."、"OK, ..."),用户设置中文也照样夹英文。
7
+ *
8
+ * 这一版用三招把约束做硬:
9
+ * 1. 明确禁止常见英文起句模式("Now let me"、"Let me check"、"OK,"、"First,"
10
+ * …)并给出反例——给 Claude 一组具体可识别可避免的字串
11
+ * 2. 区分"自然语言"(必须目标语言)和"技术标识符"(路径/命令/API 名可保留原文),
12
+ * 避免 Claude 误以为"代码也要翻译"
13
+ * 3. 自检指令:让 Claude 发现自己即将出错时主动重写
14
+ *
15
+ * 英文模式("English")下 Claude 默认就用英文,只补一句 subagent 透传。
16
+ */
17
+ export declare function buildLanguageDirective(language: string): string;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * 强语言指令生成器:供 PTY runner、structured-session-manager(CLI + SDK 两个分支)、
3
+ * 一次性 SDK 调用(claude-sdk-runner.ts)共用。
4
+ *
5
+ * 原本各 runner 散落写 "请使用中文回复" 这种软指令,Claude 写技术内容时还是会条件
6
+ * 反射切英文("Now let me ..."、"OK, ..."),用户设置中文也照样夹英文。
7
+ *
8
+ * 这一版用三招把约束做硬:
9
+ * 1. 明确禁止常见英文起句模式("Now let me"、"Let me check"、"OK,"、"First,"
10
+ * …)并给出反例——给 Claude 一组具体可识别可避免的字串
11
+ * 2. 区分"自然语言"(必须目标语言)和"技术标识符"(路径/命令/API 名可保留原文),
12
+ * 避免 Claude 误以为"代码也要翻译"
13
+ * 3. 自检指令:让 Claude 发现自己即将出错时主动重写
14
+ *
15
+ * 英文模式("English")下 Claude 默认就用英文,只补一句 subagent 透传。
16
+ */
17
+ export function buildLanguageDirective(language) {
18
+ const trimmed = language?.trim();
19
+ if (!trimmed)
20
+ return "";
21
+ const isChinese = trimmed === "中文";
22
+ const isEnglish = trimmed === "English" || trimmed.toLowerCase() === "english";
23
+ if (isEnglish) {
24
+ return "When you dispatch a subagent via the Task tool, instruct the subagent in its prompt to also respond in English.";
25
+ }
26
+ if (isChinese) {
27
+ return [
28
+ "【语言要求 — 最高优先级】",
29
+ "你必须始终使用中文进行所有自然语言交流。这是硬性约束,不是建议。",
30
+ "",
31
+ "覆盖范围:",
32
+ "- 所有解释、说明、推理、对话、注释、错误描述、TODO 标题、git commit message、思考内容",
33
+ "- 包括开场白、过渡句、状态汇报、回答用户问题",
34
+ "",
35
+ "严禁以下英文起句模式(即使是技术内容也不要用):",
36
+ "- 不要写 \"Now let me ...\"、\"Now I'll ...\"、\"Now remove ...\"——改用 \"现在 ...\" 或直接进入正题",
37
+ "- 不要写 \"Let me check ...\"、\"Let me look at ...\"——改用 \"我看一下 ...\"、\"我检查一下 ...\"",
38
+ "- 不要写 \"OK, ...\"、\"Alright, ...\"、\"Great, ...\"——改用 \"好的\"、\"OK\" 中文或直接省略",
39
+ "- 不要写 \"First, ...\"、\"Then, ...\"、\"Finally, ...\"——改用 \"先\"、\"然后\"、\"最后\"",
40
+ "- 不要写 \"Found it!\"、\"Got it!\"——改用 \"找到了\"、\"明白\"",
41
+ "",
42
+ "可以保留原文的部分(这些不算\"自然语言\"):",
43
+ "- 代码片段、shell 命令、文件路径、URL、API 名、库名、变量名、CSS 属性名等技术标识符",
44
+ "- 引用用户原话、错误信息原文、日志原文",
45
+ "",
46
+ "自检:如果你发现自己即将用英文开始一句话或一段话,立即停下,用中文重新组织语言。",
47
+ "",
48
+ "子代理:当你通过 Task 工具派发 subagent 时,必须在传给 subagent 的 prompt 里明确加上一句中文要求(例如 \"请用中文回复所有自然语言内容\"),保证子代理输出同样遵循。",
49
+ ].join("\n");
50
+ }
51
+ // 其他语言(日语、法语等)——用英文模板,把 language 替进去
52
+ return [
53
+ `[Language requirement — top priority]`,
54
+ `You MUST always use ${trimmed} for all natural-language communication. This is a hard constraint, not a suggestion.`,
55
+ "",
56
+ `Scope: all explanations, narration, reasoning, conversation, comments, error descriptions, TODO titles, git commit messages, and thinking content — including opening phrases, transitions, status updates, and answers to the user.`,
57
+ "",
58
+ `Strictly avoid starting sentences in English (e.g. "Now let me ...", "Let me check ...", "OK, ...", "First, ...", "Found it!"). Use the equivalent ${trimmed} phrasing instead, or skip the transition.`,
59
+ "",
60
+ `What may stay in its original form (these are NOT natural language):`,
61
+ `- Code, shell commands, file paths, URLs, API/library/variable names, CSS properties, other technical identifiers`,
62
+ `- Direct quotes of user input, raw error messages, raw log lines`,
63
+ "",
64
+ `Self-check: if you notice you are about to start a sentence in English, stop and rewrite it in ${trimmed}.`,
65
+ "",
66
+ `Subagent: when you dispatch a subagent via the Task tool, you MUST explicitly instruct the subagent in its prompt to also respond in ${trimmed}.`,
67
+ ].join("\n");
68
+ }
@@ -24,6 +24,18 @@ export interface ClaudeHistorySession {
24
24
  hasConversation: boolean;
25
25
  managedByWand: boolean;
26
26
  }
27
+ /** A Codex session discovered by scanning ~/.codex/sessions/ rollout files. */
28
+ export interface CodexHistorySession {
29
+ /** Codex thread id(存进 claudeSessionId 字段以复用前端/路由)。 */
30
+ claudeSessionId: string;
31
+ cwd: string;
32
+ firstUserMessage: string;
33
+ timestamp: string;
34
+ mtimeMs: number;
35
+ hasConversation: boolean;
36
+ managedByWand: boolean;
37
+ provider: "codex";
38
+ }
27
39
  export declare class ProcessManager extends EventEmitter {
28
40
  private readonly config;
29
41
  private readonly storage;
@@ -65,6 +77,10 @@ export declare class ProcessManager extends EventEmitter {
65
77
  claudeSessionId: string;
66
78
  cwd: string;
67
79
  }[]): number;
80
+ private codexHistoryCache;
81
+ listCodexHistorySessions(): CodexHistorySession[];
82
+ hasCodexSessionFile(threadId: string): boolean;
83
+ deleteCodexHistoryFiles(threadIds: string[]): number;
68
84
  get(id: string): SessionSnapshot | null;
69
85
  getPtyTranscript(id: string): string | null;
70
86
  /**
@@ -10,6 +10,7 @@ import { ClaudePtyBridge } from "./claude-pty-bridge.js";
10
10
  import { truncateMessagesForTransport } from "./message-truncator.js";
11
11
  import { appendWindow, hasExplicitConfirmSyntax, hasPermissionActionContext, normalizePromptText, PTY_OUTPUT_MAX_SIZE } from "./pty-text-utils.js";
12
12
  import { buildChildEnv } from "./env-utils.js";
13
+ import { buildLanguageDirective } from "./language-prompt.js";
13
14
  import { prepareSessionWorktree } from "./git-worktree.js";
14
15
  import { getResumeCommandSessionId } from "./resume-policy.js";
15
16
  import { applyThinkingEffortToPrompt, normalizeThinkingEffort } from "./structured-session-manager.js";
@@ -295,6 +296,150 @@ function listAllClaudeHistorySessions() {
295
296
  return [];
296
297
  }
297
298
  }
299
+ function getCodexSessionsDir() {
300
+ return path.join(os.homedir(), ".codex", "sessions");
301
+ }
302
+ /**
303
+ * codex 的 user message 里混杂了系统注入(AGENTS.md 指令、<environment_context> 等
304
+ * XML 包裹块),它们都以 "#" 或 "<" 开头。真正的用户输入是首条不以这两者开头的
305
+ * input_text。
306
+ */
307
+ function isCodexSystemInjectedText(text) {
308
+ const trimmed = text.trimStart();
309
+ return trimmed.startsWith("#") || trimmed.startsWith("<");
310
+ }
311
+ /**
312
+ * Read the head of a rollout file to extract summary metadata. Codex prepends a
313
+ * large session_meta (full base_instructions + AGENTS.md + <environment_context>)
314
+ * before the first real user turn, so a small window misses it. 64KB covers the
315
+ * first real user message in every observed session.
316
+ */
317
+ function readCodexSessionSummary(filePath) {
318
+ try {
319
+ const stats = statSync(filePath);
320
+ const fd = openSync(filePath, "r");
321
+ const buffer = Buffer.alloc(65536);
322
+ const bytesRead = readSync(fd, buffer, 0, 65536, 0);
323
+ closeSync(fd);
324
+ const chunk = buffer.toString("utf8", 0, bytesRead);
325
+ const lines = chunk.split("\n").filter((line) => line.trim().length > 0);
326
+ let id = "";
327
+ let cwd = "";
328
+ let timestamp = "";
329
+ let firstUserMessage = "";
330
+ let hasUser = false;
331
+ let hasAssistant = false;
332
+ for (const line of lines) {
333
+ let parsed;
334
+ try {
335
+ parsed = JSON.parse(line);
336
+ }
337
+ catch {
338
+ continue;
339
+ }
340
+ if (!timestamp && parsed.timestamp) {
341
+ timestamp = parsed.timestamp;
342
+ }
343
+ const payload = parsed.payload;
344
+ if (!payload)
345
+ continue;
346
+ if (parsed.type === "session_meta" || payload.type === "session_meta") {
347
+ if (!id && typeof payload.id === "string")
348
+ id = payload.id;
349
+ if (!cwd && typeof payload.cwd === "string")
350
+ cwd = payload.cwd;
351
+ continue;
352
+ }
353
+ if (payload.type === "message" && payload.role === "user") {
354
+ const text = Array.isArray(payload.content)
355
+ ? payload.content
356
+ .filter((b) => b?.type === "input_text" && typeof b.text === "string")
357
+ .map((b) => b.text)
358
+ .join("")
359
+ : "";
360
+ if (text.trim()) {
361
+ hasUser = true;
362
+ if (!firstUserMessage && !isCodexSystemInjectedText(text)) {
363
+ firstUserMessage = text.trim().slice(0, 120);
364
+ }
365
+ }
366
+ }
367
+ else if (payload.type === "message" && payload.role === "assistant") {
368
+ hasAssistant = true;
369
+ }
370
+ }
371
+ if (!id)
372
+ return null;
373
+ return {
374
+ claudeSessionId: id,
375
+ cwd,
376
+ firstUserMessage,
377
+ timestamp: timestamp || new Date(stats.mtimeMs).toISOString(),
378
+ mtimeMs: stats.mtimeMs,
379
+ hasConversation: hasUser && hasAssistant,
380
+ managedByWand: false,
381
+ provider: "codex",
382
+ };
383
+ }
384
+ catch {
385
+ return null;
386
+ }
387
+ }
388
+ /** Recursively collect rollout-*.jsonl paths under ~/.codex/sessions/. */
389
+ function listCodexRolloutFiles() {
390
+ const root = getCodexSessionsDir();
391
+ try {
392
+ return readdirSync(root, { recursive: true, withFileTypes: true })
393
+ .filter((entry) => entry.isFile()
394
+ && entry.name.startsWith("rollout-")
395
+ && entry.name.endsWith(".jsonl"))
396
+ .map((entry) => {
397
+ // Node ≥ 20 dirents from a recursive read carry parentPath/path.
398
+ const parent = entry.parentPath
399
+ ?? entry.path
400
+ ?? root;
401
+ return path.join(parent, entry.name);
402
+ });
403
+ }
404
+ catch {
405
+ return [];
406
+ }
407
+ }
408
+ /** Scan ~/.codex/sessions/ and return one entry per thread id (newest rollout wins). */
409
+ function listAllCodexHistorySessions() {
410
+ const files = listCodexRolloutFiles();
411
+ // 同一 thread 同一天可能有多个 rollout 文件,按 thread id 去重保留 mtime 最新。
412
+ const byThread = new Map();
413
+ for (const filePath of files) {
414
+ const summary = readCodexSessionSummary(filePath);
415
+ if (!summary)
416
+ continue;
417
+ const existing = byThread.get(summary.claudeSessionId);
418
+ if (!existing || summary.mtimeMs > existing.mtimeMs) {
419
+ byThread.set(summary.claudeSessionId, summary);
420
+ }
421
+ }
422
+ return Array.from(byThread.values()).sort((a, b) => b.mtimeMs - a.mtimeMs);
423
+ }
424
+ /** Delete every rollout file belonging to the given codex thread ids. */
425
+ function deleteCodexRolloutFiles(threadIds) {
426
+ if (threadIds.size === 0)
427
+ return 0;
428
+ let deleted = 0;
429
+ for (const filePath of listCodexRolloutFiles()) {
430
+ const summary = readCodexSessionSummary(filePath);
431
+ if (summary && threadIds.has(summary.claudeSessionId)) {
432
+ try {
433
+ unlinkSync(filePath);
434
+ deleted++;
435
+ }
436
+ catch {
437
+ // Best-effort — file may already be gone
438
+ }
439
+ }
440
+ }
441
+ return deleted;
442
+ }
298
443
  function snapshotMessages(record) {
299
444
  return record.ptyBridge?.getMessages() ?? record.messages;
300
445
  }
@@ -897,6 +1042,41 @@ export class ProcessManager extends EventEmitter {
897
1042
  }
898
1043
  return deleted;
899
1044
  }
1045
+ codexHistoryCache = null;
1046
+ listCodexHistorySessions() {
1047
+ const now = Date.now();
1048
+ if (this.codexHistoryCache && now < this.codexHistoryCache.expiresAt) {
1049
+ return this.codexHistoryCache.data;
1050
+ }
1051
+ const allSessions = listAllCodexHistorySessions();
1052
+ // Cross-reference with wand-managed sessions(codex 的 thread id 存在 claudeSessionId 字段)
1053
+ const managedIds = new Set();
1054
+ for (const record of this.sessions.values()) {
1055
+ if (record.provider === "codex" && record.claudeSessionId) {
1056
+ managedIds.add(record.claudeSessionId);
1057
+ }
1058
+ }
1059
+ for (const session of allSessions) {
1060
+ if (managedIds.has(session.claudeSessionId)) {
1061
+ session.managedByWand = true;
1062
+ }
1063
+ }
1064
+ this.codexHistoryCache = { data: allSessions, expiresAt: now + ProcessManager.HISTORY_CACHE_TTL_MS };
1065
+ return allSessions;
1066
+ }
1067
+ hasCodexSessionFile(threadId) {
1068
+ if (!UUID_V4_PATTERN.test(threadId))
1069
+ return false;
1070
+ return listAllCodexHistorySessions().some((s) => s.claudeSessionId === threadId);
1071
+ }
1072
+ deleteCodexHistoryFiles(threadIds) {
1073
+ const valid = new Set(threadIds.filter((id) => UUID_V4_PATTERN.test(id)));
1074
+ const deleted = deleteCodexRolloutFiles(valid);
1075
+ if (valid.size > 0) {
1076
+ this.codexHistoryCache = null;
1077
+ }
1078
+ return deleted;
1079
+ }
900
1080
  get(id) {
901
1081
  const record = this.sessions.get(id);
902
1082
  if (!record) {
@@ -1657,11 +1837,13 @@ export class ProcessManager extends EventEmitter {
1657
1837
  result += ` --append-system-prompt '${escaped}'`;
1658
1838
  }
1659
1839
  if (language) {
1660
- const langPrompt = isChinese
1661
- ? "请使用中文回复。所有解释、注释和对话文本都使用中文。"
1662
- : `Please respond in ${language}. Use ${language} for all your explanations, comments, and conversational text.`;
1663
- const escaped = langPrompt.replace(/'/g, "'\\''");
1664
- result += ` --append-system-prompt '${escaped}'`;
1840
+ // structured-session-manager.ts 走同一个 buildLanguageDirective,保证 PTY 与
1841
+ // structured 两种 runner 用同一条强约束指令——避免"换个模式 Claude 又开始夹英文"
1842
+ const langPrompt = buildLanguageDirective(language);
1843
+ if (langPrompt) {
1844
+ const escaped = langPrompt.replace(/'/g, "'\\''");
1845
+ result += ` --append-system-prompt '${escaped}'`;
1846
+ }
1665
1847
  }
1666
1848
  return result;
1667
1849
  }
@@ -9,9 +9,9 @@ export class PromptOptimizeError extends Error {
9
9
  this.name = "PromptOptimizeError";
10
10
  }
11
11
  }
12
- async function callClaudeText(prompt, cwd) {
12
+ async function callClaudeText(prompt, cwd, language) {
13
13
  try {
14
- return await runClaudePrint(prompt, { cwd, timeoutMs: CLAUDE_TIMEOUT_MS });
14
+ return await runClaudePrint(prompt, { cwd, timeoutMs: CLAUDE_TIMEOUT_MS, language });
15
15
  }
16
16
  catch (error) {
17
17
  if (error instanceof ClaudeRunError) {
@@ -50,7 +50,7 @@ export async function optimizePrompt(rawText, language, cwd) {
50
50
  throw new PromptOptimizeError(`输入过长(${text.length} 字符),请缩短到 ${MAX_INPUT_LENGTH} 以内。`, "INPUT_TOO_LONG");
51
51
  }
52
52
  const prompt = buildOptimizePrompt(text, language);
53
- const raw = await callClaudeText(prompt, cwd);
53
+ const raw = await callClaudeText(prompt, cwd, language);
54
54
  const cleaned = raw
55
55
  .replace(/^```[a-zA-Z]*\n?/, "")
56
56
  .replace(/\n?```$/, "")
package/dist/pwa.js CHANGED
@@ -1,15 +1,45 @@
1
1
  /**
2
2
  * PWA manifest and Service Worker generation.
3
3
  */
4
- import { readFileSync } from "node:fs";
4
+ import { readFileSync, existsSync, statSync } from "node:fs";
5
5
  import { createHash } from "node:crypto";
6
6
  import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
9
  const pkgPath = path.join(__dirname, "..", "package.json");
10
10
  const pkgVersion = JSON.parse(readFileSync(pkgPath, "utf-8")).version ?? "0";
11
- /** Cache version derived from package version only busts on actual releases */
12
- const CACHE_VERSION = createHash("md5").update(pkgVersion).digest("hex").slice(0, 8);
11
+ /** Cache version: package version + content fingerprint.
12
+ *
13
+ * 之前只用 pkgVersion 派生,本地 dev 时同一个 1.36.0 下改了几次 CSS / scripts,
14
+ * SW 的 cache key 都没变 → 旧的 RUNTIME_CACHE 会一直回放老 HTML 给已安装 PWA,
15
+ * 用户表现是"我明明改了 UI,怎么手机上一点变化都没有"。
16
+ * 把内容 mtime 也喂进 hash,dev iterate 时每次磁盘改动都换 cache key,
17
+ * SW activate 会把不匹配前缀的缓存 keys 删掉,从而强制重拉。
18
+ * 正式发版时由于 pkgVersion 也会变,效果叠加,无副作用。
19
+ */
20
+ function buildCacheVersion() {
21
+ const h = createHash("md5").update(pkgVersion);
22
+ const fingerprintTargets = [
23
+ path.join(__dirname, "web-ui", "content", "scripts.js"),
24
+ path.join(__dirname, "web-ui", "content", "styles.css"),
25
+ ];
26
+ for (const p of fingerprintTargets) {
27
+ try {
28
+ if (existsSync(p)) {
29
+ const s = statSync(p);
30
+ h.update(":").update(p).update(":").update(String(s.mtimeMs)).update(":").update(String(s.size));
31
+ }
32
+ }
33
+ catch {
34
+ // best effort — fingerprint 只是为了 bust 缓存,失败就退化成 pkg-only
35
+ }
36
+ }
37
+ return h.digest("hex").slice(0, 8);
38
+ }
39
+ // 不 freeze 进模块加载时——SW JS 是每次请求 generateServiceWorker() 现拼的,
40
+ // 这里也对应每次现算,dev 改 CSS 不用重启进程都能 bust 浏览器/PWA 端缓存。
41
+ // 缓存层会自己做 mtime check(styles.ts 的 mtime 缓存模式同思路),这里直接
42
+ // statSync,磁盘命中本地 fs 几十微秒级,可忽略。
13
43
  export function generatePwaManifest() {
14
44
  return JSON.stringify({
15
45
  id: "/wand",
@@ -44,9 +74,10 @@ export function generatePwaManifest() {
44
74
  });
45
75
  }
46
76
  export function generateServiceWorker() {
77
+ const cacheVersion = buildCacheVersion();
47
78
  return `
48
- const STATIC_CACHE = 'wand-static-${CACHE_VERSION}';
49
- const RUNTIME_CACHE = 'wand-runtime-${CACHE_VERSION}';
79
+ const STATIC_CACHE = 'wand-static-${cacheVersion}';
80
+ const RUNTIME_CACHE = 'wand-runtime-${cacheVersion}';
50
81
  const APP_SHELL = '/';
51
82
  const STATIC_ASSETS = [
52
83
  '/manifest.json',
@@ -699,6 +699,41 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
699
699
  res.status(response.statusCode).json(response.payload);
700
700
  }
701
701
  });
702
+ app.post("/api/codex-sessions/:threadId/resume", express.json(), async (req, res) => {
703
+ const threadId = String(req.params.threadId || "").trim();
704
+ const body = req.body;
705
+ console.log("[WAND] POST /api/codex-sessions/:threadId/resume threadId:", threadId, "cwd:", body.cwd);
706
+ try {
707
+ if (!threadId) {
708
+ res.status(400).json({ error: "Codex 会话 ID 不能为空。" });
709
+ return;
710
+ }
711
+ const history = processes.listCodexHistorySessions().find((s) => s.claudeSessionId === threadId);
712
+ if (!history) {
713
+ res.status(400).json({ error: "对应的 Codex 历史会话不存在,无法恢复。" });
714
+ return;
715
+ }
716
+ const cwd = body.cwd?.trim() || history.cwd;
717
+ if (!cwd) {
718
+ res.status(400).json({ error: "无法确定工作目录 (cwd),无法恢复。" });
719
+ return;
720
+ }
721
+ const newMode = normalizeMode(body.mode, defaultMode);
722
+ const snapshot = structured.createSession({
723
+ cwd,
724
+ mode: newMode,
725
+ provider: "codex",
726
+ runner: "codex-cli-exec",
727
+ worktreeEnabled: body.worktreeEnabled === true,
728
+ claudeSessionId: threadId,
729
+ });
730
+ onSessionCreated?.(cwd);
731
+ res.status(201).json({ resumedClaudeSessionId: threadId, ...snapshot });
732
+ }
733
+ catch (error) {
734
+ res.status(400).json({ error: getErrorMessage(error, "无法按 Codex 会话 ID 恢复会话。") });
735
+ }
736
+ });
702
737
  app.post("/api/sessions/:id/resize", (req, res) => {
703
738
  const body = req.body;
704
739
  try {
@@ -907,4 +942,79 @@ export function registerClaudeHistoryRoutes(app, processes, storage) {
907
942
  res.status(500).json({ error: getErrorMessage(error, "无法批量删除历史会话。") });
908
943
  }
909
944
  });
945
+ // ── Codex history(~/.codex/sessions/ 扫描,对齐 Claude 历史区) ──
946
+ // codex 历史的"恢复"是新建一个结构化 codex 会话并预填 thread id(存进 claudeSessionId
947
+ // 字段),用户发第一条消息时 buildCodexArgs 自动拼 `codex exec ... resume <thread_id>`。
948
+ // hidden 集合与 claude 共用(id 全局唯一,不会冲突)。
949
+ app.get("/api/codex-history", (_req, res) => {
950
+ try {
951
+ const sessions = processes.listCodexHistorySessions();
952
+ const hidden = getHiddenClaudeSessionIds(storage);
953
+ const filtered = hidden.size > 0
954
+ ? sessions.filter((s) => !hidden.has(s.claudeSessionId))
955
+ : sessions;
956
+ res.json(filtered);
957
+ }
958
+ catch (error) {
959
+ res.status(500).json({ error: getErrorMessage(error, "无法扫描 Codex 历史会话。") });
960
+ }
961
+ });
962
+ app.delete("/api/codex-history/:threadId", (req, res) => {
963
+ const threadId = req.params.threadId?.trim();
964
+ if (!threadId) {
965
+ res.status(400).json({ error: "会话 ID 不能为空。" });
966
+ return;
967
+ }
968
+ const exists = processes.listCodexHistorySessions().some((s) => s.claudeSessionId === threadId);
969
+ if (exists) {
970
+ processes.deleteCodexHistoryFiles([threadId]);
971
+ removeFromHiddenClaudeSessionIds(storage, [threadId]);
972
+ }
973
+ else {
974
+ const hidden = getHiddenClaudeSessionIds(storage);
975
+ if (!hidden.has(threadId)) {
976
+ hidden.add(threadId);
977
+ saveHiddenClaudeSessionIds(storage, hidden);
978
+ }
979
+ }
980
+ res.json({ ok: true });
981
+ });
982
+ app.post("/api/codex-history/batch-delete", express.json(), (req, res) => {
983
+ const threadIds = Array.isArray(req.body?.claudeSessionIds)
984
+ ? req.body.claudeSessionIds.filter((value) => typeof value === "string" && value.trim().length > 0)
985
+ : [];
986
+ if (threadIds.length === 0) {
987
+ res.status(400).json({ error: "至少提供一个历史会话 ID。" });
988
+ return;
989
+ }
990
+ try {
991
+ const existing = new Set(processes.listCodexHistorySessions().map((s) => s.claudeSessionId));
992
+ const toDelete = [];
993
+ const toHide = [];
994
+ for (const id of threadIds) {
995
+ if (existing.has(id))
996
+ toDelete.push(id);
997
+ else
998
+ toHide.push(id);
999
+ }
1000
+ const deleted = processes.deleteCodexHistoryFiles(toDelete);
1001
+ removeFromHiddenClaudeSessionIds(storage, toDelete);
1002
+ if (toHide.length > 0) {
1003
+ const hidden = getHiddenClaudeSessionIds(storage);
1004
+ let added = 0;
1005
+ for (const id of toHide) {
1006
+ if (!hidden.has(id)) {
1007
+ hidden.add(id);
1008
+ added++;
1009
+ }
1010
+ }
1011
+ if (added > 0)
1012
+ saveHiddenClaudeSessionIds(storage, hidden);
1013
+ }
1014
+ res.json({ ok: true, deleted: deleted + toHide.length });
1015
+ }
1016
+ catch (error) {
1017
+ res.status(500).json({ error: getErrorMessage(error, "无法批量删除历史会话。") });
1018
+ }
1019
+ });
910
1020
  }
package/dist/server.js CHANGED
@@ -1097,6 +1097,9 @@ export async function startServer(config, configPath) {
1097
1097
  ],
1098
1098
  structuredChatPersona,
1099
1099
  cardDefaults: config.cardDefaults,
1100
+ // 把语言偏好暴露给前端做 UI 文案 i18n。后端原本只用它给 Claude 拼 system prompt,
1101
+ // 前端没收到 → "SUBAGENT" / "Read" 这些 UI label 一直是英文,跟用户设的中文不匹配。
1102
+ language: config.language ?? "",
1100
1103
  updateAvailable: cachedUpdateInfo?.updateAvailable ?? false,
1101
1104
  latestVersion: cachedUpdateInfo?.latest ?? null,
1102
1105
  currentVersion: PKG_VERSION,