@co0ontty/wand 2.4.2 → 2.4.3-beta.gdd13483

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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "618521bbe5e23601be39a6599b110d008f469b01",
3
- "builtAt": "2026-07-04T11:34:01.260Z",
4
- "version": "2.4.2",
5
- "channel": "stable"
2
+ "commit": "dd13483272dbdc30919ef5f8db1a4cc97a718666",
3
+ "builtAt": "2026-07-10T02:23:43.798Z",
4
+ "version": "2.4.3-beta.gdd13483",
5
+ "channel": "beta"
6
6
  }
@@ -11,7 +11,7 @@ interface QuickCommitOptions {
11
11
  autoMessage: boolean;
12
12
  customMessage?: string;
13
13
  tag?: string;
14
- /** When `tag` is empty, ask Claude to generate one based on the diff + commit message. */
14
+ /** When `tag` is empty, ask the session provider to generate one based on the diff + commit message. */
15
15
  autoTag?: boolean;
16
16
  push?: boolean;
17
17
  /**
@@ -39,7 +39,11 @@ export declare function generateCommitMessageOnly(cwd: string, language: string,
39
39
  interface TagHeadOptions {
40
40
  cwd: string;
41
41
  language: string;
42
- /** Explicit tag name. If empty and `autoTag` is true, ask Claude to generate one. */
42
+ provider?: SessionProvider;
43
+ model?: string | null;
44
+ thinkingEffort?: SessionSnapshot["thinkingEffort"];
45
+ inheritEnv?: boolean;
46
+ /** Explicit tag name. If empty and `autoTag` is true, ask the session provider to generate one. */
43
47
  tag?: string;
44
48
  autoTag?: boolean;
45
49
  /** Push only this tag to its upstream remote after creating it. */
@@ -492,7 +492,7 @@ export async function generateCommitMessageOnly(cwd, language, ai = {}) {
492
492
  return generateCommitMessageWithTag(cwd, language, ai);
493
493
  }
494
494
  /**
495
- * Ask Claude for a single tag string. Called from `runQuickCommit` after the commit has
495
+ * Ask the session provider for a single tag string. Called from `runQuickCommit` after the commit has
496
496
  * already landed, so we look at `git show HEAD` and use `HEAD~1` for the previous tag.
497
497
  */
498
498
  async function generateTagAfterCommit(cwd, language, commitMessage, ai = {}) {
@@ -613,7 +613,12 @@ export async function runTagHead(opts) {
613
613
  catch {
614
614
  headSubject = "";
615
615
  }
616
- tagName = await generateTagAfterCommit(cwd, language, headSubject || "");
616
+ tagName = await generateTagAfterCommit(cwd, language, headSubject || "", {
617
+ provider: opts.provider,
618
+ model: opts.model,
619
+ thinkingEffort: opts.thinkingEffort,
620
+ inheritEnv: opts.inheritEnv,
621
+ });
617
622
  }
618
623
  if (!tagName) {
619
624
  throw new QuickCommitError("请填写 tag 名称,或开启 AI 生成。", "EMPTY_TAG");
@@ -1096,7 +1101,7 @@ export async function runQuickCommit(opts) {
1096
1101
  catch {
1097
1102
  commitHash = "";
1098
1103
  }
1099
- // Tag: explicit `tag` wins; if empty + autoTag, ask Claude; otherwise skip.
1104
+ // Tag: explicit `tag` wins; if empty + autoTag, ask the session provider; otherwise skip.
1100
1105
  let tagName = (tag || "").trim();
1101
1106
  if (!tagName && autoTag) {
1102
1107
  tagName = await generateTagAfterCommit(cwd, language, message, ai);
@@ -58,8 +58,9 @@ export declare function repairRuntimePath(): PathRepairResult;
58
58
  *
59
59
  * 行为:
60
60
  * - 跑 `${shell} -l -c '...'` 拉 $PATH 和 command -v claude/codex(4s 超时)
61
- * - login shell PATH 里我们还没收录的目录 **前插** 到 process.env.PATH(注意
62
- * 是前插,不是追加 —— 它比 unit 里的更可信)
61
+ * - login shell PATH 顺序重排 process.env.PATH,再把仅存在于 service 的
62
+ * 目录追加回去。不能只前插新增目录:同一个 CLI 同时装在 nvm 和 Homebrew 时,
63
+ * 两个目录本来都存在,旧实现不会重排,structured 与 PTY 会命中不同版本。
63
64
  * - 失败时静默走同步那一版结果
64
65
  *
65
66
  * 接受一个已经跑过同步阶段的 result,在上面 mutate。
@@ -153,8 +153,9 @@ export function repairRuntimePath() {
153
153
  *
154
154
  * 行为:
155
155
  * - 跑 `${shell} -l -c '...'` 拉 $PATH 和 command -v claude/codex(4s 超时)
156
- * - login shell PATH 里我们还没收录的目录 **前插** 到 process.env.PATH(注意
157
- * 是前插,不是追加 —— 它比 unit 里的更可信)
156
+ * - login shell PATH 顺序重排 process.env.PATH,再把仅存在于 service 的
157
+ * 目录追加回去。不能只前插新增目录:同一个 CLI 同时装在 nvm 和 Homebrew 时,
158
+ * 两个目录本来都存在,旧实现不会重排,structured 与 PTY 会命中不同版本。
158
159
  * - 失败时静默走同步那一版结果
159
160
  *
160
161
  * 接受一个已经跑过同步阶段的 result,在上面 mutate。
@@ -184,10 +185,11 @@ export async function deepRepairRuntimePath(result, opts = {}) {
184
185
  return result;
185
186
  }
186
187
  const delim = path.delimiter;
187
- const existing = new Set((process.env.PATH ?? "")
188
+ const currentSegments = (process.env.PATH ?? "")
188
189
  .split(delim)
189
190
  .map((seg) => seg.trim())
190
- .filter((seg) => seg.length > 0));
191
+ .filter((seg) => seg.length > 0);
192
+ const existing = new Set(currentSegments);
191
193
  // login shell 报告的所有 PATH 段 + claude/codex 解析出的目录都纳入候选。
192
194
  const fromShell = [];
193
195
  for (const seg of probe.path.split(delim).map((s) => s.trim()).filter(Boolean)) {
@@ -197,9 +199,10 @@ export async function deepRepairRuntimePath(result, opts = {}) {
197
199
  fromShell.push(path.dirname(probe.claude));
198
200
  if (probe.codex)
199
201
  fromShell.push(path.dirname(probe.codex));
200
- const additions = [];
202
+ const preferred = [];
203
+ const preferredSet = new Set();
201
204
  for (const dir of fromShell) {
202
- if (!dir || existing.has(dir))
205
+ if (!dir || preferredSet.has(dir))
203
206
  continue;
204
207
  let ok = false;
205
208
  try {
@@ -210,19 +213,22 @@ export async function deepRepairRuntimePath(result, opts = {}) {
210
213
  }
211
214
  if (!ok)
212
215
  continue;
213
- existing.add(dir);
214
- additions.push(dir);
216
+ preferredSet.add(dir);
217
+ preferred.push(dir);
218
+ }
219
+ const additions = preferred.filter((dir) => !existing.has(dir));
220
+ const serviceOnly = currentSegments.filter((dir) => !preferredSet.has(dir));
221
+ const reordered = [...preferred, ...serviceOnly];
222
+ const nextPath = reordered.join(delim);
223
+ if (nextPath && nextPath !== (process.env.PATH ?? "")) {
224
+ process.env.PATH = nextPath;
215
225
  }
216
226
  if (additions.length > 0) {
217
- // 前插:login shell 的 PATH 优先级比 unit 写死的高,让 claude 解析到用户期望的版本。
218
- const prefix = additions.join(delim);
219
- const currentPath = process.env.PATH ?? "";
220
- process.env.PATH = currentPath ? `${prefix}${delim}${currentPath}` : prefix;
221
227
  result.added.push(...additions);
222
228
  }
223
229
  result.finalPath = process.env.PATH ?? "";
224
- // 修复完再 probe 一次,让 resolved 字段反映最终能找到的位置(之前 sync 阶段
225
- // 可能 claude 还是 missing,login shell 注入 nvm 目录后这次能命中)。
230
+ // 修复完再 probe 一次,让 resolved 字段反映最终能找到的位置;目录即使原本已在
231
+ // service PATH 中,也可能因本次重排而从旧的 nvm 副本切换到 Homebrew 副本。
226
232
  result.resolved = probeCommands();
227
233
  result.deepProbe = "success";
228
234
  return result;
@@ -85,6 +85,7 @@ export declare class ProcessManager extends EventEmitter {
85
85
  hasCodexSessionFile(threadId: string): boolean;
86
86
  deleteCodexHistoryFiles(threadIds: string[]): number;
87
87
  private captureCodexSessionId;
88
+ private captureClaudeSessionId;
88
89
  get(id: string): SessionSnapshot | null;
89
90
  getPtyTranscript(id: string): string | null;
90
91
  /**
@@ -44,6 +44,7 @@ function readClaudeProjectSessionDetails(filePath, id) {
44
44
  const fileSessionIds = new Set();
45
45
  let hasAssistant = false;
46
46
  let hasUser = false;
47
+ let firstUserAtMs = null;
47
48
  for (const line of lines) {
48
49
  try {
49
50
  const parsed = JSON.parse(line);
@@ -52,6 +53,11 @@ function readClaudeProjectSessionDetails(filePath, id) {
52
53
  }
53
54
  if (parsed.type === "user" || parsed.message?.role === "user") {
54
55
  hasUser = true;
56
+ if (firstUserAtMs === null && parsed.timestamp) {
57
+ const parsedTime = Date.parse(parsed.timestamp);
58
+ if (Number.isFinite(parsedTime))
59
+ firstUserAtMs = parsedTime;
60
+ }
55
61
  }
56
62
  if (parsed.type === "assistant" || parsed.message?.role === "assistant") {
57
63
  hasAssistant = true;
@@ -75,7 +81,8 @@ function readClaudeProjectSessionDetails(filePath, id) {
75
81
  id,
76
82
  filePath,
77
83
  mtimeMs: stats.mtimeMs,
78
- hasConversation: hasUser && hasAssistant && lines.length >= REAL_CONVERSATION_MIN_LINES
84
+ hasConversation: hasUser && hasAssistant && lines.length >= REAL_CONVERSATION_MIN_LINES,
85
+ firstUserAtMs,
79
86
  };
80
87
  }
81
88
  catch {
@@ -150,6 +157,30 @@ function selectClaudeProjectSessionForRecord(record) {
150
157
  function getLatestClaudeProjectSessionId(record) {
151
158
  return selectClaudeProjectSessionForRecord(record)?.id ?? null;
152
159
  }
160
+ function selectClaudeProjectSessionForTimeWindow(record) {
161
+ const startedAtMs = parseTimeMs(record.startedAt);
162
+ if (startedAtMs === null)
163
+ return null;
164
+ const endedAtMs = parseTimeMs(record.endedAt) ?? Date.now();
165
+ const windowStart = startedAtMs - START_TIME_SKEW_MS;
166
+ const windowEnd = endedAtMs + START_TIME_SKEW_MS;
167
+ const fallbackWindowEnd = endedAtMs + DISCOVERY_RECENT_WINDOW_MS;
168
+ const candidates = listClaudeProjectSessionCandidates(record.cwd)
169
+ .map((candidate) => readClaudeProjectSessionDetails(candidate.filePath, candidate.id))
170
+ .filter((candidate) => Boolean(candidate?.hasConversation))
171
+ .filter((candidate) => {
172
+ if (candidate.firstUserAtMs !== null) {
173
+ return candidate.firstUserAtMs >= windowStart && candidate.firstUserAtMs <= windowEnd;
174
+ }
175
+ return candidate.mtimeMs >= windowStart && candidate.mtimeMs <= fallbackWindowEnd;
176
+ })
177
+ .sort((a, b) => {
178
+ const aTime = a.firstUserAtMs ?? a.mtimeMs;
179
+ const bTime = b.firstUserAtMs ?? b.mtimeMs;
180
+ return Math.abs(aTime - startedAtMs) - Math.abs(bTime - startedAtMs);
181
+ });
182
+ return candidates.length === 1 ? candidates[0] : null;
183
+ }
153
184
  function listRecentClaudeProjectSessionIds(cwd, startedAt) {
154
185
  return listClaudeProjectSessionCandidates(cwd)
155
186
  .filter((candidate) => hasRecentProjectActivity(candidate, startedAt))
@@ -504,6 +535,12 @@ function recoverCodexSessionIdFromHistory(snapshot) {
504
535
  }
505
536
  return getCodexResumeCommandSessionId(snapshot.command) ?? selectCodexSessionForTimeWindow(snapshot)?.claudeSessionId ?? null;
506
537
  }
538
+ function recoverClaudeSessionIdFromHistory(snapshot) {
539
+ if (snapshot.provider !== "claude" || snapshot.claudeSessionId) {
540
+ return null;
541
+ }
542
+ return getResumeCommandSessionId(snapshot.command) ?? selectClaudeProjectSessionForTimeWindow(snapshot)?.id ?? null;
543
+ }
507
544
  /** Delete every rollout file belonging to the given codex thread ids. */
508
545
  function deleteCodexRolloutFiles(threadIds) {
509
546
  if (threadIds.size === 0)
@@ -643,13 +680,19 @@ export class ProcessManager extends EventEmitter {
643
680
  ? getCodexResumeCommandSessionId(snapshot.command)
644
681
  : null;
645
682
  const orphanEndedAt = snapshot.status === "running" ? new Date().toISOString() : null;
646
- const sessionIdFromHistory = isCodexCmd
647
- ? recoverCodexSessionIdFromHistory({
683
+ const sessionIdFromHistory = isClaudeCmd
684
+ ? recoverClaudeSessionIdFromHistory({
648
685
  ...snapshot,
649
- provider: "codex",
686
+ provider: "claude",
650
687
  endedAt: snapshot.endedAt ?? orphanEndedAt,
651
688
  })
652
- : null;
689
+ : isCodexCmd
690
+ ? recoverCodexSessionIdFromHistory({
691
+ ...snapshot,
692
+ provider: "codex",
693
+ endedAt: snapshot.endedAt ?? orphanEndedAt,
694
+ })
695
+ : null;
653
696
  const restoredSessionId = resumeCommandSessionId ?? snapshot.claudeSessionId ?? sessionIdFromHistory;
654
697
  // Sessions restored from storage have ptyProcess: null — the old server's PTY
655
698
  // belongs to a dead process. Mark running sessions as exited so the UI
@@ -664,8 +707,9 @@ export class ProcessManager extends EventEmitter {
664
707
  messages: recoveredMessages.length > 0 ? recoveredMessages : snapshot.messages,
665
708
  };
666
709
  this.storage.saveSession(updated);
667
- if (isCodexCmd && restoredSessionId && restoredSessionId !== snapshot.claudeSessionId) {
668
- process.stderr.write(`[wand] Recovered Codex thread ID for orphan PTY ${snapshot.id}: ${restoredSessionId}\n`);
710
+ if (restoredSessionId && restoredSessionId !== snapshot.claudeSessionId) {
711
+ const label = isCodexCmd ? "Codex thread" : "Claude session";
712
+ process.stderr.write(`[wand] Recovered ${label} ID for orphan PTY ${snapshot.id}: ${restoredSessionId}\n`);
669
713
  }
670
714
  this.sessions.set(snapshot.id, {
671
715
  ...updated,
@@ -712,9 +756,8 @@ export class ProcessManager extends EventEmitter {
712
756
  : snapshot;
713
757
  if (updated !== snapshot) {
714
758
  this.storage.saveSessionMetadata(updated);
715
- if (isCodexCmd) {
716
- process.stderr.write(`[wand] Recovered Codex thread ID for saved PTY ${snapshot.id}: ${restoredSessionId}\n`);
717
- }
759
+ const label = isCodexCmd ? "Codex thread" : "Claude session";
760
+ process.stderr.write(`[wand] Recovered ${label} ID for saved PTY ${snapshot.id}: ${restoredSessionId}\n`);
718
761
  }
719
762
  this.sessions.set(snapshot.id, {
720
763
  ...updated,
@@ -995,6 +1038,7 @@ export class ProcessManager extends EventEmitter {
995
1038
  }
996
1039
  current.pendingEscalation = null;
997
1040
  current.ptyPermissionBlocked = false;
1041
+ this.captureClaudeSessionId(current, { allowTimeWindowFallback: true });
998
1042
  this.captureCodexSessionId(current, { allowTimeWindowFallback: true });
999
1043
  current.status = current.stopRequested ? "stopped" : exitCode === 0 ? "exited" : "failed";
1000
1044
  current.exitCode = current.stopRequested ? null : exitCode;
@@ -1268,6 +1312,34 @@ export class ProcessManager extends EventEmitter {
1268
1312
  process.stderr.write(`[wand] Captured Codex thread ID: ${threadId}\n`);
1269
1313
  return true;
1270
1314
  }
1315
+ captureClaudeSessionId(record, options) {
1316
+ if (record.provider !== "claude" || record.claudeSessionId) {
1317
+ return false;
1318
+ }
1319
+ record.messages = snapshotMessages(record);
1320
+ const discoveredSessionId = record.knownClaudeProjectMtimes
1321
+ ? getLatestClaudeProjectSessionId({
1322
+ cwd: record.cwd,
1323
+ startedAt: record.startedAt,
1324
+ knownClaudeProjectMtimes: record.knownClaudeProjectMtimes,
1325
+ messages: record.messages,
1326
+ })
1327
+ : null;
1328
+ const fallbackSessionId = discoveredSessionId
1329
+ ? null
1330
+ : options?.allowTimeWindowFallback
1331
+ ? selectClaudeProjectSessionForTimeWindow(record)?.id ?? null
1332
+ : null;
1333
+ const sessionId = discoveredSessionId ?? fallbackSessionId;
1334
+ if (!sessionId) {
1335
+ return false;
1336
+ }
1337
+ record.claudeSessionId = sessionId;
1338
+ record.knownClaudeProjectMtimes?.set(sessionId, Date.now());
1339
+ this.claudeHistoryCache = null;
1340
+ process.stderr.write(`[wand] Captured Claude session ID: ${sessionId}\n`);
1341
+ return true;
1342
+ }
1271
1343
  get(id) {
1272
1344
  const record = this.sessions.get(id);
1273
1345
  if (!record) {
@@ -1458,12 +1530,14 @@ export class ProcessManager extends EventEmitter {
1458
1530
  record.exitCode = null;
1459
1531
  record.endedAt = new Date().toISOString();
1460
1532
  record.ptyProcess = null;
1533
+ // Update lifecycle before dropping the bridge so Claude project-session
1534
+ // discovery can still inspect the latest parsed turns.
1535
+ this.captureClaudeSessionId(record, { allowTimeWindowFallback: true });
1536
+ this.captureCodexSessionId(record, { allowTimeWindowFallback: true });
1461
1537
  if (record.ptyBridge) {
1462
1538
  record.ptyBridge.removeAllListeners();
1463
1539
  record.ptyBridge = null;
1464
1540
  }
1465
- // Update lifecycle
1466
- this.captureCodexSessionId(record, { allowTimeWindowFallback: true });
1467
1541
  this.persist(record);
1468
1542
  return this.snapshot(record);
1469
1543
  }
@@ -4,6 +4,7 @@ import { getDefaultModelForProvider, normalizeMode } from "./config.js";
4
4
  import { blockWindowMessagesForTransport, sliceTurnBlocksForTransport, truncateMessagesForTransport, windowMessagesForTransport } from "./message-truncator.js";
5
5
  import { checkSessionWorktreeMergeability, cleanupSessionWorktree, getWorktreeMergeErrorCode, mergeSessionWorktree, WorktreeMergeError } from "./git-worktree.js";
6
6
  import { resolveSessionCwd } from "./session-cwd.js";
7
+ import { resolveSessionAiContext } from "./session-ai-context.js";
7
8
  import { getGitStatus, QuickCommitError, runQuickCommitWithFallback, runTagHead, runPush, generateCommitMessageOnly, } from "./git-quick-commit.js";
8
9
  import { getErrorMessage } from "./error-utils.js";
9
10
  export { getErrorMessage };
@@ -537,13 +538,11 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
537
538
  }
538
539
  const body = (req.body ?? {});
539
540
  try {
541
+ const ai = resolveSessionAiContext(snapshot, config);
540
542
  const result = await runQuickCommitWithFallback({
541
543
  cwd: snapshot.cwd,
542
544
  language: config.language ?? "",
543
- provider: snapshot.provider,
544
- model: snapshot.selectedModel ?? snapshot.structuredState?.model ?? getDefaultModelForProvider(config, snapshot.provider),
545
- thinkingEffort: snapshot.thinkingEffort ?? config.defaultThinkingEffort,
546
- inheritEnv: config.inheritEnv,
545
+ ...ai,
547
546
  autoMessage: body.autoMessage !== false,
548
547
  customMessage: typeof body.customMessage === "string" ? body.customMessage : undefined,
549
548
  tag: typeof body.tag === "string" ? body.tag : undefined,
@@ -573,11 +572,9 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
573
572
  return;
574
573
  }
575
574
  try {
575
+ const ai = resolveSessionAiContext(snapshot, config);
576
576
  const result = await generateCommitMessageOnly(snapshot.cwd, config.language ?? "", {
577
- provider: snapshot.provider,
578
- model: snapshot.selectedModel ?? snapshot.structuredState?.model ?? getDefaultModelForProvider(config, snapshot.provider),
579
- thinkingEffort: snapshot.thinkingEffort ?? config.defaultThinkingEffort,
580
- inheritEnv: config.inheritEnv,
577
+ ...ai,
581
578
  });
582
579
  res.json(result);
583
580
  }
@@ -601,9 +598,11 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
601
598
  }
602
599
  const body = (req.body ?? {});
603
600
  try {
601
+ const ai = resolveSessionAiContext(snapshot, config);
604
602
  const result = await runTagHead({
605
603
  cwd: snapshot.cwd,
606
604
  language: config.language ?? "",
605
+ ...ai,
607
606
  tag: typeof body.tag === "string" ? body.tag : undefined,
608
607
  autoTag: !!body.autoTag,
609
608
  push: !!body.push,
package/dist/server.js CHANGED
@@ -142,14 +142,58 @@ async function fetchGitHubLatestApk(forceRefresh = false) {
142
142
  function parseApkChannel(value) {
143
143
  return value === "beta" ? "beta" : "stable";
144
144
  }
145
+ function asRecord(value) {
146
+ return value && typeof value === "object" ? value : null;
147
+ }
148
+ async function refreshDistributionConfig(configPath, config) {
149
+ let raw;
150
+ try {
151
+ raw = JSON.parse(await readFile(configPath, "utf8"));
152
+ }
153
+ catch {
154
+ return;
155
+ }
156
+ const android = asRecord(raw.android);
157
+ if (android) {
158
+ config.android = { ...(config.android ?? {}) };
159
+ if (typeof android.enabled === "boolean")
160
+ config.android.enabled = android.enabled;
161
+ if (Object.prototype.hasOwnProperty.call(android, "apkDir")) {
162
+ config.android.apkDir = typeof android.apkDir === "string" && android.apkDir.trim()
163
+ ? android.apkDir.trim()
164
+ : "android";
165
+ }
166
+ if (Object.prototype.hasOwnProperty.call(android, "currentApkFile")) {
167
+ config.android.currentApkFile = typeof android.currentApkFile === "string"
168
+ ? android.currentApkFile.trim()
169
+ : "";
170
+ }
171
+ }
172
+ const macos = asRecord(raw.macos);
173
+ if (macos) {
174
+ config.macos = { ...(config.macos ?? {}) };
175
+ if (typeof macos.enabled === "boolean")
176
+ config.macos.enabled = macos.enabled;
177
+ if (Object.prototype.hasOwnProperty.call(macos, "dmgDir")) {
178
+ config.macos.dmgDir = typeof macos.dmgDir === "string" && macos.dmgDir.trim()
179
+ ? macos.dmgDir.trim()
180
+ : "macos";
181
+ }
182
+ if (Object.prototype.hasOwnProperty.call(macos, "currentDmgFile")) {
183
+ config.macos.currentDmgFile = typeof macos.currentDmgFile === "string"
184
+ ? macos.currentDmgFile.trim()
185
+ : "";
186
+ }
187
+ }
188
+ }
145
189
  /** 版本号带 prerelease 后缀(如 -debug.06121811)即视为 beta 构建。 */
146
190
  function isPrereleaseApkVersion(version) {
147
191
  return !!version && version.includes("-");
148
192
  }
149
- async function resolveLatestApkVersion(configDir, config, channel) {
193
+ async function resolveLatestApkVersion(configDir, config, channel, configPath) {
150
194
  // local 与 github 两个来源都看,按安装序取真正更新的那个(持平偏向 local:同源下载更快)。
151
195
  // 旧逻辑是「local 存在就一票否决」——本地目录留着旧包时,会把线上新版压住不提示。
152
- const localApk = await resolveAndroidApkAsset(configDir, config, channel);
196
+ const localApk = await resolveAndroidApkAsset(configDir, config, channel, configPath);
153
197
  const local = localApk && localApk.version
154
198
  ? {
155
199
  version: localApk.version,
@@ -212,8 +256,8 @@ async function fetchGitHubLatestDmg(forceRefresh = false) {
212
256
  return cachedGitHubDmg ?? null;
213
257
  }
214
258
  }
215
- async function resolveLatestDmgVersion(configDir, config) {
216
- const localDmg = await resolveMacosDmgAsset(configDir, config);
259
+ async function resolveLatestDmgVersion(configDir, config, configPath) {
260
+ const localDmg = await resolveMacosDmgAsset(configDir, config, configPath);
217
261
  if (localDmg && localDmg.version) {
218
262
  return {
219
263
  version: localDmg.version,
@@ -585,13 +629,18 @@ function resolveAndroidApkDir(configDir, config) {
585
629
  function extractAndroidApkVersion(fileName) {
586
630
  return extractSemver(fileName.replace(/\.apk$/i, ""));
587
631
  }
588
- async function resolveAndroidApkAsset(configDir, config, channel = "beta") {
632
+ async function resolveAndroidApkAsset(configDir, config, channel = "beta", configPath) {
633
+ if (configPath)
634
+ await refreshDistributionConfig(configPath, config);
589
635
  if (config.android?.enabled !== true)
590
636
  return null;
591
637
  const apkDir = resolveAndroidApkDir(configDir, config);
592
638
  await mkdir(apkDir, { recursive: true });
593
639
  const configuredFile = config.android?.currentApkFile?.trim();
594
- if (configuredFile) {
640
+ // Beta is the local development channel: every check should pick the newest
641
+ // APK in apkDir, so dropping a new debug build into the directory is enough.
642
+ // currentApkFile remains a stable/manual pin and backward-compatible fallback.
643
+ if (configuredFile && channel !== "beta") {
595
644
  const filePath = path.join(apkDir, path.basename(configuredFile));
596
645
  try {
597
646
  const fileStat = await stat(filePath);
@@ -676,7 +725,9 @@ function resolveMacosDmgDir(configDir, config) {
676
725
  function extractMacosDmgVersion(fileName) {
677
726
  return extractSemver(fileName.replace(/\.dmg$/i, ""));
678
727
  }
679
- async function resolveMacosDmgAsset(configDir, config) {
728
+ async function resolveMacosDmgAsset(configDir, config, configPath) {
729
+ if (configPath)
730
+ await refreshDistributionConfig(configPath, config);
680
731
  if (config.macos?.enabled !== true)
681
732
  return null;
682
733
  const dmgDir = resolveMacosDmgDir(configDir, config);
@@ -1214,7 +1265,7 @@ export async function startServer(config, configPath) {
1214
1265
  }
1215
1266
  // 更新通道:beta 包含 -debug.* 构建,stable(默认,含不传参的老客户端)只推正式版。
1216
1267
  const channel = parseApkChannel(req.query.channel);
1217
- const latest = await resolveLatestApkVersion(configDir, config, channel);
1268
+ const latest = await resolveLatestApkVersion(configDir, config, channel, configPath);
1218
1269
  if (!latest) {
1219
1270
  res.json({ updateAvailable: false, currentVersion, latestVersion: null, downloadUrl: null, source: null, channel });
1220
1271
  return;
@@ -1235,15 +1286,11 @@ export async function startServer(config, configPath) {
1235
1286
  });
1236
1287
  });
1237
1288
  app.get("/android/download", async (req, res) => {
1238
- if (config.android?.enabled !== true) {
1239
- res.status(404).json({ error: "Android APK 下载未启用。" });
1240
- return;
1241
- }
1242
1289
  // 更新弹窗的下载链接由 /api/android-apk-update 按通道生成(始终带 ?channel=)。
1243
1290
  // 裸 /android/download(网页下载页、二维码落地页)不带参时默认 beta ——
1244
1291
  // 保持「下载页拿到的就是目录里真正最新的包」的旧行为。
1245
1292
  const channel = req.query.channel === "stable" ? "stable" : "beta";
1246
- const androidApk = await resolveAndroidApkAsset(configDir, config, channel);
1293
+ const androidApk = await resolveAndroidApkAsset(configDir, config, channel, configPath);
1247
1294
  if (!androidApk) {
1248
1295
  res.status(404).json({ error: "当前没有可下载的 APK 文件。" });
1249
1296
  return;
@@ -1263,7 +1310,7 @@ export async function startServer(config, configPath) {
1263
1310
  res.status(400).json({ error: "Missing currentVersion query parameter." });
1264
1311
  return;
1265
1312
  }
1266
- const latest = await resolveLatestDmgVersion(configDir, config);
1313
+ const latest = await resolveLatestDmgVersion(configDir, config, configPath);
1267
1314
  if (!latest) {
1268
1315
  res.json({ updateAvailable: false, currentVersion, latestVersion: null, downloadUrl: null, source: null });
1269
1316
  return;
@@ -1280,11 +1327,7 @@ export async function startServer(config, configPath) {
1280
1327
  });
1281
1328
  });
1282
1329
  app.get("/macos/download", async (req, res) => {
1283
- if (config.macos?.enabled !== true) {
1284
- res.status(404).json({ error: "macOS DMG 下载未启用。" });
1285
- return;
1286
- }
1287
- const macosDmg = await resolveMacosDmgAsset(configDir, config);
1330
+ const macosDmg = await resolveMacosDmgAsset(configDir, config, configPath);
1288
1331
  if (!macosDmg) {
1289
1332
  res.status(404).json({ error: "当前没有可下载的 DMG 文件。" });
1290
1333
  return;
@@ -1453,7 +1496,7 @@ export async function startServer(config, configPath) {
1453
1496
  };
1454
1497
  const { password: _pw, ...safeConfig } = config;
1455
1498
  const defaultModels = getProviderDefaultModels(config);
1456
- const localApk = await resolveAndroidApkAsset(configDir, config);
1499
+ const localApk = await resolveAndroidApkAsset(configDir, config, "beta", configPath);
1457
1500
  const ghApk = await fetchGitHubLatestApk();
1458
1501
  const apkDir = resolveAndroidApkDir(configDir, config);
1459
1502
  // Backward-compatible: pick best available for hasApk/version/downloadUrl
@@ -1462,7 +1505,7 @@ export async function startServer(config, configPath) {
1462
1505
  : ghApk
1463
1506
  ? { hasApk: true, fileName: ghApk.fileName, version: ghApk.version, size: ghApk.size, updatedAt: null, downloadUrl: ghApk.downloadUrl, source: "github" }
1464
1507
  : null;
1465
- const localDmg = await resolveMacosDmgAsset(configDir, config);
1508
+ const localDmg = await resolveMacosDmgAsset(configDir, config, configPath);
1466
1509
  const ghDmg = await fetchGitHubLatestDmg();
1467
1510
  const dmgDir = resolveMacosDmgDir(configDir, config);
1468
1511
  const resolvedDmg = localDmg
@@ -1525,7 +1568,7 @@ export async function startServer(config, configPath) {
1525
1568
  });
1526
1569
  });
1527
1570
  app.get("/api/android-apk", async (_req, res) => {
1528
- const localApk = await resolveAndroidApkAsset(configDir, config);
1571
+ const localApk = await resolveAndroidApkAsset(configDir, config, "beta", configPath);
1529
1572
  const ghApk = await fetchGitHubLatestApk();
1530
1573
  const apkDir = resolveAndroidApkDir(configDir, config);
1531
1574
  const resolvedApk = localApk
@@ -1548,7 +1591,7 @@ export async function startServer(config, configPath) {
1548
1591
  });
1549
1592
  });
1550
1593
  app.get("/api/macos-dmg", async (_req, res) => {
1551
- const localDmg = await resolveMacosDmgAsset(configDir, config);
1594
+ const localDmg = await resolveMacosDmgAsset(configDir, config, configPath);
1552
1595
  const ghDmg = await fetchGitHubLatestDmg();
1553
1596
  const dmgDir = resolveMacosDmgDir(configDir, config);
1554
1597
  const resolvedDmg = localDmg
@@ -0,0 +1,15 @@
1
+ import type { SessionProvider, SessionSnapshot, WandConfig } from "./types.js";
2
+ export interface SessionAiContext {
3
+ provider: SessionProvider;
4
+ model?: string;
5
+ thinkingEffort: SessionSnapshot["thinkingEffort"];
6
+ inheritEnv?: boolean;
7
+ }
8
+ /**
9
+ * Resolve the provider from every representation used by current and legacy
10
+ * sessions. Older persisted sessions may not have the top-level provider, but
11
+ * still identify Codex through structuredState, runner, or command.
12
+ */
13
+ export declare function resolveSessionProvider(snapshot: Pick<SessionSnapshot, "provider" | "structuredState" | "runner" | "command">): SessionProvider;
14
+ /** Build the provider-specific settings used by session-adjacent AI actions. */
15
+ export declare function resolveSessionAiContext(snapshot: Pick<SessionSnapshot, "provider" | "structuredState" | "runner" | "command" | "selectedModel" | "thinkingEffort">, config: Pick<WandConfig, "defaultModel" | "defaultCodexModel" | "defaultThinkingEffort" | "inheritEnv">): SessionAiContext;
@@ -0,0 +1,36 @@
1
+ import { getDefaultModelForProvider } from "./config.js";
2
+ /**
3
+ * Resolve the provider from every representation used by current and legacy
4
+ * sessions. Older persisted sessions may not have the top-level provider, but
5
+ * still identify Codex through structuredState, runner, or command.
6
+ */
7
+ export function resolveSessionProvider(snapshot) {
8
+ if (snapshot.provider === "claude" || snapshot.provider === "codex") {
9
+ return snapshot.provider;
10
+ }
11
+ if (snapshot.structuredState?.provider === "claude" || snapshot.structuredState?.provider === "codex") {
12
+ return snapshot.structuredState.provider;
13
+ }
14
+ const runner = snapshot.runner ?? snapshot.structuredState?.runner;
15
+ if (runner === "codex-cli-exec")
16
+ return "codex";
17
+ if (runner === "claude-cli" || runner === "claude-cli-print" || runner === "claude-sdk")
18
+ return "claude";
19
+ return /^codex\b/i.test(snapshot.command.trim()) ? "codex" : "claude";
20
+ }
21
+ function normalizeModel(value) {
22
+ const model = value?.trim();
23
+ return model && model !== "default" ? model : undefined;
24
+ }
25
+ /** Build the provider-specific settings used by session-adjacent AI actions. */
26
+ export function resolveSessionAiContext(snapshot, config) {
27
+ const provider = resolveSessionProvider(snapshot);
28
+ const sessionModel = normalizeModel(snapshot.selectedModel) ?? normalizeModel(snapshot.structuredState?.model);
29
+ const defaultModel = normalizeModel(getDefaultModelForProvider(config, provider));
30
+ return {
31
+ provider,
32
+ model: sessionModel ?? defaultModel,
33
+ thinkingEffort: snapshot.thinkingEffort ?? config.defaultThinkingEffort,
34
+ inheritEnv: config.inheritEnv,
35
+ };
36
+ }
package/dist/storage.js CHANGED
@@ -24,7 +24,12 @@ function inferSessionProvider(row) {
24
24
  if (row.runner === "claude-cli" || row.runner === "claude-cli-print") {
25
25
  return "claude";
26
26
  }
27
- return /^claude\b/.test(row.command.trim()) ? "claude" : undefined;
27
+ if (row.runner === "codex-cli-exec") {
28
+ return "codex";
29
+ }
30
+ if (/^codex\b/i.test(row.command.trim()))
31
+ return "codex";
32
+ return /^claude\b/i.test(row.command.trim()) ? "claude" : undefined;
28
33
  }
29
34
  function parseWorktreeInfo(raw) {
30
35
  const parsed = safeJsonParse(raw);