dskcode 0.1.26 → 0.1.28

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/index.js CHANGED
@@ -771,8 +771,8 @@ async function getAllSkills(cwd) {
771
771
  }
772
772
 
773
773
  // src/cli/index.tsx
774
- import { readFile as readFile10 } from "fs/promises";
775
- import { join as join9 } from "path";
774
+ import { readFile as readFile11 } from "fs/promises";
775
+ import { join as join10 } from "path";
776
776
 
777
777
  // src/ui/RenderScope.tsx
778
778
  import { render } from "ink";
@@ -846,7 +846,15 @@ import { Box as Box5, Text as Text6 } from "ink";
846
846
  // src/provider/cost-tracker.ts
847
847
  import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
848
848
  import { join as join3 } from "path";
849
- var CostTracker = class {
849
+ var CostTracker = class _CostTracker {
850
+ /** 全局活跃实例表,用于进程退出时统一 flush。 */
851
+ static #instances = /* @__PURE__ */ new Set();
852
+ /** 进程退出兜底:统一 flush 所有活跃 CostTracker。 */
853
+ static async flushAll() {
854
+ await Promise.allSettled(
855
+ [..._CostTracker.#instances].map((t) => t.flush())
856
+ );
857
+ }
850
858
  #costDir;
851
859
  #budgetLimit;
852
860
  #tokenBudgetLimit;
@@ -872,6 +880,7 @@ var CostTracker = class {
872
880
  const today = getTodayStr();
873
881
  this.#todayDate = today;
874
882
  this.#todaySummary = createEmptyDailySummary(today);
883
+ _CostTracker.#instances.add(this);
875
884
  }
876
885
  // -----------------------------------------------------------------------
877
886
  // 核心方法
@@ -879,6 +888,7 @@ var CostTracker = class {
879
888
  /**
880
889
  * 记录一次 API 调用的 Token 使用量和成本。
881
890
  * 自动计算费用,累加到会话级和日级统计。
891
+ * 标记脏数据(dirty),由上层在合适的时机调用 flush() 持久化到磁盘。
882
892
  */
883
893
  record(usage, model) {
884
894
  const cost = calculateCost(usage, model);
@@ -987,8 +997,10 @@ var CostTracker = class {
987
997
  /**
988
998
  * 从磁盘加载历史成本数据。
989
999
  * 启动时调用,用于恢复今日和历史数据。
1000
+ * 副作用:确保成本目录存在(首次运行时创建 ~/.dskcode/costs)。
990
1001
  */
991
1002
  async load() {
1003
+ await this.#ensureCostDir();
992
1004
  const store = await this.#loadStore();
993
1005
  const today = getTodayStr();
994
1006
  if (store.daily[today]) {
@@ -998,14 +1010,19 @@ var CostTracker = class {
998
1010
  }
999
1011
  /**
1000
1012
  * 将脏数据持久化到磁盘。
1001
- * 通常在每次 record() 后异步调用,或在会话结束时主动调用。
1013
+ * 会话结束/进程退出时主动调用一次即可。需要每日数据精确时也可在每次
1014
+ * record() 后调用。并发安全:靠 #flushInProgress 互斥。
1002
1015
  */
1003
1016
  async flush() {
1004
1017
  if (!this.#dirty || this.#flushInProgress) return;
1005
1018
  this.#flushInProgress = true;
1006
1019
  this.#dirty = false;
1007
1020
  try {
1021
+ await this.#ensureCostDir();
1008
1022
  await this.#saveStore();
1023
+ } catch (err) {
1024
+ this.#dirty = true;
1025
+ console.error("[CostTracker] flush \u5931\u8D25:", err);
1009
1026
  } finally {
1010
1027
  this.#flushInProgress = false;
1011
1028
  }
@@ -1033,6 +1050,14 @@ var CostTracker = class {
1033
1050
  this.#sessionStartedAt = (/* @__PURE__ */ new Date()).toISOString();
1034
1051
  this.#sessionRecords.length = 0;
1035
1052
  }
1053
+ /**
1054
+ * 销毁实例:从全局实例表移除并 flush。
1055
+ * 一般用不到,仅在测试或显式生命周期管理时使用。
1056
+ */
1057
+ async dispose() {
1058
+ _CostTracker.#instances.delete(this);
1059
+ await this.flush();
1060
+ }
1036
1061
  // -----------------------------------------------------------------------
1037
1062
  // 内部方法
1038
1063
  // -----------------------------------------------------------------------
@@ -1085,6 +1110,10 @@ var CostTracker = class {
1085
1110
  return { version: 1, daily: {} };
1086
1111
  }
1087
1112
  }
1113
+ /** 确保成本目录存在(首次运行时创建 ~/.dskcode/costs) */
1114
+ async #ensureCostDir() {
1115
+ await mkdir3(this.#costDir, { recursive: true });
1116
+ }
1088
1117
  /** 保存成本存储到磁盘 */
1089
1118
  async #saveStore() {
1090
1119
  const store = await this.#loadStore();
@@ -1096,7 +1125,6 @@ var CostTracker = class {
1096
1125
  for (const date of datesToRemove) {
1097
1126
  delete store.daily[date];
1098
1127
  }
1099
- await mkdir3(this.#costDir, { recursive: true });
1100
1128
  const filePath = join3(this.#costDir, "history.json");
1101
1129
  await writeFile2(filePath, JSON.stringify(store, null, 2), "utf-8");
1102
1130
  }
@@ -2086,17 +2114,183 @@ var ToolRegistry = class {
2086
2114
  }
2087
2115
  };
2088
2116
 
2117
+ // src/checkpoint/git-checkpoint.ts
2118
+ import { execFile } from "child_process";
2119
+ import { promisify } from "util";
2120
+ var execFileAsync = promisify(execFile);
2121
+ var EXEC_OPTIONS = { windowsHide: true };
2122
+ async function isGitRepo(cwd) {
2123
+ try {
2124
+ await execFileAsync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, ...EXEC_OPTIONS });
2125
+ return true;
2126
+ } catch {
2127
+ return false;
2128
+ }
2129
+ }
2130
+ async function hasCommits(cwd) {
2131
+ try {
2132
+ await execFileAsync("git", ["rev-parse", "--verify", "HEAD"], { cwd, ...EXEC_OPTIONS });
2133
+ return true;
2134
+ } catch {
2135
+ return false;
2136
+ }
2137
+ }
2138
+ async function hasWorkingChanges(cwd) {
2139
+ const out = await execFileAsync("git", ["status", "--porcelain"], { cwd, ...EXEC_OPTIONS });
2140
+ return out.stdout.trim().length > 0;
2141
+ }
2142
+ async function git(args, cwd) {
2143
+ try {
2144
+ const { stdout } = await execFileAsync("git", args, { cwd, ...EXEC_OPTIONS });
2145
+ return stdout;
2146
+ } catch (err) {
2147
+ const msg = err instanceof Error ? err.message : String(err);
2148
+ throw new Error(`git ${args.join(" ")} \u5931\u8D25: ${msg}`);
2149
+ }
2150
+ }
2151
+ async function listStashShas(cwd) {
2152
+ const out = await git(["stash", "list", "--format=%H"], cwd);
2153
+ return out.split("\n").map((l) => l.trim()).filter(Boolean);
2154
+ }
2155
+ async function createCheckpoint(cwd) {
2156
+ const timestamp = Date.now();
2157
+ const inRepo = await isGitRepo(cwd);
2158
+ if (!inRepo) return { stashSha: "", timestamp, cwd, isGitRepo: false };
2159
+ if (!await hasCommits(cwd)) return { stashSha: "", timestamp, cwd, isGitRepo: true };
2160
+ if (!await hasWorkingChanges(cwd)) return { stashSha: "", timestamp, cwd, isGitRepo: true };
2161
+ const beforeShas = await listStashShas(cwd);
2162
+ await git(["stash", "push", "-m", `dskcode-cp-${timestamp}`, "-u"], cwd);
2163
+ const newShas = await listStashShas(cwd);
2164
+ if (newShas.length === 0) throw new Error("git stash push \u672A\u80FD\u521B\u5EFA stash entry");
2165
+ const newSha = newShas.find((s) => !beforeShas.includes(s)) ?? newShas[0];
2166
+ await git(["stash", "apply", newSha], cwd);
2167
+ return { stashSha: newSha, timestamp, cwd, isGitRepo: true };
2168
+ }
2169
+ async function restoreCheckpointForce(checkpoint) {
2170
+ if (!checkpoint.isGitRepo) throw new Error("\u975E git \u4ED3\u5E93\uFF0C\u65E0\u6CD5\u6062\u590D\u6587\u4EF6\u72B6\u6001");
2171
+ if (!checkpoint.stashSha) throw new Error("\u68C0\u67E5\u70B9\u4E3A\u7A7A\uFF08\u5DE5\u4F5C\u533A\u539F\u672C\u5C31\u5E72\u51C0\uFF09\uFF0C\u65E0\u9700\u6062\u590D");
2172
+ const { cwd, stashSha } = checkpoint;
2173
+ const currentShas = await listStashShas(cwd);
2174
+ if (!currentShas.includes(stashSha)) {
2175
+ throw new Error("\u68C0\u67E5\u70B9\u5DF2\u5931\u6548\uFF08stash entry \u5DF2\u88AB\u6D88\u8D39\u6216 GC\uFF09\uFF0C\u65E0\u6CD5\u6062\u590D");
2176
+ }
2177
+ await git(["checkout", "--", "."], cwd);
2178
+ await git(["clean", "-fd"], cwd);
2179
+ await git(["stash", "apply", stashSha], cwd);
2180
+ const refIndex = currentShas.indexOf(stashSha);
2181
+ if (refIndex >= 0) await git(["stash", "drop", `stash@{${refIndex}}`], cwd);
2182
+ }
2183
+ async function restoreToClean(cwd) {
2184
+ const inRepo = await isGitRepo(cwd);
2185
+ if (!inRepo) throw new Error("\u975E git \u4ED3\u5E93\uFF0C\u65E0\u6CD5\u6062\u590D\u6587\u4EF6\u72B6\u6001");
2186
+ if (!await hasCommits(cwd)) throw new Error("\u4ED3\u5E93\u65E0 commit\uFF0C\u65E0\u6CD5\u6062\u590D");
2187
+ await git(["checkout", "--", "."], cwd);
2188
+ await git(["clean", "-fd"], cwd);
2189
+ }
2190
+ async function discardCheckpoint(checkpoint) {
2191
+ if (!checkpoint.isGitRepo || !checkpoint.stashSha) return;
2192
+ const { cwd, stashSha } = checkpoint;
2193
+ const shas = await listStashShas(cwd);
2194
+ const idx = shas.indexOf(stashSha);
2195
+ if (idx < 0) return;
2196
+ try {
2197
+ await git(["stash", "drop", `stash@{${idx}}`], cwd);
2198
+ } catch {
2199
+ }
2200
+ }
2201
+
2202
+ // src/session-store/session-store.ts
2203
+ import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile3, readdir as readdir2, unlink, rename } from "fs/promises";
2204
+ import { join as join4 } from "path";
2205
+ import { randomUUID } from "crypto";
2206
+ function defaultSessionsDir() {
2207
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "~";
2208
+ return join4(home, ".dskcode", "sessions");
2209
+ }
2210
+ var SessionStore = class {
2211
+ #dir;
2212
+ constructor(dir) {
2213
+ this.#dir = dir ?? defaultSessionsDir();
2214
+ }
2215
+ get dir() {
2216
+ return this.#dir;
2217
+ }
2218
+ async save(session) {
2219
+ await mkdir4(this.#dir, { recursive: true });
2220
+ const finalPath = join4(this.#dir, `${session.id}.json`);
2221
+ const tmpPath = join4(this.#dir, `.${session.id}.json.tmp`);
2222
+ await writeFile3(tmpPath, JSON.stringify(session, null, 2), "utf-8");
2223
+ await rename(tmpPath, finalPath);
2224
+ }
2225
+ async load(id) {
2226
+ const path = join4(this.#dir, `${id}.json`);
2227
+ try {
2228
+ const content = await readFile4(path, "utf-8");
2229
+ return JSON.parse(content);
2230
+ } catch (err) {
2231
+ if (isENOENT(err)) return null;
2232
+ throw err;
2233
+ }
2234
+ }
2235
+ async list() {
2236
+ let files;
2237
+ try {
2238
+ files = await readdir2(this.#dir);
2239
+ } catch (err) {
2240
+ if (isENOENT(err)) return [];
2241
+ throw err;
2242
+ }
2243
+ const results = [];
2244
+ for (const file of files) {
2245
+ if (!file.endsWith(".json") || file.startsWith(".")) continue;
2246
+ try {
2247
+ const content = await readFile4(join4(this.#dir, file), "utf-8");
2248
+ const s = JSON.parse(content);
2249
+ results.push({
2250
+ id: s.id,
2251
+ title: s.title || "\uFF08\u65E0\u6807\u9898\uFF09",
2252
+ updatedAt: s.updatedAt,
2253
+ cwd: s.cwd,
2254
+ messageCount: s.messages?.length ?? 0
2255
+ });
2256
+ } catch {
2257
+ }
2258
+ }
2259
+ return results.sort((a, b) => b.updatedAt - a.updatedAt);
2260
+ }
2261
+ async delete(id) {
2262
+ const path = join4(this.#dir, `${id}.json`);
2263
+ try {
2264
+ await unlink(path);
2265
+ } catch (err) {
2266
+ if (!isENOENT(err)) throw err;
2267
+ }
2268
+ }
2269
+ async exists(id) {
2270
+ return await this.load(id) !== null;
2271
+ }
2272
+ static newId() {
2273
+ return randomUUID();
2274
+ }
2275
+ };
2276
+ function isENOENT(err) {
2277
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
2278
+ }
2279
+
2089
2280
  // src/agent/index.ts
2090
- var Session = class {
2281
+ var Session = class _Session {
2091
2282
  #messages = [];
2092
2283
  #provider;
2093
2284
  #toolRegistry;
2094
2285
  #costTracker;
2095
2286
  #options;
2096
2287
  #abortController = new AbortController();
2097
- // 风暴检测:记录每轮的工具调用错误
2288
+ #sessionId;
2289
+ #store;
2290
+ #createdAt;
2291
+ #persistTimer = null;
2292
+ #checkpoints = /* @__PURE__ */ new Map();
2098
2293
  #stormRecords = [];
2099
- /** 当前会话模式:code(代码模式)或 plan(计划模式) */
2100
2294
  #mode = "code";
2101
2295
  constructor(provider, tools = [], costTracker, options) {
2102
2296
  this.#provider = provider;
@@ -2114,12 +2308,13 @@ var Session = class {
2114
2308
  preserveRecentRounds: options?.preserveRecentRounds ?? 10,
2115
2309
  projectContext: options?.projectContext,
2116
2310
  gate: options?.gate ?? new AlwaysAllowGate(),
2117
- writeRoots: options?.writeRoots ?? [options?.cwd ?? process.cwd()]
2311
+ writeRoots: options?.writeRoots ?? [options?.cwd ?? process.cwd()],
2312
+ enableCheckpoint: options?.enableCheckpoint ?? true
2118
2313
  };
2314
+ this.#sessionId = options?.sessionId ?? SessionStore.newId();
2315
+ this.#store = options?.store === false ? null : options?.store ?? new SessionStore();
2316
+ this.#createdAt = Date.now();
2119
2317
  }
2120
- // -------------------------------------------------------------------------
2121
- // 公共只读属性
2122
- // -------------------------------------------------------------------------
2123
2318
  get messages() {
2124
2319
  return this.#messages;
2125
2320
  }
@@ -2132,36 +2327,35 @@ var Session = class {
2132
2327
  get model() {
2133
2328
  return this.#provider.model();
2134
2329
  }
2135
- /** 获取工具注册表(只读视图) */
2136
2330
  get toolRegistry() {
2137
2331
  return this.#toolRegistry;
2138
2332
  }
2139
- /** 获取当前会话模式 */
2140
2333
  get mode() {
2141
2334
  return this.#mode;
2142
2335
  }
2143
- /** 切换会话模式,返回新模式 */
2336
+ get id() {
2337
+ return this.#sessionId;
2338
+ }
2339
+ get store() {
2340
+ return this.#store;
2341
+ }
2342
+ get createdAt() {
2343
+ return this.#createdAt;
2344
+ }
2144
2345
  setMode(mode) {
2145
2346
  this.#mode = mode;
2146
2347
  return this.#mode;
2147
2348
  }
2148
- // -------------------------------------------------------------------------
2149
- // 流式对话 — Agent 主循环
2150
- // -------------------------------------------------------------------------
2151
- /**
2152
- * 执行一轮用户对话,以 AsyncGenerator 形式逐步 yield 事件。
2153
- *
2154
- * 主循环流程:
2155
- * 1. 追加用户消息
2156
- * 2. 进入 Agent 循环(最多 maxToolRounds 轮)
2157
- * a. 构建消息 → 裁剪 → 调用 Provider 流式接口
2158
- * b. 解析响应:文本增量、工具调用、使用量
2159
- * c. 如果有工具调用 → 执行工具 → 追加结果 → 继续循环
2160
- * d. 如果没有工具调用 → 退出循环
2161
- * 3. yield done 事件
2162
- */
2163
2349
  async *chat(userInput, opts) {
2164
2350
  this.#messages.push({ role: "user", content: userInput });
2351
+ const userMsgIndex = this.#messages.length - 1;
2352
+ if (this.#options.enableCheckpoint) {
2353
+ try {
2354
+ const checkpoint = await createCheckpoint(this.#options.cwd);
2355
+ this.#checkpoints.set(userMsgIndex, checkpoint);
2356
+ } catch {
2357
+ }
2358
+ }
2165
2359
  const startTime = Date.now();
2166
2360
  let toolRounds = 0;
2167
2361
  try {
@@ -2170,7 +2364,6 @@ var Session = class {
2170
2364
  const [trimmed] = trimMessages(
2171
2365
  [...this.#messages],
2172
2366
  {
2173
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
2174
2367
  model: this.#provider.model(),
2175
2368
  reservedForOutput: this.#options.reservedForOutput,
2176
2369
  systemPrompt,
@@ -2196,28 +2389,17 @@ var Session = class {
2196
2389
  accumulatedText += chunk.content;
2197
2390
  yield { type: "text_delta", content: chunk.content };
2198
2391
  }
2199
- if (chunk.toolCalls && chunk.toolCalls.length > 0) {
2200
- lastToolCalls = chunk.toolCalls;
2201
- }
2202
- if (chunk.usage) {
2203
- lastUsage = chunk.usage;
2204
- }
2205
- if (chunk.finishReason) {
2206
- _lastFinishReason = chunk.finishReason;
2207
- }
2392
+ if (chunk.toolCalls && chunk.toolCalls.length > 0) lastToolCalls = chunk.toolCalls;
2393
+ if (chunk.usage) lastUsage = chunk.usage;
2394
+ if (chunk.finishReason) _lastFinishReason = chunk.finishReason;
2208
2395
  }
2209
2396
  if (lastUsage) {
2210
2397
  const modelId = this.#provider.model();
2211
2398
  this.#costTracker.record(lastUsage, modelId);
2212
2399
  yield { type: "usage", usage: lastUsage, model: modelId };
2213
2400
  }
2214
- const assistantMsg = {
2215
- role: "assistant",
2216
- content: accumulatedText
2217
- };
2218
- if (lastToolCalls && lastToolCalls.length > 0) {
2219
- assistantMsg.toolCalls = lastToolCalls;
2220
- }
2401
+ const assistantMsg = { role: "assistant", content: accumulatedText };
2402
+ if (lastToolCalls && lastToolCalls.length > 0) assistantMsg.toolCalls = lastToolCalls;
2221
2403
  this.#messages.push(assistantMsg);
2222
2404
  if (lastToolCalls && lastToolCalls.length > 0) {
2223
2405
  yield { type: "tool_calls", calls: lastToolCalls };
@@ -2236,11 +2418,9 @@ var Session = class {
2236
2418
  for (const item of results.items) {
2237
2419
  yield { type: "tool_result", name: item.name, result: item.result };
2238
2420
  let toolContent = item.result.data;
2239
- if (item.result.diff && item.result.diff.patch) {
2240
- toolContent += `
2421
+ if (item.result.diff && item.result.diff.patch) toolContent += `
2241
2422
 
2242
2423
  ${item.result.diff.patch}`;
2243
- }
2244
2424
  this.#messages.push({
2245
2425
  role: "tool",
2246
2426
  content: toolContent,
@@ -2254,31 +2434,17 @@ ${item.result.diff.patch}`;
2254
2434
  break;
2255
2435
  }
2256
2436
  } catch (err) {
2257
- if (err instanceof DOMException && err.name === "AbortError") {
2258
- return;
2259
- }
2260
- if (err instanceof Error && err.name === "AbortError") {
2261
- return;
2262
- }
2263
- yield {
2264
- type: "error",
2265
- error: err instanceof Error ? err : new Error(String(err))
2266
- };
2437
+ if (err instanceof DOMException && err.name === "AbortError") return;
2438
+ if (err instanceof Error && err.name === "AbortError") return;
2439
+ yield { type: "error", error: err instanceof Error ? err : new Error(String(err)) };
2267
2440
  return;
2268
2441
  }
2269
2442
  const elapsed = Date.now() - startTime;
2443
+ void this.#persist();
2444
+ await this.#costTracker.flush().catch(() => {
2445
+ });
2270
2446
  yield { type: "done", elapsed };
2271
2447
  }
2272
- // -------------------------------------------------------------------------
2273
- // 工具执行 — 批量、并行/串行、Gate、风暴检测
2274
- // -------------------------------------------------------------------------
2275
- /**
2276
- * 执行一批工具调用。
2277
- *
2278
- * 并行策略:
2279
- * - 如果这批工具全部是 ReadOnly 的,并行执行(最多 8 并发)
2280
- * - 否则按顺序串行执行,保证写/读顺序
2281
- */
2282
2448
  async #executeBatch(calls) {
2283
2449
  const toolCtx = {
2284
2450
  cwd: this.#options.cwd,
@@ -2313,9 +2479,6 @@ ${item.result.diff.patch}`;
2313
2479
  }
2314
2480
  return { items, records };
2315
2481
  }
2316
- /**
2317
- * 执行单个工具调用,包含 Gate 检查和预览。
2318
- */
2319
2482
  async #executeOne(tc, ctx) {
2320
2483
  const toolName = tc.name;
2321
2484
  const timestamp = Date.now();
@@ -2354,12 +2517,7 @@ ${item.result.diff.patch}`;
2354
2517
  const result = await tool.execute(toolArgs, ctx);
2355
2518
  return {
2356
2519
  item: { name: toolName, callId: tc.id, result },
2357
- record: {
2358
- name: toolName,
2359
- success: result.success,
2360
- error: result.error,
2361
- timestamp
2362
- }
2520
+ record: { name: toolName, success: result.success, error: result.error, timestamp }
2363
2521
  };
2364
2522
  } catch (err) {
2365
2523
  const message = err instanceof Error ? err.message : String(err);
@@ -2370,46 +2528,172 @@ ${item.result.diff.patch}`;
2370
2528
  };
2371
2529
  }
2372
2530
  }
2373
- // -------------------------------------------------------------------------
2374
- // 风暴检测 — 同一工具同一错误连续 3 次 → 强制换策略
2375
- // -------------------------------------------------------------------------
2376
- /**
2377
- * 连续 3 次同一工具同一错误 → 触发风暴中断。
2378
- */
2379
2531
  #checkStormBreak(currentCalls) {
2380
2532
  if (this.#stormRecords.length < 3) return false;
2381
2533
  const recentErrors = this.#stormRecords.slice(-3);
2382
2534
  if (recentErrors.length < 3) return false;
2383
2535
  const first = recentErrors[0];
2384
- const allSame = recentErrors.every(
2385
- (r) => r.name === first.name && r.error === first.error && !r.success
2386
- );
2536
+ const allSame = recentErrors.every((r) => r.name === first.name && r.error === first.error && !r.success);
2387
2537
  if (!allSame) return false;
2388
2538
  return currentCalls.some((tc) => tc.name === first.name);
2389
2539
  }
2390
- // -------------------------------------------------------------------------
2391
- // 会话管理
2392
- // -------------------------------------------------------------------------
2393
- /** 取消正在进行的流式请求 */
2394
2540
  abort() {
2395
2541
  this.#abortController.abort();
2542
+ if (this.#persistTimer) {
2543
+ clearTimeout(this.#persistTimer);
2544
+ this.#persistTimer = null;
2545
+ }
2396
2546
  }
2397
- /** 重置会话历史(保留 provider/tools 配置,重置成本追踪) */
2398
2547
  reset() {
2399
2548
  this.#messages.length = 0;
2400
2549
  this.#costTracker.resetSession();
2401
2550
  this.#stormRecords = [];
2551
+ this.#checkpoints.clear();
2552
+ }
2553
+ // -------------------------------------------------------------------------
2554
+ // 持久化与恢复
2555
+ // -------------------------------------------------------------------------
2556
+ async persistNow() {
2557
+ if (this.#persistTimer) {
2558
+ clearTimeout(this.#persistTimer);
2559
+ this.#persistTimer = null;
2560
+ }
2561
+ await this.#doPersist();
2562
+ }
2563
+ #persist() {
2564
+ if (!this.#store) return;
2565
+ if (this.#persistTimer) {
2566
+ this.#persistTimer.refresh();
2567
+ return;
2568
+ }
2569
+ this.#persistTimer = setTimeout(() => {
2570
+ this.#persistTimer = null;
2571
+ void this.#doPersist();
2572
+ }, 500);
2573
+ this.#persistTimer.unref();
2574
+ }
2575
+ async #doPersist() {
2576
+ if (!this.#store) return;
2577
+ const stored = {
2578
+ id: this.#sessionId,
2579
+ title: this.#deriveTitle(),
2580
+ createdAt: this.#createdAt,
2581
+ updatedAt: Date.now(),
2582
+ cwd: this.#options.cwd,
2583
+ model: this.#provider.model(),
2584
+ messages: this.#serializeMessages(),
2585
+ totalCost: this.#costTracker.sessionTotalCost
2586
+ };
2587
+ try {
2588
+ await this.#store.save(stored);
2589
+ } catch (err) {
2590
+ console.error("[Session] \u6301\u4E45\u5316\u5931\u8D25:", err);
2591
+ }
2592
+ }
2593
+ #deriveTitle() {
2594
+ for (const m of this.#messages) {
2595
+ if (m.role === "user" && m.content.trim()) return m.content.trim().slice(0, 40);
2596
+ }
2597
+ return "\u65B0\u4F1A\u8BDD";
2598
+ }
2599
+ #serializeMessages() {
2600
+ return this.#messages.map((msg, idx) => {
2601
+ const checkpoint = this.#checkpoints.get(idx);
2602
+ if (msg.role === "user" && checkpoint) return { ...msg, checkpoint };
2603
+ return { ...msg };
2604
+ });
2605
+ }
2606
+ static async resume(id, provider, tools = [], costTracker, options) {
2607
+ const store = options?.store === false ? null : options?.store ?? new SessionStore();
2608
+ if (!store) throw new Error("resume \u9700\u8981\u542F\u7528\u6301\u4E45\u5316\uFF08options.store \u4E0D\u80FD\u4E3A false\uFF09");
2609
+ const stored = await store.load(id);
2610
+ if (!stored) throw new Error(`\u4F1A\u8BDD ${id} \u4E0D\u5B58\u5728`);
2611
+ const session = new _Session(provider, tools, costTracker, { ...options, sessionId: id, store });
2612
+ for (const m of stored.messages) {
2613
+ session.#messages.push({
2614
+ role: m.role,
2615
+ content: m.content,
2616
+ toolCallId: m.toolCallId,
2617
+ name: m.name,
2618
+ toolCalls: m.toolCalls
2619
+ });
2620
+ }
2621
+ for (let i = 0; i < stored.messages.length; i++) {
2622
+ const cp2 = stored.messages[i]?.checkpoint;
2623
+ if (cp2) session.#checkpoints.set(i, cp2);
2624
+ }
2625
+ session.#createdAt = stored.createdAt;
2626
+ return session;
2627
+ }
2628
+ // -------------------------------------------------------------------------
2629
+ // 检查点与 Rewind
2630
+ // -------------------------------------------------------------------------
2631
+ listCheckpoints() {
2632
+ const result = [];
2633
+ for (const [index, checkpoint] of this.#checkpoints) {
2634
+ const msg = this.#messages[index];
2635
+ if (!msg || msg.role !== "user") continue;
2636
+ result.push({ index, preview: msg.content.slice(0, 80), timestamp: checkpoint.timestamp, isGitRepo: checkpoint.isGitRepo });
2637
+ }
2638
+ return result.sort((a, b) => a.index - b.index);
2639
+ }
2640
+ async rewind(targetIndex) {
2641
+ if (targetIndex < 0 || targetIndex >= this.#messages.length) {
2642
+ return { ok: false, error: `\u65E0\u6548\u7684\u6D88\u606F\u7D22\u5F15 ${targetIndex}` };
2643
+ }
2644
+ const target = this.#messages[targetIndex];
2645
+ if (!target || target.role !== "user") {
2646
+ return { ok: false, error: `\u7D22\u5F15 ${targetIndex} \u4E0D\u662F user \u6D88\u606F` };
2647
+ }
2648
+ const checkpoint = this.#checkpoints.get(targetIndex);
2649
+ if (!checkpoint) return { ok: false, error: "\u8BE5\u6D88\u606F\u6CA1\u6709\u68C0\u67E5\u70B9" };
2650
+ this.#messages.length = targetIndex + 1;
2651
+ const toDiscard = [];
2652
+ for (const [idx, cp2] of this.#checkpoints) {
2653
+ if (idx > targetIndex) {
2654
+ toDiscard.push(cp2);
2655
+ this.#checkpoints.delete(idx);
2656
+ }
2657
+ }
2658
+ let fileRestored = false;
2659
+ if (checkpoint.isGitRepo) {
2660
+ try {
2661
+ if (checkpoint.stashSha) {
2662
+ await restoreCheckpointForce(checkpoint);
2663
+ } else {
2664
+ await restoreToClean(this.#options.cwd);
2665
+ }
2666
+ fileRestored = true;
2667
+ } catch (err) {
2668
+ const msg = err instanceof Error ? err.message : String(err);
2669
+ return { ok: false, error: `\u5BF9\u8BDD\u5DF2\u622A\u65AD\u4F46\u6587\u4EF6\u6062\u590D\u5931\u8D25\uFF1A${msg}` };
2670
+ }
2671
+ }
2672
+ this.#checkpoints.delete(targetIndex);
2673
+ for (const cp2 of toDiscard) {
2674
+ void discardCheckpoint(cp2);
2675
+ }
2676
+ this.#persist();
2677
+ return { ok: true, fileRestored };
2678
+ }
2679
+ hasCheckpoints() {
2680
+ return this.listCheckpoints().length > 0;
2681
+ }
2682
+ async delete() {
2683
+ if (this.#store) await this.#store.delete(this.#sessionId);
2684
+ for (const cp2 of this.#checkpoints.values()) {
2685
+ void discardCheckpoint(cp2);
2686
+ }
2687
+ this.#checkpoints.clear();
2402
2688
  }
2403
2689
  // -------------------------------------------------------------------------
2404
2690
  // 内部方法
2405
2691
  // -------------------------------------------------------------------------
2406
- /** 构建系统提示词 */
2407
2692
  #buildSystemPrompt() {
2408
2693
  const enabledTools = this.#toolRegistry.list();
2409
2694
  const toolDescs = enabledTools.map((t) => ({
2410
2695
  name: t.name,
2411
2696
  description: t.description,
2412
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
2413
2697
  parameters: t.parameters
2414
2698
  }));
2415
2699
  const opts = {
@@ -2419,25 +2703,14 @@ ${item.result.diff.patch}`;
2419
2703
  projectContext: this.#options.projectContext ?? void 0,
2420
2704
  cwd: this.#options.cwd
2421
2705
  };
2422
- if (this.#mode === "plan") {
2423
- return buildPlanSystemPrompt(opts);
2424
- }
2706
+ if (this.#mode === "plan") return buildPlanSystemPrompt(opts);
2425
2707
  return buildSystemPrompt(opts);
2426
2708
  }
2427
- /**
2428
- * 将注册的工具转为 ToolDefinition 格式(预留给 function calling)。
2429
- * 计划模式下只返回读工具,禁止非读工具暴露给 LLM。
2430
- */
2431
2709
  #buildToolDefinitions() {
2432
2710
  const tools = this.#mode === "plan" ? this.#toolRegistry.listReadTools() : this.#toolRegistry.list();
2433
2711
  return tools.map((t) => ({
2434
2712
  type: "function",
2435
- function: {
2436
- name: t.name,
2437
- description: t.description,
2438
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
2439
- parameters: t.parameters
2440
- }
2713
+ function: { name: t.name, description: t.description, parameters: t.parameters }
2441
2714
  }));
2442
2715
  }
2443
2716
  };
@@ -2831,7 +3104,7 @@ function computeFileDiff(oldContent, newContent, filePath) {
2831
3104
  }
2832
3105
 
2833
3106
  // src/tool/eol.ts
2834
- import { writeFile as writeFile3 } from "fs/promises";
3107
+ import { writeFile as writeFile4 } from "fs/promises";
2835
3108
  function detectEol(text) {
2836
3109
  if (text.length === 0) return "\n";
2837
3110
  const crlfIdx = text.indexOf("\r\n");
@@ -2862,11 +3135,11 @@ function normalizeEol(originalContent, newContent) {
2862
3135
  }
2863
3136
  async function writeFileWithEol(filePath, originalContent, newContent) {
2864
3137
  const content = originalContent.length > 0 ? normalizeEol(originalContent, newContent) : newContent;
2865
- await writeFile3(filePath, content, "utf-8");
3138
+ await writeFile4(filePath, content, "utf-8");
2866
3139
  }
2867
3140
 
2868
3141
  // src/tool/builtins/read-file.ts
2869
- import { readFile as readFile4, stat } from "fs/promises";
3142
+ import { readFile as readFile5, stat } from "fs/promises";
2870
3143
  import { open } from "fs/promises";
2871
3144
  import { relative as relative2 } from "path";
2872
3145
  async function checkBinary(filePath) {
@@ -2934,7 +3207,7 @@ var readFileTool = {
2934
3207
  };
2935
3208
  }
2936
3209
  }
2937
- const content = await readFile4(filePath, "utf-8");
3210
+ const content = await readFile5(filePath, "utf-8");
2938
3211
  const lines = content.split("\n");
2939
3212
  if (lines.length > 0 && lines[lines.length - 1] === "" && content.endsWith("\n")) {
2940
3213
  lines.pop();
@@ -2970,7 +3243,7 @@ var readFileTool = {
2970
3243
  };
2971
3244
 
2972
3245
  // src/tool/builtins/write-file.ts
2973
- import { mkdir as mkdir4, readFile as readFile5 } from "fs/promises";
3246
+ import { mkdir as mkdir5, readFile as readFile6 } from "fs/promises";
2974
3247
  import { dirname, relative as relative3, basename } from "path";
2975
3248
  var writeFileTool = {
2976
3249
  name: "write_file",
@@ -3009,11 +3282,11 @@ var writeFileTool = {
3009
3282
  let oldContent = "";
3010
3283
  let existedBefore = false;
3011
3284
  try {
3012
- oldContent = await readFile5(filePath, "utf-8");
3285
+ oldContent = await readFile6(filePath, "utf-8");
3013
3286
  existedBefore = true;
3014
3287
  } catch {
3015
3288
  }
3016
- await mkdir4(dirname(filePath), { recursive: true });
3289
+ await mkdir5(dirname(filePath), { recursive: true });
3017
3290
  const content = args.content;
3018
3291
  await writeFileWithEol(filePath, oldContent, content);
3019
3292
  const diff = computeFileDiff(oldContent, content, filePath);
@@ -3042,7 +3315,7 @@ var writeFileTool = {
3042
3315
  };
3043
3316
 
3044
3317
  // src/tool/builtins/edit-file.ts
3045
- import { readFile as readFile6 } from "fs/promises";
3318
+ import { readFile as readFile7 } from "fs/promises";
3046
3319
  import { basename as basename2 } from "path";
3047
3320
  var editFileTool = {
3048
3321
  name: "edit_file",
@@ -3085,7 +3358,7 @@ var editFileTool = {
3085
3358
  }
3086
3359
  }
3087
3360
  try {
3088
- const content = await readFile6(filePath, "utf-8");
3361
+ const content = await readFile7(filePath, "utf-8");
3089
3362
  const firstIndex = content.indexOf(args.old_text);
3090
3363
  if (firstIndex === -1) {
3091
3364
  return {
@@ -3133,7 +3406,7 @@ ${oldLines} \u884C \u2192 ${newLines} \u884C
3133
3406
  };
3134
3407
 
3135
3408
  // src/tool/builtins/multi-edit.ts
3136
- import { readFile as readFile7 } from "fs/promises";
3409
+ import { readFile as readFile8 } from "fs/promises";
3137
3410
  import { basename as basename3 } from "path";
3138
3411
  var multiEditTool = {
3139
3412
  name: "multi_edit",
@@ -3179,7 +3452,7 @@ var multiEditTool = {
3179
3452
  }
3180
3453
  }
3181
3454
  try {
3182
- const originalContent = await readFile7(filePath, "utf-8");
3455
+ const originalContent = await readFile8(filePath, "utf-8");
3183
3456
  let currentContent = originalContent;
3184
3457
  for (let idx = 0; idx < args.edits.length; idx++) {
3185
3458
  const step = args.edits[idx];
@@ -3247,7 +3520,7 @@ var multiEditTool = {
3247
3520
  };
3248
3521
 
3249
3522
  // src/tool/builtins/delete-range.ts
3250
- import { readFile as readFile8 } from "fs/promises";
3523
+ import { readFile as readFile9 } from "fs/promises";
3251
3524
  import { basename as basename4 } from "path";
3252
3525
  function findUniqueLine(lines, anchor, label) {
3253
3526
  const matches = [];
@@ -3310,7 +3583,7 @@ var deleteRangeTool = {
3310
3583
  }
3311
3584
  }
3312
3585
  try {
3313
- const content = await readFile8(filePath, "utf-8");
3586
+ const content = await readFile9(filePath, "utf-8");
3314
3587
  const lines = content.split("\n");
3315
3588
  const startResult = findUniqueLine(lines, args.startAnchor, "start_anchor");
3316
3589
  if ("error" in startResult) {
@@ -3433,8 +3706,8 @@ ${truncateOutput(result.stderr)}`);
3433
3706
  };
3434
3707
 
3435
3708
  // src/tool/builtins/glob.ts
3436
- import { readdir as readdir2, stat as stat2 } from "fs/promises";
3437
- import { join as join4, relative as relative4, isAbsolute as isAbsolute2 } from "path";
3709
+ import { readdir as readdir3, stat as stat2 } from "fs/promises";
3710
+ import { join as join5, relative as relative4, isAbsolute as isAbsolute2 } from "path";
3438
3711
  function globToRegex(pattern) {
3439
3712
  let regexStr = pattern;
3440
3713
  regexStr = regexStr.replace(/\*\*\//g, "<<GLOBSTAR_SLASH>>");
@@ -3451,7 +3724,7 @@ async function walkDir(dir, baseDir) {
3451
3724
  const results = [];
3452
3725
  let entries;
3453
3726
  try {
3454
- entries = await readdir2(dir, { withFileTypes: true });
3727
+ entries = await readdir3(dir, { withFileTypes: true });
3455
3728
  } catch {
3456
3729
  return results;
3457
3730
  }
@@ -3459,7 +3732,7 @@ async function walkDir(dir, baseDir) {
3459
3732
  if (entry.isDirectory() && (entry.name === "node_modules" || entry.name === ".git")) {
3460
3733
  continue;
3461
3734
  }
3462
- const fullPath = join4(dir, entry.name);
3735
+ const fullPath = join5(dir, entry.name);
3463
3736
  const relPath = relative4(baseDir, fullPath);
3464
3737
  if (entry.isDirectory()) {
3465
3738
  results.push(...await walkDir(fullPath, baseDir));
@@ -3492,7 +3765,7 @@ var globTool = {
3492
3765
  if (!args?.pattern || typeof args.pattern !== "string") {
3493
3766
  return { success: false, data: "\u7F3A\u5C11\u5FC5\u8981\u53C2\u6570 pattern", error: "INVALID_ARGS" };
3494
3767
  }
3495
- const searchDir = args.directory ? isAbsolute2(args.directory) ? args.directory : join4(ctx.cwd, args.directory) : ctx.cwd;
3768
+ const searchDir = args.directory ? isAbsolute2(args.directory) ? args.directory : join5(ctx.cwd, args.directory) : ctx.cwd;
3496
3769
  const regex = globToRegex(args.pattern);
3497
3770
  try {
3498
3771
  const dirStat = await stat2(searchDir);
@@ -3531,13 +3804,13 @@ var globTool = {
3531
3804
  };
3532
3805
 
3533
3806
  // src/tool/builtins/grep.ts
3534
- import { readdir as readdir3, readFile as readFile9, stat as stat3 } from "fs/promises";
3535
- import { join as join5, relative as relative5, isAbsolute as isAbsolute3 } from "path";
3807
+ import { readdir as readdir4, readFile as readFile10, stat as stat3 } from "fs/promises";
3808
+ import { join as join6, relative as relative5, isAbsolute as isAbsolute3 } from "path";
3536
3809
  async function collectFiles(dir, extension, maxFiles = 200) {
3537
3810
  const results = [];
3538
3811
  let entries;
3539
3812
  try {
3540
- entries = await readdir3(dir, { withFileTypes: true });
3813
+ entries = await readdir4(dir, { withFileTypes: true });
3541
3814
  } catch {
3542
3815
  return results;
3543
3816
  }
@@ -3546,7 +3819,7 @@ async function collectFiles(dir, extension, maxFiles = 200) {
3546
3819
  if (entry.isDirectory() && (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist")) {
3547
3820
  continue;
3548
3821
  }
3549
- const fullPath = join5(dir, entry.name);
3822
+ const fullPath = join6(dir, entry.name);
3550
3823
  if (entry.isDirectory()) {
3551
3824
  results.push(...await collectFiles(fullPath, extension, maxFiles - results.length));
3552
3825
  } else {
@@ -3593,7 +3866,7 @@ var grepTool = {
3593
3866
  if (!args?.pattern || typeof args.pattern !== "string") {
3594
3867
  return { success: false, data: "\u7F3A\u5C11\u5FC5\u8981\u53C2\u6570 pattern", error: "INVALID_ARGS" };
3595
3868
  }
3596
- const searchDir = args.directory ? isAbsolute3(args.directory) ? args.directory : join5(ctx.cwd, args.directory) : ctx.cwd;
3869
+ const searchDir = args.directory ? isAbsolute3(args.directory) ? args.directory : join6(ctx.cwd, args.directory) : ctx.cwd;
3597
3870
  const maxFiles = args.max_files ?? 200;
3598
3871
  try {
3599
3872
  const flags = args.case_sensitive ? "g" : "gi";
@@ -3606,7 +3879,7 @@ var grepTool = {
3606
3879
  const matches = [];
3607
3880
  for (const filePath of files) {
3608
3881
  try {
3609
- const content = await readFile9(filePath, "utf-8");
3882
+ const content = await readFile10(filePath, "utf-8");
3610
3883
  const lines = content.split("\n");
3611
3884
  const relPath = relative5(searchDir, filePath);
3612
3885
  for (let i = 0; i < lines.length; i++) {
@@ -3652,8 +3925,8 @@ var grepTool = {
3652
3925
  };
3653
3926
 
3654
3927
  // src/tool/builtins/ls.ts
3655
- import { readdir as readdir4, stat as stat4 } from "fs/promises";
3656
- import { join as join6, relative as relative6 } from "path";
3928
+ import { readdir as readdir5, stat as stat4 } from "fs/promises";
3929
+ import { join as join7, relative as relative6 } from "path";
3657
3930
  var lsTool = {
3658
3931
  name: "ls",
3659
3932
  kind: "read" /* Read */,
@@ -3677,7 +3950,7 @@ var lsTool = {
3677
3950
  const dirPath = args.path ? resolvePath(args.path, ctx.cwd) : ctx.cwd;
3678
3951
  const showAll = args.all ?? false;
3679
3952
  try {
3680
- const entries = await readdir4(dirPath, { withFileTypes: true });
3953
+ const entries = await readdir5(dirPath, { withFileTypes: true });
3681
3954
  const lines = [];
3682
3955
  const sorted = [...entries].toSorted((a, b) => {
3683
3956
  if (a.isDirectory() !== b.isDirectory()) {
@@ -3691,7 +3964,7 @@ var lsTool = {
3691
3964
  let sizeStr = "";
3692
3965
  if (typeMark === "FILE") {
3693
3966
  try {
3694
- const fileStat = await stat4(join6(dirPath, entry.name));
3967
+ const fileStat = await stat4(join7(dirPath, entry.name));
3695
3968
  if (fileStat.size < 1024) {
3696
3969
  sizeStr = `${fileStat.size}B`;
3697
3970
  } else if (fileStat.size < 1024 * 1024) {
@@ -3900,6 +4173,9 @@ var PHASE_CONFIG = {
3900
4173
  calling_tools: { icon: "\u{1F6E0}", label: "\u8C03\u7528\u5DE5\u5177", color: "#f59e0b" },
3901
4174
  executing_tools: { icon: "\u26A1", label: "\u6267\u884C\u5DE5\u5177", color: "#00ffff" }
3902
4175
  };
4176
+ function isFileMutatingTool(name) {
4177
+ return name === "edit_file" || name === "write_file" || name === "multi_edit" || name === "delete_range";
4178
+ }
3903
4179
  var commandRegistry = /* @__PURE__ */ new Map();
3904
4180
  function registerCommand(name, cmd) {
3905
4181
  commandRegistry.set(name, cmd);
@@ -3929,6 +4205,7 @@ registerCommand("/game", { desc: "\u542F\u52A8\u6E38\u620F", handler: () => ({ k
3929
4205
  registerCommand("/stock", { desc: "\u67E5\u770B\u80A1\u7968\u884C\u60C5", handler: () => ({ kind: "navigate", target: "stock" }) });
3930
4206
  registerCommand("/plan", { desc: "\u5207\u6362\u4E3A\u8BA1\u5212\u6A21\u5F0F\uFF08Shift+Tab\uFF09", handler: () => ({ kind: "text", content: "\u8F93\u5165 /plan \u6216\u6309 Shift+Tab \u5207\u6362\u4E3A\u8BA1\u5212\u6A21\u5F0F" }) });
3931
4207
  registerCommand("/code", { desc: "\u5207\u6362\u56DE\u4EE3\u7801\u6A21\u5F0F\uFF08Shift+Tab\uFF09", handler: () => ({ kind: "text", content: "\u8F93\u5165 /code \u6216\u6309 Shift+Tab \u5207\u6362\u56DE\u4EE3\u7801\u6A21\u5F0F" }) });
4208
+ registerCommand("/rewind", { desc: "\u56DE\u9000\u5230\u5386\u53F2\u68C0\u67E5\u70B9\uFF081 = \u6700\u65B0\uFF09", handler: () => ({ kind: "text", content: "\u8BF7\u76F4\u63A5\u8F93\u5165 /rewind \u67E5\u770B\u53EF\u56DE\u9000\u7684\u68C0\u67E5\u70B9\u5217\u8868\uFF0C\u6216 /rewind <\u5E8F\u53F7> \u76F4\u63A5\u56DE\u9000\uFF081 = \u6700\u65B0\uFF0C2 = \u4E0A\u4E00\u6B21\uFF0C\u4F9D\u6B64\u7C7B\u63A8\uFF09" }) });
3932
4209
  var STREAMING_PLACEHOLDERS = [
3933
4210
  "\u8BA9\u5B50\u5F39\u98DE\u4E00\u4F1A\u513F...",
3934
4211
  "\u9A6C\u4E0A\u5C31\u597D...",
@@ -4007,6 +4284,13 @@ function ChatSession({
4007
4284
  const [skillSelectIndex, setSkillSelectIndex] = useState3(0);
4008
4285
  const [fileSelectIndex, setFileSelectIndex] = useState3(0);
4009
4286
  const [inputKey, setInputKey] = useState3(0);
4287
+ const [rewindSelecting, setRewindSelecting] = useState3(false);
4288
+ const [rewindSelectIndex, setRewindSelectIndex] = useState3(0);
4289
+ const [rewindList, setRewindList] = useState3([]);
4290
+ const [rewinding, setRewinding] = useState3(false);
4291
+ const [rewindHintPhase, setRewindHintPhase] = useState3("idle");
4292
+ const currentRoundModifiedRef = useRef2(false);
4293
+ const rewindHintTimerRef = useRef2(null);
4010
4294
  const [selectingModel, setSelectingModel] = useState3(false);
4011
4295
  const [modelSelectIndex, setModelSelectIndex] = useState3(0);
4012
4296
  const modelOptions = ["deepseek-v4-flash", "deepseek-v4-pro"];
@@ -4023,6 +4307,14 @@ function ChatSession({
4023
4307
  setSkillSelectIndex(0);
4024
4308
  setFileSelectIndex(0);
4025
4309
  }, [input]);
4310
+ useEffect3(() => {
4311
+ return () => {
4312
+ if (rewindHintTimerRef.current) {
4313
+ clearTimeout(rewindHintTimerRef.current);
4314
+ rewindHintTimerRef.current = null;
4315
+ }
4316
+ };
4317
+ }, []);
4026
4318
  const getFilteredSkills = useCallback2(
4027
4319
  (value) => {
4028
4320
  const match = value.match(/(?:^|\s)\/([^/]*)$/);
@@ -4053,12 +4345,89 @@ function ChatSession({
4053
4345
  },
4054
4346
  [files]
4055
4347
  );
4348
+ function rebuildDisplayFromSession(sess) {
4349
+ const next = [];
4350
+ for (const m of sess.messages) {
4351
+ if (m.role === "user") {
4352
+ next.push({ role: "user", content: m.content });
4353
+ } else if (m.role === "assistant") {
4354
+ next.push({
4355
+ role: "assistant",
4356
+ content: m.content,
4357
+ assistantDetail: {
4358
+ content: m.content,
4359
+ toolCalls: m.toolCalls
4360
+ }
4361
+ });
4362
+ } else if (m.role === "tool") {
4363
+ next.push({ role: "tool", content: m.content });
4364
+ }
4365
+ }
4366
+ setDisplayMessages(next);
4367
+ setStaticKey((prev) => prev + 1);
4368
+ }
4369
+ const doRewind = useCallback2(async (target, displayNumber) => {
4370
+ const session = sessionRef.current;
4371
+ if (!session) return;
4372
+ setRewinding(true);
4373
+ setIsStreaming(true);
4374
+ setStreamingPhase("thinking");
4375
+ setStreamingPlaceholder("\u23EA \u6B63\u5728\u56DE\u9000\u5230\u68C0\u67E5\u70B9\u2026");
4376
+ setInput("");
4377
+ try {
4378
+ const r = await session.rewind(target.index);
4379
+ if (r.ok) {
4380
+ rebuildDisplayFromSession(session);
4381
+ const tail = r.fileRestored ? "\uFF0C\u5DE5\u4F5C\u533A\u6587\u4EF6\u5DF2\u6062\u590D" : "\uFF08\u4EC5\u5BF9\u8BDD\u56DE\u9000\uFF0C\u672A\u6062\u590D\u6587\u4EF6\uFF09";
4382
+ setDisplayMessages((prev) => [
4383
+ ...prev,
4384
+ { role: "assistant", content: `\u23EA \u5DF2\u56DE\u9000\u5230\u68C0\u67E5\u70B9 #${displayNumber}${tail}\u3002` }
4385
+ ]);
4386
+ } else {
4387
+ setDisplayMessages((prev) => [
4388
+ ...prev,
4389
+ { role: "assistant", content: `\u26A0 \u56DE\u9000\u5931\u8D25\uFF1A${r.error}` }
4390
+ ]);
4391
+ }
4392
+ } catch (err) {
4393
+ const msg = err instanceof Error ? err.message : String(err);
4394
+ setDisplayMessages((prev) => [
4395
+ ...prev,
4396
+ { role: "assistant", content: `\u26A0 \u56DE\u9000\u5F02\u5E38\uFF1A${msg}` }
4397
+ ]);
4398
+ } finally {
4399
+ setRewinding(false);
4400
+ setIsStreaming(false);
4401
+ setStreamingPhase(null);
4402
+ setStreamingPlaceholder("");
4403
+ }
4404
+ }, []);
4056
4405
  const { doubleCtrlC, handleCtrlC } = useDoubleCtrlC(() => {
4057
4406
  process.exit(0);
4058
4407
  });
4059
4408
  useInput(
4060
4409
  useCallback2(
4061
4410
  (_input, key) => {
4411
+ if (rewindSelecting) {
4412
+ if (key.upArrow) {
4413
+ setRewindSelectIndex((prev) => (prev - 1 + rewindList.length) % rewindList.length);
4414
+ } else if (key.downArrow) {
4415
+ setRewindSelectIndex((prev) => (prev + 1) % rewindList.length);
4416
+ } else if (key.return) {
4417
+ const target = rewindList[rewindSelectIndex];
4418
+ setRewindSelecting(false);
4419
+ if (target) {
4420
+ void doRewind(target, rewindSelectIndex + 1);
4421
+ }
4422
+ } else if (key.escape) {
4423
+ setRewindSelecting(false);
4424
+ setDisplayMessages((prev) => [
4425
+ ...prev,
4426
+ { role: "assistant", content: "\u5DF2\u53D6\u6D88\u56DE\u9000\u3002" }
4427
+ ]);
4428
+ }
4429
+ return;
4430
+ }
4062
4431
  if (selectingModel) {
4063
4432
  if (key.upArrow) {
4064
4433
  setModelSelectIndex((prev) => (prev - 1 + modelOptions.length) % modelOptions.length);
@@ -4165,7 +4534,7 @@ function ChatSession({
4165
4534
  setInput(_input);
4166
4535
  }
4167
4536
  },
4168
- [selectingModel, modelSelectIndex, modelOptions, activeModel, isStreaming, handleCtrlC, input, skills, skillSelectIndex, fileSelectIndex, getFilteredSkills, getFilteredFiles, sessionMode, setSessionMode]
4537
+ [selectingModel, modelSelectIndex, modelOptions, activeModel, isStreaming, handleCtrlC, input, skills, skillSelectIndex, fileSelectIndex, getFilteredSkills, getFilteredFiles, sessionMode, setSessionMode, rewindSelecting, rewindSelectIndex, rewindList, doRewind]
4169
4538
  )
4170
4539
  );
4171
4540
  useEffect3(() => {
@@ -4289,6 +4658,64 @@ function ChatSession({
4289
4658
  if (!trimmed) return;
4290
4659
  if (trimmed.startsWith("/") && trimmed.length > 1) {
4291
4660
  const cmdLower = trimmed.toLowerCase();
4661
+ if (cmdLower === "/rewind" || cmdLower.startsWith("/rewind ")) {
4662
+ if (isStreaming || rewinding) {
4663
+ const reason = rewinding ? "\u56DE\u9000\u4E2D" : "\u751F\u6210\u4E2D";
4664
+ setDisplayMessages((prev) => [
4665
+ ...prev,
4666
+ { role: "user", content: trimmed },
4667
+ { role: "assistant", content: `\u26A0 \u6B63\u5728${reason}\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5 /rewind\u3002` }
4668
+ ]);
4669
+ setInput("");
4670
+ return;
4671
+ }
4672
+ if (!sessionRef.current) {
4673
+ setDisplayMessages((prev) => [
4674
+ ...prev,
4675
+ { role: "user", content: trimmed },
4676
+ { role: "assistant", content: "\u26A0 Session \u672A\u5C31\u7EEA\uFF0C\u65E0\u6CD5\u56DE\u9000\u3002" }
4677
+ ]);
4678
+ setInput("");
4679
+ return;
4680
+ }
4681
+ const cps = sessionRef.current.listCheckpoints();
4682
+ if (cps.length === 0) {
4683
+ setDisplayMessages((prev) => [
4684
+ ...prev,
4685
+ { role: "user", content: trimmed },
4686
+ { role: "assistant", content: "\u26A0 \u6CA1\u6709\u53EF\u56DE\u9000\u7684\u68C0\u67E5\u70B9\u3002\n\u53EA\u6709\u5728 git \u4ED3\u5E93\u5185\u4E14\u53D1\u751F\u8FC7\u5BF9\u8BDD\u540E\u624D\u4F1A\u751F\u6210\u68C0\u67E5\u70B9\u3002" }
4687
+ ]);
4688
+ setInput("");
4689
+ return;
4690
+ }
4691
+ const parts = trimmed.split(/\s+/);
4692
+ if (parts.length >= 2) {
4693
+ const n = Number(parts[1]);
4694
+ if (!Number.isInteger(n) || n < 1 || n > cps.length) {
4695
+ setDisplayMessages((prev) => [
4696
+ ...prev,
4697
+ { role: "user", content: trimmed },
4698
+ { role: "assistant", content: `\u26A0 \u65E0\u6548\u7684\u5E8F\u53F7\u300C${parts[1]}\u300D\u3002\u53EF\u7528\u8303\u56F4 1~${cps.length}\uFF081 \u8868\u793A\u6700\u65B0\uFF09\u3002` }
4699
+ ]);
4700
+ setInput("");
4701
+ return;
4702
+ }
4703
+ const target = cps[cps.length - n];
4704
+ setInput("");
4705
+ await doRewind(target, n);
4706
+ return;
4707
+ }
4708
+ setRewindList([...cps].reverse());
4709
+ setRewindSelectIndex(0);
4710
+ setRewindSelecting(true);
4711
+ setDisplayMessages((prev) => [
4712
+ ...prev,
4713
+ { role: "user", content: trimmed },
4714
+ { role: "assistant", content: `\u23F7\u2191\u2193 \u9009\u62E9\u68C0\u67E5\u70B9\uFF0CEnter \u786E\u8BA4\uFF0CEsc \u53D6\u6D88\uFF08\u5171 ${cps.length} \u4E2A\u53EF\u56DE\u9000\u4F4D\u7F6E\uFF09` }
4715
+ ]);
4716
+ setInput("");
4717
+ return;
4718
+ }
4292
4719
  if (cmdLower === "/model") {
4293
4720
  const curIdx = modelOptions.indexOf(activeModel);
4294
4721
  setModelSelectIndex(curIdx >= 0 ? curIdx : 0);
@@ -4443,6 +4870,12 @@ function ChatSession({
4443
4870
  currentCostRef.current = void 0;
4444
4871
  currentModelRef.current = void 0;
4445
4872
  streamErrorRef.current = void 0;
4873
+ currentRoundModifiedRef.current = false;
4874
+ setRewindHintPhase("idle");
4875
+ if (rewindHintTimerRef.current) {
4876
+ clearTimeout(rewindHintTimerRef.current);
4877
+ rewindHintTimerRef.current = null;
4878
+ }
4446
4879
  const session = sessionRef.current;
4447
4880
  const abortController = new AbortController();
4448
4881
  abortRef.current = abortController;
@@ -4470,6 +4903,12 @@ function ChatSession({
4470
4903
  currentToolCallsRef.current = next;
4471
4904
  return next;
4472
4905
  });
4906
+ for (const call of event.calls) {
4907
+ if (isFileMutatingTool(call.name)) {
4908
+ currentRoundModifiedRef.current = true;
4909
+ break;
4910
+ }
4911
+ }
4473
4912
  break;
4474
4913
  case "tool_result":
4475
4914
  setStreamingPhase("executing_tools");
@@ -4540,6 +4979,14 @@ function ChatSession({
4540
4979
  }
4541
4980
  ]);
4542
4981
  }
4982
+ if (currentRoundModifiedRef.current && !finStreamError) {
4983
+ setRewindHintPhase("pending");
4984
+ if (rewindHintTimerRef.current) clearTimeout(rewindHintTimerRef.current);
4985
+ rewindHintTimerRef.current = setTimeout(() => {
4986
+ setRewindHintPhase((prev) => prev === "pending" ? "visible" : prev);
4987
+ rewindHintTimerRef.current = null;
4988
+ }, 2e3);
4989
+ }
4543
4990
  }
4544
4991
  }, [onLaunchGame, onLaunchStock, currentContent, currentToolCalls, skills, skillSelectIndex, getFilteredSkills, thinkingEnabled, thinkingEffort, responseFormat, toolChoice, activeModel, sessionMode]);
4545
4992
  useEffect3(() => {
@@ -4685,6 +5132,38 @@ function ChatSession({
4685
5132
  /* @__PURE__ */ jsx9(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text10, { color: "#808080", dimColor: true, children: "\u2191\u2193 \u9009\u62E9 \xB7 Enter \u786E\u8BA4 \xB7 Esc \u53D6\u6D88" }) })
4686
5133
  ] }),
4687
5134
  /* @__PURE__ */ jsx9(Text10, { color: "#00ffff", dimColor: true, children: "\u2500".repeat(dividerWidth) })
5135
+ ] }) : rewindSelecting ? /* @__PURE__ */ jsxs9(Box9, { marginTop: 1, flexDirection: "column", children: [
5136
+ /* @__PURE__ */ jsx9(Text10, { color: "#00ffff", dimColor: true, children: "\u2500".repeat(dividerWidth) }),
5137
+ /* @__PURE__ */ jsxs9(Box9, { flexDirection: "column", marginTop: 1, children: [
5138
+ /* @__PURE__ */ jsx9(Text10, { bold: true, color: "#ff9800", children: "\u9009\u62E9\u8981\u56DE\u9000\u7684\u68C0\u67E5\u70B9\uFF1A" }),
5139
+ rewindList.map((cp2, i) => {
5140
+ const isSelected = i === rewindSelectIndex;
5141
+ const marker = isSelected ? " > " : " ";
5142
+ const time = new Date(cp2.timestamp).toLocaleTimeString();
5143
+ const preview = cp2.preview || "(\u7A7A)";
5144
+ const tag = cp2.isGitRepo ? "" : " [\u975E git\uFF0C\u4EC5\u5BF9\u8BDD]";
5145
+ return /* @__PURE__ */ jsx9(Box9, { children: /* @__PURE__ */ jsxs9(
5146
+ Text10,
5147
+ {
5148
+ color: isSelected ? "#ff9800" : void 0,
5149
+ bold: isSelected,
5150
+ children: [
5151
+ marker,
5152
+ "#",
5153
+ i + 1,
5154
+ " ",
5155
+ time,
5156
+ " `",
5157
+ preview,
5158
+ tag,
5159
+ "`"
5160
+ ]
5161
+ }
5162
+ ) }, cp2.index);
5163
+ }),
5164
+ /* @__PURE__ */ jsx9(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text10, { color: "#808080", dimColor: true, children: "\u2191\u2193 \u9009\u62E9 \xB7 Enter \u786E\u8BA4 \xB7 Esc \u53D6\u6D88" }) })
5165
+ ] }),
5166
+ /* @__PURE__ */ jsx9(Text10, { color: "#00ffff", dimColor: true, children: "\u2500".repeat(dividerWidth) })
4688
5167
  ] }) : /* @__PURE__ */ jsxs9(Fragment, { children: [
4689
5168
  (hasConversationStarted || sessionMode === "plan") && isStreaming && streamingPhase ? /* @__PURE__ */ jsx9(Box9, { marginTop: 1, justifyContent: "center", children: /* @__PURE__ */ jsxs9(Text10, { bold: true, color: PHASE_CONFIG[streamingPhase].color, children: [
4690
5169
  PHASE_CONFIG[streamingPhase].icon,
@@ -4692,7 +5171,11 @@ function ChatSession({
4692
5171
  PHASE_CONFIG[streamingPhase].label,
4693
5172
  " ",
4694
5173
  /* @__PURE__ */ jsx9(InkSpinner2, { type: "dots" })
4695
- ] }) }) : null,
5174
+ ] }) }) : rewindHintPhase === "visible" ? (
5175
+ // 本轮修改了文件时,流式结束后 2s 在原位置展示 /rewind 提示
5176
+ // 一直保留到下次对话开始,与 /rewind 1(最新检查点)配套
5177
+ /* @__PURE__ */ jsx9(Box9, { marginTop: 1, justifyContent: "center", children: /* @__PURE__ */ jsx9(Text10, { color: "#808080", children: "\u21A9 /rewind 1 \u53EF\u64A4\u56DE\u672C\u6B21\u4FEE\u6539" }) })
5178
+ ) : null,
4696
5179
  /* @__PURE__ */ jsx9(Box9, { marginTop: 1, children: hasConversationStarted || sessionMode === "plan" ? /* @__PURE__ */ jsxs9(Text10, { color: sessionMode === "plan" ? "#ff69b4" : "#00ffff", dimColor: sessionMode !== "plan", children: [
4697
5180
  /* @__PURE__ */ jsx9(Text10, { children: sessionMode === "plan" ? "\u2500".repeat(Math.max(dividerWidth - 48, 1)) : "\u2500".repeat(Math.max(dividerWidth - 35, 10)) }),
4698
5181
  balance !== null && /* @__PURE__ */ jsxs9(Text10, { color: "yellow", children: [
@@ -5966,9 +6449,9 @@ import { Box as Box13, Text as Text14, useInput as useInput5 } from "ink";
5966
6449
  import { useState as useState7, useCallback as useCallback6, useEffect as useEffect6, useMemo as useMemo2 } from "react";
5967
6450
  import asciichart from "asciichart";
5968
6451
  import os from "os";
5969
- import { join as join7 } from "path";
6452
+ import { join as join8 } from "path";
5970
6453
  import { jsx as jsx13, jsxs as jsxs13 } from "react/jsx-runtime";
5971
- var SETTINGS_PATH = join7(os.homedir(), ".dskcode", "settings.json");
6454
+ var SETTINGS_PATH = join8(os.homedir(), ".dskcode", "settings.json");
5972
6455
  function toFileUrl(p) {
5973
6456
  const norm = p.replace(/\\/g, "/");
5974
6457
  if (process.platform === "win32") {
@@ -6302,8 +6785,8 @@ function renderDetail(stock, _onBack, prices, countdown = 10, currentTime) {
6302
6785
  }
6303
6786
 
6304
6787
  // src/utils/scan-files.ts
6305
- import { readdir as readdir5 } from "fs/promises";
6306
- import { join as join8, relative as relative7 } from "path";
6788
+ import { readdir as readdir6 } from "fs/promises";
6789
+ import { join as join9, relative as relative7 } from "path";
6307
6790
  var IGNORE_DIRS = /* @__PURE__ */ new Set([
6308
6791
  "node_modules",
6309
6792
  ".git",
@@ -6354,7 +6837,7 @@ async function scanProjectFiles(baseDir, dir) {
6354
6837
  const currentDir = dir ?? baseDir;
6355
6838
  let entries;
6356
6839
  try {
6357
- entries = await readdir5(currentDir, { withFileTypes: true });
6840
+ entries = await readdir6(currentDir, { withFileTypes: true });
6358
6841
  } catch {
6359
6842
  return [];
6360
6843
  }
@@ -6368,12 +6851,12 @@ async function scanProjectFiles(baseDir, dir) {
6368
6851
  } else if (entry.isFile()) {
6369
6852
  const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
6370
6853
  if (SOURCE_EXTS.has(ext)) {
6371
- files.push(relative7(baseDir, join8(currentDir, name)));
6854
+ files.push(relative7(baseDir, join9(currentDir, name)));
6372
6855
  }
6373
6856
  }
6374
6857
  }
6375
6858
  const nested = await Promise.all(
6376
- dirs.map((d) => scanProjectFiles(baseDir, join8(currentDir, d)))
6859
+ dirs.map((d) => scanProjectFiles(baseDir, join9(currentDir, d)))
6377
6860
  );
6378
6861
  for (const n of nested) {
6379
6862
  files.push(...n);
@@ -6461,10 +6944,10 @@ compdef _dskcode_completion dskcode`);
6461
6944
  program2.command("stock").description("\u67E5\u770B\u81EA\u9009\u80A1\u5B9E\u65F6\u884C\u60C5").argument("[codes...]", "\u80A1\u7968\u4EE3\u7801\uFF08\u7A7A\u683C\u5206\u9694\uFF09\uFF0C\u5982 513090 600519").action(async function(codes) {
6462
6945
  const _ctx = this.dskcodeCtx;
6463
6946
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "~";
6464
- const globalConfigPath = join9(home, ".dskcode", "settings.json");
6947
+ const globalConfigPath = join10(home, ".dskcode", "settings.json");
6465
6948
  let globalConfigHasStock = false;
6466
6949
  try {
6467
- const raw = await readFile10(globalConfigPath, "utf-8");
6950
+ const raw = await readFile11(globalConfigPath, "utf-8");
6468
6951
  const parsed = JSON.parse(raw);
6469
6952
  const stock = parsed.stock;
6470
6953
  globalConfigHasStock = Array.isArray(stock?.symbols) && stock.symbols.length > 0;
@@ -6629,10 +7112,18 @@ var ExitCode = {
6629
7112
  // src/index.ts
6630
7113
  var sigintCount = 0;
6631
7114
  var sigintTimer = null;
7115
+ async function gracefulExit(code) {
7116
+ try {
7117
+ await CostTracker.flushAll();
7118
+ } catch {
7119
+ }
7120
+ process.exit(code);
7121
+ }
6632
7122
  process.on("SIGINT", () => {
6633
7123
  sigintCount++;
6634
7124
  if (sigintCount >= 2) {
6635
- process.exit(ExitCode.SIGINT);
7125
+ void gracefulExit(ExitCode.SIGINT);
7126
+ return;
6636
7127
  }
6637
7128
  process.stdout.write("\n \u26A0 \u518D\u6309\u4E00\u6B21 Ctrl+C \u9000\u51FA dskcode\n");
6638
7129
  if (sigintTimer) clearTimeout(sigintTimer);
@@ -6640,18 +7131,25 @@ process.on("SIGINT", () => {
6640
7131
  sigintCount = 0;
6641
7132
  }, 1500);
6642
7133
  });
7134
+ process.on("SIGTERM", () => {
7135
+ void gracefulExit(ExitCode.SIGINT);
7136
+ });
6643
7137
  var program = createCli();
6644
7138
  try {
6645
7139
  await program.parseAsync(process.argv);
7140
+ await CostTracker.flushAll();
6646
7141
  } catch (err) {
6647
7142
  const error = err;
6648
7143
  if (error.code === "commander.helpDisplayed" || error.code === "commander.version") {
7144
+ await CostTracker.flushAll();
6649
7145
  process.exit(error.exitCode ?? ExitCode.SUCCESS);
6650
7146
  }
6651
7147
  if (typeof error.exitCode === "number") {
7148
+ await CostTracker.flushAll();
6652
7149
  process.exit(error.exitCode);
6653
7150
  }
6654
7151
  console.error(String(err));
7152
+ await CostTracker.flushAll();
6655
7153
  process.exit(ExitCode.GENERAL_ERROR);
6656
7154
  }
6657
7155
  //# sourceMappingURL=index.js.map