@liziy/session-queue 0.2.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.
- package/README.md +149 -0
- package/index.ts +1873 -0
- package/package.json +29 -0
package/index.ts
ADDED
|
@@ -0,0 +1,1873 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @liziy/session-queue —— pi 的线性队列会话管理 + 文件变更记忆回滚
|
|
3
|
+
* =============================================================================
|
|
4
|
+
* 把 pi 的会话从「树」改为「队列」:双 Esc 唤起线性列表,回滚直接截断后续记录。
|
|
5
|
+
* 配合 JIT 工具拦截:edit/write/bash 执行前/后拍照存证,记录每个 turn 修改的文件。
|
|
6
|
+
* 回滚时弹窗确认 + 脏检查,自动还原文件内容。
|
|
7
|
+
*
|
|
8
|
+
* 核心特性:
|
|
9
|
+
* - 队列视图:10 轮滚动窗口,.slice(-10) 一刀切
|
|
10
|
+
* - JIT 拦截:pi.on("tool_call"/"tool_result") 按需拍照,0 启动开销
|
|
11
|
+
* - 内容寻址:相同内容只存一次(sha256 命名)
|
|
12
|
+
* - 脏检查:回滚前比对当前文件 hash,防止覆盖用户手动修改
|
|
13
|
+
* - 工作区白名单:默认休眠,/rollback enable 启用
|
|
14
|
+
* - snapshot GC:标记-扫描回收孤儿快照,防止磁盘无限增长
|
|
15
|
+
* - bash 追踪:rm/del/mv/重定向 的文件操作(多文件、glob 展开)
|
|
16
|
+
* - 零依赖
|
|
17
|
+
*
|
|
18
|
+
* 存储路径:
|
|
19
|
+
* config.json 工作区列表
|
|
20
|
+
* queue-{sessionId}.json 队列(内嵌 changes)
|
|
21
|
+
* snapshots/{hash}.content 内容寻址快照
|
|
22
|
+
*
|
|
23
|
+
* 命令:
|
|
24
|
+
* /rollback 唤起队列选择器(回滚弹窗)
|
|
25
|
+
* /rollback enable 启用当前目录的变更记录
|
|
26
|
+
* /rollback disable 暂停当前目录的变更记录
|
|
27
|
+
* /rollback list 列出当前已启用的工作区
|
|
28
|
+
* /rollback gc 手动回收孤儿快照
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
32
|
+
import * as fs from "node:fs";
|
|
33
|
+
import * as path from "node:path";
|
|
34
|
+
import { createHash } from "node:crypto";
|
|
35
|
+
|
|
36
|
+
// =============================================================================
|
|
37
|
+
// 常量
|
|
38
|
+
// =============================================================================
|
|
39
|
+
|
|
40
|
+
const MAX_TURNS = 10;
|
|
41
|
+
const WRITE_TOOLS = new Set(["edit", "write"]);
|
|
42
|
+
const EMPTY_HASH = "0000000000000000000000000000000000000000000000000000000000000000";
|
|
43
|
+
const GC_THROTTLE_MS = 5 * 60 * 1000; // 自动 GC 节流:5 分钟
|
|
44
|
+
const MAX_QUEUE_FILES = 20; // GC 时保留的最近 queue 文件数量
|
|
45
|
+
|
|
46
|
+
// =============================================================================
|
|
47
|
+
// 类型
|
|
48
|
+
// =============================================================================
|
|
49
|
+
|
|
50
|
+
interface FileChange {
|
|
51
|
+
path: string;
|
|
52
|
+
action: "edit" | "write" | "create" | "delete";
|
|
53
|
+
beforeHash: string;
|
|
54
|
+
afterHash: string;
|
|
55
|
+
viaBash?: boolean; // 由 bash 命令推断(可能不完整)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface QueueEntry {
|
|
59
|
+
turnIndex: number;
|
|
60
|
+
text: string;
|
|
61
|
+
timestamp: string;
|
|
62
|
+
changes: FileChange[];
|
|
63
|
+
residual?: boolean; // 跳过冲突时保留的未回滚残留
|
|
64
|
+
sessionEntryId?: string; // 对应 Session Tree 中 user 消息的 entry ID
|
|
65
|
+
resultText?: string; // assistant 的最后回复文本(当前节点展示用)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface QueueData {
|
|
69
|
+
version: 1;
|
|
70
|
+
sessionId: string;
|
|
71
|
+
entries: QueueEntry[];
|
|
72
|
+
currentIndex: number;
|
|
73
|
+
lastGcAt?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface Config {
|
|
77
|
+
workspaces: string[];
|
|
78
|
+
followSessionTree?: boolean; // 是否在 Session Tree 导航时自动回滚队列
|
|
79
|
+
lang?: Lang; // 语言:'zh' | 'en',默认 'zh'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// =============================================================================
|
|
83
|
+
// 全局状态(每个 pi 进程内单例)
|
|
84
|
+
// =============================================================================
|
|
85
|
+
|
|
86
|
+
const EXT_DIR = path.join(process.env.USERPROFILE || process.env.HOME || "", ".pi/agent/extensions/session-queue");
|
|
87
|
+
const SNAPSHOT_DIR = path.join(EXT_DIR, "snapshots");
|
|
88
|
+
const CONFIG_FILE = path.join(EXT_DIR, "config.json");
|
|
89
|
+
|
|
90
|
+
let activeWorkspace: string | null = null;
|
|
91
|
+
let currentSessionId: string | null = null;
|
|
92
|
+
let turnIndex = 0;
|
|
93
|
+
let followSessionTreeEnabled = true; // 从 config 加载;默认开启
|
|
94
|
+
|
|
95
|
+
// JIT 拦截状态:当前 turn 内
|
|
96
|
+
const beforeMap = new Map<string, string | null>(); // path → 第一次拍照(turn 开始时的状态)
|
|
97
|
+
const afterMap = new Map<string, string | null>(); // path → 最新一次读取(每次 tool_result 更新)
|
|
98
|
+
const bashTrackedPaths = new Set<string>(); // bash 分支追踪的路径(供 onToolResult 回填 after)
|
|
99
|
+
const turnChanges: FileChange[] = [];
|
|
100
|
+
|
|
101
|
+
// 锁:防止 turn_end 和回滚并发
|
|
102
|
+
let isProcessing = false;
|
|
103
|
+
|
|
104
|
+
// 上一个已落盘 entry 所属的 user 消息 id;用于在 turn_end 时判断是否为新提问。
|
|
105
|
+
// 在 turn_end(而非 turn_start)时检测,因为此时 branch 一定已包含当前 user 消息。
|
|
106
|
+
let lastFlushedUserMsgId: string | null = null;
|
|
107
|
+
|
|
108
|
+
// GC 节流时间戳
|
|
109
|
+
let lastGcRun = 0;
|
|
110
|
+
|
|
111
|
+
// ANSI 转义码(仅给“点”上色,文字保持默认色)
|
|
112
|
+
const ANSI = {
|
|
113
|
+
reset: "\x1b[0m",
|
|
114
|
+
cyan: "\x1b[36m", // 青(天蓝)
|
|
115
|
+
green: "\x1b[32m", // 亮绿
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// =============================================================================
|
|
119
|
+
// i18n
|
|
120
|
+
// =============================================================================
|
|
121
|
+
|
|
122
|
+
type Lang = "zh" | "en";
|
|
123
|
+
type I18nValue = string | ((...args: any[]) => string);
|
|
124
|
+
|
|
125
|
+
const I18N: Record<Lang, Record<string, I18nValue>> = {
|
|
126
|
+
zh: {
|
|
127
|
+
// === 状态栏 ===
|
|
128
|
+
"status.sync": `${ANSI.green}●${ANSI.reset} 同步`,
|
|
129
|
+
"status.record": `${ANSI.cyan}●${ANSI.reset} 记录`,
|
|
130
|
+
|
|
131
|
+
// === 主菜单 ===
|
|
132
|
+
"menu.rollbackHistory": (n: number) => `📋 回滚历史 (${n} 个变更点)`,
|
|
133
|
+
"menu.manageWorkspaces": "📂 管理工作区",
|
|
134
|
+
"menu.clearQueue": "🗑️ 清空当前队列",
|
|
135
|
+
"menu.deleteAndClear": "⏸ 删除并清空",
|
|
136
|
+
"menu.enableCurrent": "✅ 启用当前工作区",
|
|
137
|
+
"menu.followSystemTree": (on: boolean) => `🔗 跟随系统Tree (${on ? "开" : "关"})`,
|
|
138
|
+
"menu.collectOrphans": "🧹 回收孤儿快照",
|
|
139
|
+
"menu.exit": "❌ 退出菜单",
|
|
140
|
+
|
|
141
|
+
// === 菜单子项 ===
|
|
142
|
+
"menu.back": "← 返回",
|
|
143
|
+
"menu.workspaceCurrent": " ← 当前",
|
|
144
|
+
"menu.pauseThisWorkspace": "⏸ 暂停此工作区",
|
|
145
|
+
"menu.enableThisWorkspace": "✅ 启用此工作区",
|
|
146
|
+
"menu.removeFromList": "🗑️ 从列表中删除",
|
|
147
|
+
|
|
148
|
+
// === 选择器标题 ===
|
|
149
|
+
"select.mainTitle": "会话队列回滚",
|
|
150
|
+
"select.workspacesTitle": (n: number) => `已启用的工作区 (${n})`,
|
|
151
|
+
"select.workspaceActionTitle": (ws: string) => `工作区: ${ws}`,
|
|
152
|
+
"select.rollbackTitle": (n: number) => `回滚到哪个检查点?(${n} 个检查点 + 当前状态)`,
|
|
153
|
+
|
|
154
|
+
// === 确认弹窗 ===
|
|
155
|
+
"confirm.rollbackTitle": (text: string) => `确认回滚到「${text}」?`,
|
|
156
|
+
"confirm.willProcess": (n: number) => `将处理 ${n} 个文件变更:`,
|
|
157
|
+
"confirm.conflictsWarn": (n: number) => `⚠ ${n} 个文件被外部修改:`,
|
|
158
|
+
"confirm.btnForceOverwrite": "✅ 强制覆盖并回滚",
|
|
159
|
+
"confirm.btnSkipConflicts": "⏭ 跳过冲突,回滚其他",
|
|
160
|
+
"confirm.btnConfirm": "✅ 确定回滚",
|
|
161
|
+
"confirm.btnCancel": "❌ 取消",
|
|
162
|
+
|
|
163
|
+
// === 预览格式 ===
|
|
164
|
+
"format.restore": (n: number, files: string) => ` 还原(${n}):${files}`,
|
|
165
|
+
"format.create": (n: number, files: string) => ` 新增(${n}):${files}`,
|
|
166
|
+
"format.remove": (n: number, files: string) => ` 删除(${n}):${files}`,
|
|
167
|
+
"format.noFileChanges": " (无文件变更)",
|
|
168
|
+
"format.bashTag": " ⚠bash",
|
|
169
|
+
"format.currentNode": " ← 当前节点",
|
|
170
|
+
"format.currentState": "当前状态",
|
|
171
|
+
"format.restored": (n: number) => `还原 ${n}`,
|
|
172
|
+
"format.created": (n: number) => `新增 ${n}`,
|
|
173
|
+
"format.removed": (n: number) => `删除 ${n}`,
|
|
174
|
+
"format.skippedConflicts": (n: number) => `,跳过 ${n} 个冲突`,
|
|
175
|
+
"format.skipped": (n: number) => `,跳过 ${n} 个`,
|
|
176
|
+
"format.missingSnapshot": (n: number) => `(其中 ${n} 个快照丢失,请手动检查)`,
|
|
177
|
+
"format.noChange": "无变更",
|
|
178
|
+
|
|
179
|
+
// === 通知 ===
|
|
180
|
+
"notify.needInteractive": "rollback 需要交互模式",
|
|
181
|
+
"notify.noSession": "无活跃 session",
|
|
182
|
+
"notify.processing": "正在处理上一轮,请稍候",
|
|
183
|
+
"notify.emptyQueue": "队列为空",
|
|
184
|
+
"notify.onlyTurn0": "只有 Turn 0,没有可回滚的记录",
|
|
185
|
+
"notify.noCheckpoints": "没有可回滚的检查点",
|
|
186
|
+
"notify.currentNode": "当前节点不可回滚,请选其他 turn",
|
|
187
|
+
"notify.rolledBack": (text: string) => `已回滚到「${text}」`,
|
|
188
|
+
"notify.rolledBackWith": (text: string, parts: string) => `已回滚到「${text}」:${parts}`,
|
|
189
|
+
"notify.autoRollback": (text: string) => `↩️ Session Tree 导航 → 自动回滚到「${text}」`,
|
|
190
|
+
"notify.autoRollbackWith": (text: string, parts: string) => `↩️ Session Tree 导航 → 自动回滚到「${text}」:${parts}`,
|
|
191
|
+
"notify.queueCleared": (snap: number, queue?: number) => `✅ 队列已清空,回收 ${snap} 个孤儿快照${queue ? `,清理 ${queue} 个旧 queue 文件` : ""}`,
|
|
192
|
+
"notify.deletedAndCleared": (snap: number, queue?: number) => `⏸ 已删除并清空当前 session 的记录,回收 ${snap} 个孤儿快照${queue ? `,清理 ${queue} 个旧 queue 文件` : ""}`,
|
|
193
|
+
"notify.paused": (ws: string, snap: number, queue?: number) => `⏸ 已暂停:${ws},回收 ${snap} 个孤儿快照${queue ? `,清理 ${queue} 个旧 queue 文件` : ""}`,
|
|
194
|
+
"notify.removedFromList": (ws: string) => `🗑️ 已从列表中删除:${ws}`,
|
|
195
|
+
"notify.enabledTracking": (path: string) => `✅ 已启用变更记录:${path}`,
|
|
196
|
+
"notify.enabled": (ws: string) => `✅ 已启用:${ws}`,
|
|
197
|
+
"notify.noWorkspace": "当前工作区未启用",
|
|
198
|
+
"notify.workspaceActive": "当前工作区已启用",
|
|
199
|
+
"notify.otherActive": "已有其他工作区启用中,请先暂停",
|
|
200
|
+
"notify.noWorkspaces": "没有已启用的工作区",
|
|
201
|
+
"notify.queueAlreadyEmpty": "队列已经为空",
|
|
202
|
+
"notify.followOn": "🔗 已开启:Session Tree 导航将自动回滚队列",
|
|
203
|
+
"notify.followOff": "⏸ 已关闭:Session Tree 导航不会自动回滚队列",
|
|
204
|
+
"notify.langSwitched": (lang: string) => `🌐 已切换语言:${lang}(zh=中文,en=English)`,
|
|
205
|
+
"notify.langInvalid": (lang: string) => `⚠ 无效语言代码:${lang}(可选:zh、en)`,
|
|
206
|
+
"notify.gcResult": (scanned: number, deleted: number, live: number, queueDeleted?: number, queueKept?: number) =>
|
|
207
|
+
`🧹 扫描 ${scanned} 个快照,删除 ${deleted} 个孤儿,存活 ${live} 个` +
|
|
208
|
+
`${queueDeleted ? `;清理 ${queueDeleted} 个旧 queue 文件,保留 ${queueKept ?? 0} 个` : ""}`,
|
|
209
|
+
|
|
210
|
+
// === Debug ===
|
|
211
|
+
"debug.fromExtension": "[SQ-debug] session_tree: fromExtension=true 跳过",
|
|
212
|
+
"debug.activeWorkspaceNull": (sess: string) => `[SQ-debug] session_tree: activeWorkspace=null, currentSession=${sess}`,
|
|
213
|
+
"debug.toggleOff": "[SQ-debug] session_tree: toggle off",
|
|
214
|
+
"debug.isProcessing": "[SQ-debug] session_tree: isProcessing=true",
|
|
215
|
+
"debug.noNav": (id: string) => `[SQ-debug] session_tree: 无实际导航 (newLeafId===oldLeafId=${id})`,
|
|
216
|
+
"debug.emptyQueue": "[SQ-debug] session_tree: queue.entries=[]",
|
|
217
|
+
"debug.nav": (src: string, navId: string, leafId: string, qLen: number, qCi: number, sess: string) =>
|
|
218
|
+
`[SQ-debug] nav: ${src}=${navId}, leafId=${leafId}, queue(${qLen}e/${qCi}ci), session=${sess}`,
|
|
219
|
+
"debug.noNavMsg": "[SQ-debug] session_tree: 未找到 nav user msg",
|
|
220
|
+
"debug.navNoMatch": (navId: string, entries: string) => `[SQ-debug] session_tree: nav=${navId} 未匹配 entries=${entries}`,
|
|
221
|
+
"debug.idxOutOfRange": (idx: number, len: number) => `[SQ-debug] session_tree: idx=${idx} >= ${len}(超出队列范围),跳过`,
|
|
222
|
+
"debug.rollbackFired": (idx: number, len: number) => `[SQ-debug] session_tree 回滚 idx=${idx} entries=${len}`,
|
|
223
|
+
"debug.exception": (msg: string) => `[SQ-debug] session_tree 异常: ${msg}`,
|
|
224
|
+
|
|
225
|
+
// === 命令描述 ===
|
|
226
|
+
"cmd.description": "会话队列回滚(单入口菜单);/rollback zh|en 切换语言",
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
en: {
|
|
230
|
+
// === Status bar ===
|
|
231
|
+
"status.sync": `${ANSI.green}●${ANSI.reset} Sync`,
|
|
232
|
+
"status.record": `${ANSI.cyan}●${ANSI.reset} Record`,
|
|
233
|
+
|
|
234
|
+
// === Main menu ===
|
|
235
|
+
"menu.rollbackHistory": (n: number) => `📋 Rollback History (${n} checkpoints)`,
|
|
236
|
+
"menu.manageWorkspaces": "📂 Manage Workspaces",
|
|
237
|
+
"menu.clearQueue": "🗑️ Clear Current Queue",
|
|
238
|
+
"menu.deleteAndClear": "⏸ Delete & Clear",
|
|
239
|
+
"menu.enableCurrent": "✅ Enable Current Workspace",
|
|
240
|
+
"menu.followSystemTree": (on: boolean) => `🔗 Follow System Tree (${on ? "on" : "off"})`,
|
|
241
|
+
"menu.collectOrphans": "🧹 Collect Orphan Snapshots",
|
|
242
|
+
"menu.exit": "❌ Exit Menu",
|
|
243
|
+
|
|
244
|
+
// === Submenu ===
|
|
245
|
+
"menu.back": "← Back",
|
|
246
|
+
"menu.workspaceCurrent": " ← current",
|
|
247
|
+
"menu.pauseThisWorkspace": "⏸ Pause This Workspace",
|
|
248
|
+
"menu.enableThisWorkspace": "✅ Enable This Workspace",
|
|
249
|
+
"menu.removeFromList": "🗑️ Remove From List",
|
|
250
|
+
|
|
251
|
+
// === Select titles ===
|
|
252
|
+
"select.mainTitle": "Session Queue Rollback",
|
|
253
|
+
"select.workspacesTitle": (n: number) => `Enabled Workspaces (${n})`,
|
|
254
|
+
"select.workspaceActionTitle": (ws: string) => `Workspace: ${ws}`,
|
|
255
|
+
"select.rollbackTitle": (n: number) => `Rollback to which checkpoint? (${n} checkpoints + current state)`,
|
|
256
|
+
|
|
257
|
+
// === Confirm dialog ===
|
|
258
|
+
"confirm.rollbackTitle": (text: string) => `Confirm rollback to "${text}"?`,
|
|
259
|
+
"confirm.willProcess": (n: number) => `Will process ${n} file change(s):`,
|
|
260
|
+
"confirm.conflictsWarn": (n: number) => `⚠ ${n} file(s) externally modified:`,
|
|
261
|
+
"confirm.btnForceOverwrite": "✅ Force overwrite and rollback",
|
|
262
|
+
"confirm.btnSkipConflicts": "⏭ Skip conflicts, rollback others",
|
|
263
|
+
"confirm.btnConfirm": "✅ Confirm Rollback",
|
|
264
|
+
"confirm.btnCancel": "❌ Cancel",
|
|
265
|
+
|
|
266
|
+
// === Preview format ===
|
|
267
|
+
"format.restore": (n: number, files: string) => ` Restore (${n}): ${files}`,
|
|
268
|
+
"format.create": (n: number, files: string) => ` Create (${n}): ${files}`,
|
|
269
|
+
"format.remove": (n: number, files: string) => ` Remove (${n}): ${files}`,
|
|
270
|
+
"format.noFileChanges": " (no file changes)",
|
|
271
|
+
"format.bashTag": " ⚠bash",
|
|
272
|
+
"format.currentNode": " ← current node",
|
|
273
|
+
"format.currentState": "Current State",
|
|
274
|
+
"format.restored": (n: number) => `Restored ${n}`,
|
|
275
|
+
"format.created": (n: number) => `Created ${n}`,
|
|
276
|
+
"format.removed": (n: number) => `Removed ${n}`,
|
|
277
|
+
"format.skippedConflicts": (n: number) => `, skipped ${n} conflict(s)`,
|
|
278
|
+
"format.skipped": (n: number) => `, skipped ${n}`,
|
|
279
|
+
"format.missingSnapshot": (n: number) => `(${n} snapshot(s) missing, please check manually)`,
|
|
280
|
+
"format.noChange": "no changes",
|
|
281
|
+
|
|
282
|
+
// === Notifications ===
|
|
283
|
+
"notify.needInteractive": "rollback requires interactive mode",
|
|
284
|
+
"notify.noSession": "No active session",
|
|
285
|
+
"notify.processing": "Processing previous turn, please wait",
|
|
286
|
+
"notify.emptyQueue": "Queue is empty",
|
|
287
|
+
"notify.onlyTurn0": "Only Turn 0, no rollback records",
|
|
288
|
+
"notify.noCheckpoints": "No checkpoints to roll back to",
|
|
289
|
+
"notify.currentNode": "Current node cannot be rolled back, choose another turn",
|
|
290
|
+
"notify.rolledBack": (text: string) => `Rolled back to "${text}"`,
|
|
291
|
+
"notify.rolledBackWith": (text: string, parts: string) => `Rolled back to "${text}": ${parts}`,
|
|
292
|
+
"notify.autoRollback": (text: string) => `↩️ Session Tree nav → auto rollback to "${text}"`,
|
|
293
|
+
"notify.autoRollbackWith": (text: string, parts: string) => `↩️ Session Tree nav → auto rollback to "${text}": ${parts}`,
|
|
294
|
+
"notify.queueCleared": (snap: number, queue?: number) => `✅ Queue cleared, collected ${snap} orphan snapshot(s)${queue ? `, cleaned ${queue} old queue file(s)` : ""}`,
|
|
295
|
+
"notify.deletedAndCleared": (snap: number, queue?: number) => `⏸ Deleted and cleared current session's records, collected ${snap} orphan snapshot(s)${queue ? `, cleaned ${queue} old queue file(s)` : ""}`,
|
|
296
|
+
"notify.paused": (ws: string, snap: number, queue?: number) => `⏸ Paused: ${ws}, collected ${snap} orphan snapshot(s)${queue ? `, cleaned ${queue} old queue file(s)` : ""}`,
|
|
297
|
+
"notify.removedFromList": (ws: string) => `🗑️ Removed from list: ${ws}`,
|
|
298
|
+
"notify.enabledTracking": (path: string) => `✅ Tracking enabled: ${path}`,
|
|
299
|
+
"notify.enabled": (ws: string) => `✅ Enabled: ${ws}`,
|
|
300
|
+
"notify.noWorkspace": "Current workspace not enabled",
|
|
301
|
+
"notify.workspaceActive": "Current workspace already enabled",
|
|
302
|
+
"notify.otherActive": "Another workspace is active, please pause it first",
|
|
303
|
+
"notify.noWorkspaces": "No enabled workspaces",
|
|
304
|
+
"notify.queueAlreadyEmpty": "Queue is already empty",
|
|
305
|
+
"notify.followOn": "🔗 Enabled: Session Tree navigation will auto-rollback queue",
|
|
306
|
+
"notify.followOff": "⏸ Disabled: Session Tree navigation will not auto-rollback queue",
|
|
307
|
+
"notify.langSwitched": (lang: string) => `🌐 Language switched: ${lang} (zh=中文, en=English)`,
|
|
308
|
+
"notify.langInvalid": (lang: string) => `⚠ Invalid language code: ${lang} (options: zh, en)`,
|
|
309
|
+
"notify.gcResult": (scanned: number, deleted: number, live: number, queueDeleted?: number, queueKept?: number) =>
|
|
310
|
+
`🧹 Scanned ${scanned} snapshot(s), deleted ${deleted} orphan(s), ${live} live` +
|
|
311
|
+
`${queueDeleted ? `; cleaned ${queueDeleted} old queue file(s), kept ${queueKept ?? 0}` : ""}`,
|
|
312
|
+
|
|
313
|
+
// === Debug ===
|
|
314
|
+
"debug.fromExtension": "[SQ-debug] session_tree: fromExtension=true skipped",
|
|
315
|
+
"debug.activeWorkspaceNull": (sess: string) => `[SQ-debug] session_tree: activeWorkspace=null, currentSession=${sess}`,
|
|
316
|
+
"debug.toggleOff": "[SQ-debug] session_tree: toggle off",
|
|
317
|
+
"debug.isProcessing": "[SQ-debug] session_tree: isProcessing=true",
|
|
318
|
+
"debug.noNav": (id: string) => `[SQ-debug] session_tree: no actual nav (newLeafId===oldLeafId=${id})`,
|
|
319
|
+
"debug.emptyQueue": "[SQ-debug] session_tree: queue.entries=[]",
|
|
320
|
+
"debug.nav": (src: string, navId: string, leafId: string, qLen: number, qCi: number, sess: string) =>
|
|
321
|
+
`[SQ-debug] nav: ${src}=${navId}, leafId=${leafId}, queue(${qLen}e/${qCi}ci), session=${sess}`,
|
|
322
|
+
"debug.noNavMsg": "[SQ-debug] session_tree: no nav user msg found",
|
|
323
|
+
"debug.navNoMatch": (navId: string, entries: string) => `[SQ-debug] session_tree: nav=${navId} not matched entries=${entries}`,
|
|
324
|
+
"debug.idxOutOfRange": (idx: number, len: number) => `[SQ-debug] session_tree: idx=${idx} >= ${len} (out of range), skipped`,
|
|
325
|
+
"debug.rollbackFired": (idx: number, len: number) => `[SQ-debug] session_tree rollback idx=${idx} entries=${len}`,
|
|
326
|
+
"debug.exception": (msg: string) => `[SQ-debug] session_tree exception: ${msg}`,
|
|
327
|
+
|
|
328
|
+
// === Command description ===
|
|
329
|
+
"cmd.description": "Session queue rollback (single-entry menu); /rollback zh|en to switch language",
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
let currentLang: Lang = "zh";
|
|
334
|
+
|
|
335
|
+
function setLang(lang: Lang): void {
|
|
336
|
+
if (I18N[lang]) currentLang = lang;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function t(key: string, ...args: any[]): string {
|
|
340
|
+
const entry = I18N[currentLang]?.[key] ?? I18N.zh[key] ?? key;
|
|
341
|
+
return typeof entry === "function" ? entry(...args) : entry;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// 状态文本(3 态:未启用 / 仅记录 / 记录+同步)
|
|
345
|
+
function getStatusText(): string | undefined {
|
|
346
|
+
if (!activeWorkspace) return undefined; // 状态 1:未启用 → 不显示
|
|
347
|
+
if (followSessionTreeEnabled) return t("status.sync"); // 状态 3:亮绿点
|
|
348
|
+
return t("status.record"); // 状态 2:青色点
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// =============================================================================
|
|
352
|
+
// 工具:路径与 IO
|
|
353
|
+
// =============================================================================
|
|
354
|
+
|
|
355
|
+
function ensureDirs(): void {
|
|
356
|
+
try {
|
|
357
|
+
fs.mkdirSync(SNAPSHOT_DIR, { recursive: true });
|
|
358
|
+
} catch {
|
|
359
|
+
// 忽略:可能权限问题
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function queueFilePath(sessionId: string): string {
|
|
364
|
+
return path.join(EXT_DIR, `queue-${sessionId}.json`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// 原子写入
|
|
368
|
+
function atomicWriteFile(filePath: string, content: string): void {
|
|
369
|
+
const tmpPath = filePath + ".tmp";
|
|
370
|
+
fs.writeFileSync(tmpPath, content, "utf-8");
|
|
371
|
+
fs.renameSync(tmpPath, filePath);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function readContentIfExists(filePath: string): string | null {
|
|
375
|
+
try {
|
|
376
|
+
return fs.readFileSync(filePath, "utf-8");
|
|
377
|
+
} catch {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function isFile(filePath: string): boolean {
|
|
383
|
+
try {
|
|
384
|
+
return fs.statSync(filePath).isFile();
|
|
385
|
+
} catch {
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function isDirectory(filePath: string): boolean {
|
|
391
|
+
try {
|
|
392
|
+
return fs.statSync(filePath).isDirectory();
|
|
393
|
+
} catch {
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function sha256(content: string | null): string {
|
|
399
|
+
if (content === null) return EMPTY_HASH;
|
|
400
|
+
return createHash("sha256").update(content, "utf-8").digest("hex");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function snapshotPath(hash: string): string {
|
|
404
|
+
return path.join(SNAPSHOT_DIR, `${hash}.content`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function readSnapshot(hash: string): string | null {
|
|
408
|
+
if (hash === EMPTY_HASH) return null;
|
|
409
|
+
return readContentIfExists(snapshotPath(hash));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function writeSnapshotIfMissing(hash: string, content: string | null): void {
|
|
413
|
+
if (hash === EMPTY_HASH || content === null) return;
|
|
414
|
+
const p = snapshotPath(hash);
|
|
415
|
+
if (!fs.existsSync(p)) {
|
|
416
|
+
try {
|
|
417
|
+
atomicWriteFile(p, content);
|
|
418
|
+
} catch {
|
|
419
|
+
// 忽略写入失败
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function isInsideWorkspace(targetPath: string, workspace: string): boolean {
|
|
425
|
+
try {
|
|
426
|
+
const target = path.resolve(targetPath);
|
|
427
|
+
const ws = path.resolve(workspace);
|
|
428
|
+
// Windows 盘符不同 → 直接 false
|
|
429
|
+
if (target[0] !== ws[0]) return false;
|
|
430
|
+
return target.startsWith(ws + path.sep) || target === ws;
|
|
431
|
+
} catch {
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// 简单 glob 展开(零依赖):支持 basename 中的 * 和 ?
|
|
437
|
+
function expandGlob(pattern: string): string[] {
|
|
438
|
+
if (!pattern.includes("*") && !pattern.includes("?")) return [pattern];
|
|
439
|
+
try {
|
|
440
|
+
const dir = path.dirname(pattern);
|
|
441
|
+
const base = path.basename(pattern);
|
|
442
|
+
const reSrc = "^" + base.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$";
|
|
443
|
+
const re = new RegExp(reSrc);
|
|
444
|
+
const entries = fs.readdirSync(dir);
|
|
445
|
+
return entries.filter((e) => re.test(e)).map((e) => path.join(dir, e));
|
|
446
|
+
} catch {
|
|
447
|
+
return [pattern];
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// =============================================================================
|
|
452
|
+
// 工具:队列与配置 IO
|
|
453
|
+
// =============================================================================
|
|
454
|
+
|
|
455
|
+
function loadQueue(sessionId: string): QueueData {
|
|
456
|
+
const file = queueFilePath(sessionId);
|
|
457
|
+
try {
|
|
458
|
+
const data = fs.readFileSync(file, "utf-8");
|
|
459
|
+
const parsed = JSON.parse(data) as QueueData;
|
|
460
|
+
if (parsed.version === 1) return parsed;
|
|
461
|
+
} catch {
|
|
462
|
+
// 文件不存在或解析失败
|
|
463
|
+
}
|
|
464
|
+
return {
|
|
465
|
+
version: 1,
|
|
466
|
+
sessionId,
|
|
467
|
+
entries: [],
|
|
468
|
+
currentIndex: -1,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function saveQueue(data: QueueData): void {
|
|
473
|
+
const file = queueFilePath(data.sessionId);
|
|
474
|
+
try {
|
|
475
|
+
atomicWriteFile(file, JSON.stringify(data, null, 2));
|
|
476
|
+
} catch {
|
|
477
|
+
// 忽略
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function loadConfig(): Config {
|
|
482
|
+
try {
|
|
483
|
+
const data = fs.readFileSync(CONFIG_FILE, "utf-8");
|
|
484
|
+
const cfg = JSON.parse(data) as Config;
|
|
485
|
+
if (typeof cfg.followSessionTree === "boolean") {
|
|
486
|
+
followSessionTreeEnabled = cfg.followSessionTree;
|
|
487
|
+
}
|
|
488
|
+
if (cfg.lang) {
|
|
489
|
+
setLang(cfg.lang);
|
|
490
|
+
}
|
|
491
|
+
return cfg;
|
|
492
|
+
} catch {
|
|
493
|
+
return { workspaces: [] };
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function saveConfig(config: Config): void {
|
|
498
|
+
try {
|
|
499
|
+
atomicWriteFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
500
|
+
} catch {
|
|
501
|
+
// 忽略
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function isWorkspaceEnabled(cwd: string): boolean {
|
|
506
|
+
const config = loadConfig();
|
|
507
|
+
return config.workspaces.some((ws) => isInsideWorkspace(cwd, ws));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function addWorkspace(cwd: string): void {
|
|
511
|
+
const config = loadConfig();
|
|
512
|
+
const resolved = path.resolve(cwd);
|
|
513
|
+
if (!config.workspaces.includes(resolved)) {
|
|
514
|
+
config.workspaces.push(resolved);
|
|
515
|
+
saveConfig(config);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function removeWorkspace(cwd: string): void {
|
|
520
|
+
const config = loadConfig();
|
|
521
|
+
const resolved = path.resolve(cwd);
|
|
522
|
+
config.workspaces = config.workspaces.filter((ws) => ws !== resolved);
|
|
523
|
+
saveConfig(config);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// =============================================================================
|
|
527
|
+
// 工具:从 input 提取文件路径
|
|
528
|
+
// =============================================================================
|
|
529
|
+
|
|
530
|
+
function extractFilePath(input: Record<string, unknown>): string | null {
|
|
531
|
+
// edit / write 工具的参数:path 或 filePath
|
|
532
|
+
const raw = (input.path as string) ?? (input.filePath as string) ?? (input.file_path as string);
|
|
533
|
+
if (!raw || typeof raw !== "string") return null;
|
|
534
|
+
return raw;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// =============================================================================
|
|
538
|
+
// snapshot GC:标记-扫描回收
|
|
539
|
+
// =============================================================================
|
|
540
|
+
|
|
541
|
+
function runGc(): { snapDeleted: number; snapScanned: number; live: number; queueDeleted: number; queueKept: number } {
|
|
542
|
+
const liveSet = new Set<string>();
|
|
543
|
+
|
|
544
|
+
let queueFiles: string[] = [];
|
|
545
|
+
try {
|
|
546
|
+
queueFiles = fs.readdirSync(EXT_DIR).filter(
|
|
547
|
+
(f) => f.startsWith("queue-") && f.endsWith(".json"),
|
|
548
|
+
);
|
|
549
|
+
} catch {
|
|
550
|
+
lastGcRun = Date.now();
|
|
551
|
+
return { snapDeleted: 0, snapScanned: 0, live: 0, queueDeleted: 0, queueKept: 0 };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Phase 1:收集所有 queue 引用的 hash 集合
|
|
555
|
+
for (const qf of queueFiles) {
|
|
556
|
+
try {
|
|
557
|
+
const data = JSON.parse(fs.readFileSync(path.join(EXT_DIR, qf), "utf-8")) as QueueData;
|
|
558
|
+
if (data.version !== 1) continue;
|
|
559
|
+
for (const e of data.entries) {
|
|
560
|
+
for (const c of e.changes) {
|
|
561
|
+
if (c.beforeHash && c.beforeHash !== EMPTY_HASH) liveSet.add(c.beforeHash);
|
|
562
|
+
if (c.afterHash && c.afterHash !== EMPTY_HASH) liveSet.add(c.afterHash);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
} catch {
|
|
566
|
+
// 单个 queue 损坏不影响整体
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Phase 2:清理孤儿快照
|
|
571
|
+
let snapFiles: string[] = [];
|
|
572
|
+
try {
|
|
573
|
+
snapFiles = fs.readdirSync(SNAPSHOT_DIR);
|
|
574
|
+
} catch { /* ignore */ }
|
|
575
|
+
|
|
576
|
+
let snapDeleted = 0;
|
|
577
|
+
let snapScanned = 0;
|
|
578
|
+
for (const sf of snapFiles) {
|
|
579
|
+
if (!sf.endsWith(".content")) continue;
|
|
580
|
+
snapScanned++;
|
|
581
|
+
const hash = sf.slice(0, -".content".length);
|
|
582
|
+
if (!liveSet.has(hash)) {
|
|
583
|
+
try {
|
|
584
|
+
fs.unlinkSync(path.join(SNAPSHOT_DIR, sf));
|
|
585
|
+
snapDeleted++;
|
|
586
|
+
} catch {
|
|
587
|
+
// 忽略删除失败
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Phase 3:清理旧 queue 文件(按 mtime 保留最近 MAX_QUEUE_FILES 个)
|
|
593
|
+
// 必须在 Phase 1 扫描完成后再删,避免 liveSet 误判
|
|
594
|
+
let queueDeleted = 0;
|
|
595
|
+
let queueKept = queueFiles.length;
|
|
596
|
+
if (queueFiles.length > MAX_QUEUE_FILES) {
|
|
597
|
+
const withMtime = queueFiles.map((name) => {
|
|
598
|
+
try {
|
|
599
|
+
return { name, mtime: fs.statSync(path.join(EXT_DIR, name)).mtimeMs };
|
|
600
|
+
} catch {
|
|
601
|
+
return { name, mtime: 0 };
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
withMtime.sort((a, b) => b.mtime - a.mtime); // 最新的在前
|
|
605
|
+
const toDelete = withMtime.slice(MAX_QUEUE_FILES);
|
|
606
|
+
for (const old of toDelete) {
|
|
607
|
+
try {
|
|
608
|
+
fs.unlinkSync(path.join(EXT_DIR, old.name));
|
|
609
|
+
queueDeleted++;
|
|
610
|
+
queueKept--;
|
|
611
|
+
} catch {
|
|
612
|
+
// 忽略删除失败
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
lastGcRun = Date.now();
|
|
618
|
+
return { snapDeleted, snapScanned, live: liveSet.size, queueDeleted, queueKept };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function maybeGc(force = false): void {
|
|
622
|
+
if (!force && Date.now() - lastGcRun < GC_THROTTLE_MS) return;
|
|
623
|
+
try {
|
|
624
|
+
runGc();
|
|
625
|
+
} catch {
|
|
626
|
+
// GC 失败不影响主流程
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// =============================================================================
|
|
631
|
+
// 核心:bash 命令文件路径解析
|
|
632
|
+
// =============================================================================
|
|
633
|
+
|
|
634
|
+
// 跳过空白与 shell 分隔符,返回下一个 token 起始位置
|
|
635
|
+
function skipWs(cmd: string, i: number): number {
|
|
636
|
+
while (i < cmd.length && /\s/.test(cmd[i])) i++;
|
|
637
|
+
return i;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// 从位置 i 读取一个 token(支持引号、转义),返回 {token, next}
|
|
641
|
+
// 注意:Windows 路径含反斜杠,双引号内只在遇到 " \ $ ` 时才当转义,
|
|
642
|
+
// 其他情况反斜杠保留为字面量(避免 E:\work → E:work 吃掉分隔符)。
|
|
643
|
+
function readToken(cmd: string, i: number): { token: string; next: number } | null {
|
|
644
|
+
i = skipWs(cmd, i);
|
|
645
|
+
if (i >= cmd.length) return null;
|
|
646
|
+
const ch = cmd[i];
|
|
647
|
+
if (ch === ";" || ch === "&" || ch === "|") return null;
|
|
648
|
+
if (ch === '"') {
|
|
649
|
+
i++;
|
|
650
|
+
let s = "";
|
|
651
|
+
while (i < cmd.length && cmd[i] !== '"') {
|
|
652
|
+
if (cmd[i] === "\\" && i + 1 < cmd.length) {
|
|
653
|
+
const nxt = cmd[i + 1];
|
|
654
|
+
if (nxt === '"' || nxt === "\\" || nxt === "$" || nxt === "`") {
|
|
655
|
+
s += nxt;
|
|
656
|
+
i += 2;
|
|
657
|
+
} else {
|
|
658
|
+
s += "\\"; // 字面反斜杠
|
|
659
|
+
i++;
|
|
660
|
+
}
|
|
661
|
+
} else {
|
|
662
|
+
s += cmd[i];
|
|
663
|
+
i++;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
i++; // 跳过结束引号
|
|
667
|
+
return { token: s, next: i };
|
|
668
|
+
}
|
|
669
|
+
if (ch === "'") {
|
|
670
|
+
// 单引号:无转义,全部字面量
|
|
671
|
+
i++;
|
|
672
|
+
let s = "";
|
|
673
|
+
while (i < cmd.length && cmd[i] !== "'") {
|
|
674
|
+
s += cmd[i];
|
|
675
|
+
i++;
|
|
676
|
+
}
|
|
677
|
+
i++;
|
|
678
|
+
return { token: s, next: i };
|
|
679
|
+
}
|
|
680
|
+
let s = "";
|
|
681
|
+
while (i < cmd.length && !/\s/.test(cmd[i]) && cmd[i] !== ";" && cmd[i] !== "&" && cmd[i] !== "|") {
|
|
682
|
+
s += cmd[i];
|
|
683
|
+
i++;
|
|
684
|
+
}
|
|
685
|
+
return { token: s, next: i };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function isFlag(tok: string): boolean {
|
|
689
|
+
return tok.startsWith("-") && tok.length > 1 && !tok.startsWith("--") || tok.startsWith("--");
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// 提取 rm/del 的所有目标(多命令、多文件、跳过 flag)
|
|
693
|
+
function parseRmTargets(cmd: string): string[] {
|
|
694
|
+
const targets: string[] = [];
|
|
695
|
+
const re = /\b(rm|del)\b/gi;
|
|
696
|
+
let m: RegExpExecArray | null;
|
|
697
|
+
while ((m = re.exec(cmd)) !== null) {
|
|
698
|
+
let i = m.index + m[1].length;
|
|
699
|
+
// 收集该 rm 之后到分隔符前的所有非 flag token
|
|
700
|
+
while (true) {
|
|
701
|
+
const r = readToken(cmd, i);
|
|
702
|
+
if (!r) break;
|
|
703
|
+
i = r.next;
|
|
704
|
+
if (isFlag(r.token)) continue;
|
|
705
|
+
if (r.token) targets.push(r.token);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return targets;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// 提取 mv 的 (src, dst) 对
|
|
712
|
+
function parseMvPairs(cmd: string): Array<[string, string]> {
|
|
713
|
+
const pairs: Array<[string, string]> = [];
|
|
714
|
+
const re = /\bmv\b/gi;
|
|
715
|
+
let m: RegExpExecArray | null;
|
|
716
|
+
while ((m = re.exec(cmd)) !== null) {
|
|
717
|
+
let i = m.index + m[1].length;
|
|
718
|
+
const args: string[] = [];
|
|
719
|
+
while (args.length < 2) {
|
|
720
|
+
const r = readToken(cmd, i);
|
|
721
|
+
if (!r) break;
|
|
722
|
+
i = r.next;
|
|
723
|
+
if (isFlag(r.token)) continue;
|
|
724
|
+
args.push(r.token);
|
|
725
|
+
}
|
|
726
|
+
if (args.length === 2) pairs.push([args[0], args[1]]);
|
|
727
|
+
}
|
|
728
|
+
return pairs;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// 提取 > / >> 重定向目标
|
|
732
|
+
function parseRedirectTargets(cmd: string): string[] {
|
|
733
|
+
const targets: string[] = [];
|
|
734
|
+
const re = />>?\s*("[^"]*"|'[^']*'|\S+)/g;
|
|
735
|
+
let m: RegExpExecArray | null;
|
|
736
|
+
while ((m = re.exec(cmd)) !== null) {
|
|
737
|
+
let t = m[1];
|
|
738
|
+
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
|
739
|
+
t = t.slice(1, -1);
|
|
740
|
+
}
|
|
741
|
+
if (t) targets.push(t);
|
|
742
|
+
}
|
|
743
|
+
return targets;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// =============================================================================
|
|
747
|
+
// 核心:JIT 工具拦截
|
|
748
|
+
// =============================================================================
|
|
749
|
+
|
|
750
|
+
function trackPathBefore(filePath: string, viaBash: boolean): void {
|
|
751
|
+
if (!isInsideWorkspace(filePath, activeWorkspace!)) return;
|
|
752
|
+
// 目录无法内容快照,跳过(回滚不支持目录还原)
|
|
753
|
+
if (isDirectory(filePath)) return;
|
|
754
|
+
if (!beforeMap.has(filePath)) {
|
|
755
|
+
beforeMap.set(filePath, readContentIfExists(filePath));
|
|
756
|
+
}
|
|
757
|
+
if (viaBash) bashTrackedPaths.add(filePath);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function onToolCall(event: { toolName: string; input: Record<string, unknown> }): void {
|
|
761
|
+
if (!activeWorkspace) return;
|
|
762
|
+
|
|
763
|
+
// 处理 edit/write 工具
|
|
764
|
+
if (WRITE_TOOLS.has(event.toolName)) {
|
|
765
|
+
const raw = extractFilePath(event.input);
|
|
766
|
+
if (!raw) return;
|
|
767
|
+
const filePath = path.resolve(raw);
|
|
768
|
+
trackPathBefore(filePath, false);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// 处理 bash 工具(rm / del / mv / 重定向)
|
|
773
|
+
if (event.toolName === "bash") {
|
|
774
|
+
const cmd = ((event.input as any).command || "").trim();
|
|
775
|
+
if (!cmd) return;
|
|
776
|
+
|
|
777
|
+
// rm / del:原路径将被删除
|
|
778
|
+
for (const raw of parseRmTargets(cmd)) {
|
|
779
|
+
for (const expanded of expandGlob(raw)) {
|
|
780
|
+
trackPathBefore(path.resolve(expanded), true);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// mv:oldPath 删除 + newPath 覆盖
|
|
785
|
+
for (const [oldRaw, newRaw] of parseMvPairs(cmd)) {
|
|
786
|
+
for (const expanded of expandGlob(oldRaw)) {
|
|
787
|
+
trackPathBefore(path.resolve(expanded), true);
|
|
788
|
+
}
|
|
789
|
+
// newPath 可能被覆盖,记录其 before
|
|
790
|
+
for (const expanded of expandGlob(newRaw)) {
|
|
791
|
+
trackPathBefore(path.resolve(expanded), true);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// > / >> 重定向:target 被覆盖
|
|
796
|
+
for (const raw of parseRedirectTargets(cmd)) {
|
|
797
|
+
for (const expanded of expandGlob(raw)) {
|
|
798
|
+
trackPathBefore(path.resolve(expanded), true);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function onToolResult(event: { toolName: string; input: Record<string, unknown> }): void {
|
|
805
|
+
if (!activeWorkspace) return;
|
|
806
|
+
|
|
807
|
+
if (WRITE_TOOLS.has(event.toolName)) {
|
|
808
|
+
const raw = extractFilePath(event.input);
|
|
809
|
+
if (!raw) return;
|
|
810
|
+
const filePath = path.resolve(raw);
|
|
811
|
+
if (!isInsideWorkspace(filePath, activeWorkspace)) return;
|
|
812
|
+
if (!beforeMap.has(filePath)) return;
|
|
813
|
+
afterMap.set(filePath, readContentIfExists(filePath));
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// bash 分支:对所有 bash 追踪路径回填 after(删除后为 null、移动后为目标内容)
|
|
818
|
+
if (event.toolName === "bash") {
|
|
819
|
+
for (const filePath of bashTrackedPaths) {
|
|
820
|
+
if (!beforeMap.has(filePath)) continue;
|
|
821
|
+
afterMap.set(filePath, readContentIfExists(filePath));
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// =============================================================================
|
|
827
|
+
// 核心:turn_end 持久化
|
|
828
|
+
// =============================================================================
|
|
829
|
+
|
|
830
|
+
// 从 ctx.sessionManager 的 branch 中取最后一条 user 消息的 {id, text}
|
|
831
|
+
function currentUserMessage(ctx: any): { id: string | null; text: string } {
|
|
832
|
+
try {
|
|
833
|
+
const branch = ctx.sessionManager.getBranch();
|
|
834
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
835
|
+
const e = branch[i];
|
|
836
|
+
if (e.type === "message" && e.message.role === "user") {
|
|
837
|
+
const m = e.message as any;
|
|
838
|
+
let text = "";
|
|
839
|
+
if (typeof m.content === "string") text = m.content;
|
|
840
|
+
else if (Array.isArray(m.content))
|
|
841
|
+
text = m.content.filter((b: any) => b.type === "text").map((b: any) => b.text).join(" ");
|
|
842
|
+
return { id: e.id ?? null, text };
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
} catch {
|
|
846
|
+
// ignore
|
|
847
|
+
}
|
|
848
|
+
return { id: null, text: "" };
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// 从 branch 中取最后一条 assistant 消息的回复文本(用于当前节点展示)
|
|
852
|
+
function currentAssistantText(ctx: any): string {
|
|
853
|
+
try {
|
|
854
|
+
const branch = ctx.sessionManager.getBranch();
|
|
855
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
856
|
+
const e = branch[i];
|
|
857
|
+
if (e.type === "message" && e.message?.role === "assistant") {
|
|
858
|
+
const m = e.message as any;
|
|
859
|
+
let text = "";
|
|
860
|
+
if (typeof m.content === "string") text = m.content;
|
|
861
|
+
else if (Array.isArray(m.content))
|
|
862
|
+
text = m.content.filter((b: any) => b.type === "text").map((b: any) => b.text).join(" ");
|
|
863
|
+
return text.slice(0, 80) || "";
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
} catch {
|
|
867
|
+
// ignore
|
|
868
|
+
}
|
|
869
|
+
return "";
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function flushTurn(ctx: any): void {
|
|
873
|
+
if (isProcessing) return;
|
|
874
|
+
isProcessing = true;
|
|
875
|
+
try {
|
|
876
|
+
if (!currentSessionId) return;
|
|
877
|
+
|
|
878
|
+
const queue = loadQueue(currentSessionId);
|
|
879
|
+
|
|
880
|
+
// ── Session Tree 同步检测 ──
|
|
881
|
+
// 仅在当前工作区启用且开关打开、且是新 user 消息(避免同一提问内反复触发)时才执行。
|
|
882
|
+
const canSync =
|
|
883
|
+
activeWorkspace !== null && followSessionTreeEnabled &&
|
|
884
|
+
currentUserMessage(ctx).id !== lastFlushedUserMsgId;
|
|
885
|
+
if (canSync) {
|
|
886
|
+
const navIdx = detectSessionTreeNav(ctx, queue);
|
|
887
|
+
if (navIdx !== null) {
|
|
888
|
+
autoRollbackForSessionTree(ctx, queue, navIdx);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Turn 0 初始化(始终创建占位)
|
|
893
|
+
if (queue.entries.length === 0) {
|
|
894
|
+
queue.entries.push({
|
|
895
|
+
turnIndex: 0,
|
|
896
|
+
text: "(会话起点)",
|
|
897
|
+
timestamp: new Date().toISOString(),
|
|
898
|
+
changes: [],
|
|
899
|
+
});
|
|
900
|
+
queue.currentIndex = 0;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// 从 beforeMap + afterMap 生成 changes(同 turn 多次编辑聚合为 1 条)
|
|
904
|
+
const aggregated: FileChange[] = [];
|
|
905
|
+
for (const [filePath, before] of beforeMap) {
|
|
906
|
+
const after = afterMap.has(filePath)
|
|
907
|
+
? afterMap.get(filePath)!
|
|
908
|
+
: readContentIfExists(filePath);
|
|
909
|
+
const beforeHash = sha256(before);
|
|
910
|
+
const afterHash = sha256(after);
|
|
911
|
+
if (beforeHash === afterHash) continue; // 没变化 → 跳过
|
|
912
|
+
|
|
913
|
+
writeSnapshotIfMissing(beforeHash, before);
|
|
914
|
+
writeSnapshotIfMissing(afterHash, after);
|
|
915
|
+
|
|
916
|
+
let action: FileChange["action"];
|
|
917
|
+
if (before === null) action = "create";
|
|
918
|
+
else if (after === null) action = "delete";
|
|
919
|
+
else action = "write";
|
|
920
|
+
|
|
921
|
+
aggregated.push({
|
|
922
|
+
path: filePath,
|
|
923
|
+
action,
|
|
924
|
+
beforeHash,
|
|
925
|
+
afterHash,
|
|
926
|
+
viaBash: bashTrackedPaths.has(filePath) || undefined,
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// 清空缓存,下一轮重新拍照
|
|
931
|
+
beforeMap.clear();
|
|
932
|
+
afterMap.clear();
|
|
933
|
+
bashTrackedPaths.clear();
|
|
934
|
+
|
|
935
|
+
const curUser = currentUserMessage(ctx);
|
|
936
|
+
const currentText = curUser.text.slice(0, 80) || "";
|
|
937
|
+
const curUserMsgId = curUser.id;
|
|
938
|
+
const lastEntry = queue.entries[queue.entries.length - 1];
|
|
939
|
+
|
|
940
|
+
// 按用户提问归类:在 turn_end 时检测当前 user 消息 id 是否变化。
|
|
941
|
+
// - id 与上次落盘的相同 → 同一提问的后续 turn_end → 追加步骤到现有 entry;
|
|
942
|
+
// - id 变化 / 首次 / 回滚后(lastFlushedUserMsgId=null)→ 新建 entry。
|
|
943
|
+
//
|
|
944
|
+
// 合并采用「追加步骤」(保留每个 turn 的快照),而非旧的 net 合并,
|
|
945
|
+
// 避免吞掉中间的删除/重建步骤导致按步回滚失败。
|
|
946
|
+
// planRollback/applyRollback 按 turnIndex 升序聚合 earliest-before / latest-after,
|
|
947
|
+
// 天然兼容同 path 多条 step。
|
|
948
|
+
const sameQuestion =
|
|
949
|
+
curUserMsgId !== null &&
|
|
950
|
+
curUserMsgId === lastFlushedUserMsgId;
|
|
951
|
+
if (
|
|
952
|
+
sameQuestion &&
|
|
953
|
+
lastEntry &&
|
|
954
|
+
lastEntry.turnIndex !== 0 &&
|
|
955
|
+
!lastEntry.residual
|
|
956
|
+
) {
|
|
957
|
+
for (const nc of aggregated) lastEntry.changes.push(nc);
|
|
958
|
+
// 更新 resultText 为本轮的 assistant 回复
|
|
959
|
+
const at = currentAssistantText(ctx);
|
|
960
|
+
if (at) lastEntry.resultText = at;
|
|
961
|
+
queue.currentIndex = queue.entries.length - 1;
|
|
962
|
+
saveQueue(queue);
|
|
963
|
+
turnIndex++;
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
lastFlushedUserMsgId = curUserMsgId;
|
|
968
|
+
|
|
969
|
+
// 新建 entry(用 entries 长度作为编号)
|
|
970
|
+
const entryIdx = queue.entries.length;
|
|
971
|
+
const newEntry: QueueEntry = {
|
|
972
|
+
turnIndex: entryIdx,
|
|
973
|
+
text: currentText || `变更 #${entryIdx}`,
|
|
974
|
+
timestamp: new Date().toISOString(),
|
|
975
|
+
changes: aggregated,
|
|
976
|
+
sessionEntryId: curUserMsgId ?? undefined,
|
|
977
|
+
resultText: currentAssistantText(ctx) || undefined,
|
|
978
|
+
};
|
|
979
|
+
queue.entries.push(newEntry);
|
|
980
|
+
queue.currentIndex = queue.entries.length - 1;
|
|
981
|
+
|
|
982
|
+
// 滚动窗口:只保留最后 10 条
|
|
983
|
+
if (queue.entries.length > MAX_TURNS) {
|
|
984
|
+
queue.entries = queue.entries.slice(-MAX_TURNS);
|
|
985
|
+
queue.currentIndex = queue.entries.length - 1;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
saveQueue(queue);
|
|
989
|
+
turnIndex++;
|
|
990
|
+
|
|
991
|
+
// 每次 turn_end 都触发节流 GC(回收孤儿 snapshot + 清理旧 queue 文件)
|
|
992
|
+
maybeGc();
|
|
993
|
+
} finally {
|
|
994
|
+
isProcessing = false;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// =============================================================================
|
|
999
|
+
// 核心:回滚算法
|
|
1000
|
+
// =============================================================================
|
|
1001
|
+
|
|
1002
|
+
interface RollbackChange {
|
|
1003
|
+
path: string;
|
|
1004
|
+
beforeHash: string;
|
|
1005
|
+
afterHash: string;
|
|
1006
|
+
action: FileChange["action"];
|
|
1007
|
+
turnIndex: number;
|
|
1008
|
+
viaBash?: boolean;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
interface RollbackPlan {
|
|
1012
|
+
changes: RollbackChange[];
|
|
1013
|
+
conflictPaths: string[]; // 脏检查失败的路径
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function planRollback(queue: QueueData, targetIndex: number, force = false): RollbackPlan {
|
|
1017
|
+
// Option C 语义:每个 entry 是一个检查点 = 该 entry 修改之前的文件状态。
|
|
1018
|
+
// 回滚到 targetIndex = 撤销 targetIndex 及之后所有 entry(含 targetIndex 自身)。
|
|
1019
|
+
const discarded = queue.entries.slice(targetIndex).filter((e) => !e.residual);
|
|
1020
|
+
const changes: RollbackChange[] = [];
|
|
1021
|
+
for (const entry of discarded) {
|
|
1022
|
+
for (const c of entry.changes) {
|
|
1023
|
+
changes.push({ ...c, turnIndex: entry.turnIndex });
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const conflictPaths: string[] = [];
|
|
1028
|
+
|
|
1029
|
+
// 脏检查:当前文件 hash 应等于最新 turn 留下的 afterHash
|
|
1030
|
+
// 仅在手动回滚时检查(force=false);Session Tree 同步(force=true)强制覆盖
|
|
1031
|
+
if (!force) {
|
|
1032
|
+
// 聚合:同一文件只检查"最新一条"的 afterHash(按 turnIndex 升序,后覆盖前)
|
|
1033
|
+
const latestAfterByPath = new Map<string, string>();
|
|
1034
|
+
const sortedAsc = [...changes].sort((a, b) => a.turnIndex - b.turnIndex);
|
|
1035
|
+
for (const c of sortedAsc) {
|
|
1036
|
+
latestAfterByPath.set(c.path, c.afterHash);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
for (const [filePath, expectedAfterHash] of latestAfterByPath) {
|
|
1040
|
+
const current = readContentIfExists(filePath);
|
|
1041
|
+
const currentHash = sha256(current);
|
|
1042
|
+
if (currentHash !== expectedAfterHash) {
|
|
1043
|
+
conflictPaths.push(filePath);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
return { changes, conflictPaths };
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function applyRollback(plan: RollbackPlan): {
|
|
1052
|
+
restored: number;
|
|
1053
|
+
skipped: string[];
|
|
1054
|
+
missingSnapshot: string[];
|
|
1055
|
+
} {
|
|
1056
|
+
const skipped: string[] = [];
|
|
1057
|
+
const missingSnapshot: string[] = [];
|
|
1058
|
+
|
|
1059
|
+
// 关键修复:聚合取「最早 turnIndex」的 beforeHash = 被丢弃 turns 第一次修改前的真实原始状态
|
|
1060
|
+
// 升序遍历,第一个命中即为最小 turnIndex 的 before。
|
|
1061
|
+
// 这正确处理删除/重建多步序列:
|
|
1062
|
+
// F: A→(delete)→(create C)→(delete),回滚到 A 那一步时,最早 discarded 的 before=A → 还原 A;
|
|
1063
|
+
// 回滚到「删除」那步时,最早 discarded 的 before=EMPTY → 删除文件。
|
|
1064
|
+
// 不会误取中间快照。
|
|
1065
|
+
const sortedAsc = [...plan.changes].sort((a, b) => a.turnIndex - b.turnIndex);
|
|
1066
|
+
const finalBeforeByPath = new Map<string, string>();
|
|
1067
|
+
for (const c of sortedAsc) {
|
|
1068
|
+
if (!finalBeforeByPath.has(c.path)) {
|
|
1069
|
+
finalBeforeByPath.set(c.path, c.beforeHash);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
for (const [filePath, beforeHash] of finalBeforeByPath) {
|
|
1074
|
+
if (plan.conflictPaths.includes(filePath)) {
|
|
1075
|
+
skipped.push(filePath);
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (beforeHash === EMPTY_HASH) {
|
|
1080
|
+
// 原始状态:文件不存在 → 删除
|
|
1081
|
+
try {
|
|
1082
|
+
fs.unlinkSync(filePath);
|
|
1083
|
+
} catch {
|
|
1084
|
+
// 文件可能已被删除
|
|
1085
|
+
}
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// 非空 hash:必须能读到快照,否则绝不删除(避免误删用户文件)
|
|
1090
|
+
const content = readSnapshot(beforeHash);
|
|
1091
|
+
if (content === null) {
|
|
1092
|
+
missingSnapshot.push(filePath);
|
|
1093
|
+
skipped.push(filePath);
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
try {
|
|
1097
|
+
atomicWriteFile(filePath, content);
|
|
1098
|
+
} catch {
|
|
1099
|
+
skipped.push(filePath);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
return {
|
|
1104
|
+
restored: finalBeforeByPath.size - skipped.length,
|
|
1105
|
+
skipped,
|
|
1106
|
+
missingSnapshot,
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// =============================================================================
|
|
1111
|
+
// 回滚预览:按文件分类(还原/新增/删除)供确认菜单展示
|
|
1112
|
+
// =============================================================================
|
|
1113
|
+
|
|
1114
|
+
interface RollbackPreview {
|
|
1115
|
+
restore: string[]; // 文件将还原到原内容
|
|
1116
|
+
create: string[]; // 文件将被新增创建
|
|
1117
|
+
remove: string[]; // 文件将被删除
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function previewRollback(plan: RollbackPlan, workspace: string): RollbackPreview {
|
|
1121
|
+
// 复用 applyRollback 的 earliest-before 聚合逻辑
|
|
1122
|
+
const sortedAsc = [...plan.changes].sort((a, b) => a.turnIndex - b.turnIndex);
|
|
1123
|
+
const finalBeforeByPath = new Map<string, string>();
|
|
1124
|
+
for (const c of sortedAsc) {
|
|
1125
|
+
if (!finalBeforeByPath.has(c.path)) finalBeforeByPath.set(c.path, c.beforeHash);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const restore: string[] = [];
|
|
1129
|
+
const create: string[] = [];
|
|
1130
|
+
const remove: string[] = [];
|
|
1131
|
+
for (const [filePath, beforeHash] of finalBeforeByPath) {
|
|
1132
|
+
if (plan.conflictPaths.includes(filePath)) continue; // 跳过的不计入
|
|
1133
|
+
const rel = path.relative(workspace, filePath) || filePath;
|
|
1134
|
+
const currentlyExists = readContentIfExists(filePath) !== null;
|
|
1135
|
+
if (beforeHash === EMPTY_HASH) {
|
|
1136
|
+
// 目标状态:不存在
|
|
1137
|
+
remove.push(rel);
|
|
1138
|
+
} else if (!currentlyExists) {
|
|
1139
|
+
// 目标状态:有内容 且 当前不存在 → 新增
|
|
1140
|
+
create.push(rel);
|
|
1141
|
+
} else {
|
|
1142
|
+
restore.push(rel);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return { restore, create, remove };
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function formatPreview(preview: RollbackPreview): string {
|
|
1149
|
+
const lines: string[] = [];
|
|
1150
|
+
if (preview.restore.length > 0) {
|
|
1151
|
+
lines.push(t("format.restore", preview.restore.length, preview.restore.join(", ")));
|
|
1152
|
+
}
|
|
1153
|
+
if (preview.create.length > 0) {
|
|
1154
|
+
lines.push(t("format.create", preview.create.length, preview.create.join(", ")));
|
|
1155
|
+
}
|
|
1156
|
+
if (preview.remove.length > 0) {
|
|
1157
|
+
lines.push(t("format.remove", preview.remove.length, preview.remove.join(", ")));
|
|
1158
|
+
}
|
|
1159
|
+
if (lines.length === 0) lines.push(t("format.noFileChanges"));
|
|
1160
|
+
return lines.join("\n");
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Session Tree 同步:检测到用户通过 Session Tree 导航到旧对话点时,
|
|
1164
|
+
// 自动回滚队列和文件,让 Queue 跟随系统Tree。
|
|
1165
|
+
// ─────────────────────────────────────────────────────────────
|
|
1166
|
+
|
|
1167
|
+
// 检测 Session Tree 是否被导航到旧对话点
|
|
1168
|
+
// 返回:需要回滚到的 queue.entries 数组下标;未发生导航则返回 null
|
|
1169
|
+
function detectSessionTreeNav(ctx: any, queue: QueueData): number | null {
|
|
1170
|
+
if (queue.entries.length < 2) return null;
|
|
1171
|
+
if (queue.currentIndex < 1) return null;
|
|
1172
|
+
|
|
1173
|
+
try {
|
|
1174
|
+
const branch = ctx.sessionManager.getBranch();
|
|
1175
|
+
if (!Array.isArray(branch)) return null;
|
|
1176
|
+
|
|
1177
|
+
// 收集 branch 中所有 user 消息 ID(按时间顺序)
|
|
1178
|
+
const userMsgIds: string[] = [];
|
|
1179
|
+
for (const e of branch) {
|
|
1180
|
+
if (e.type === "message" && e.message?.role === "user") {
|
|
1181
|
+
userMsgIds.push(e.id ?? "");
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
if (userMsgIds.length < 2) return null;
|
|
1185
|
+
|
|
1186
|
+
// 当前 user 消息(新输入的问题)
|
|
1187
|
+
const currentUserMsgId = userMsgIds[userMsgIds.length - 1];
|
|
1188
|
+
// 前一条 user 消息(用户导航到的对话点)
|
|
1189
|
+
const prevUserMsgId = userMsgIds[userMsgIds.length - 2];
|
|
1190
|
+
if (!prevUserMsgId) return null;
|
|
1191
|
+
|
|
1192
|
+
// 按 sessionEntryId 匹配(新 entry 都有该字段)
|
|
1193
|
+
const idx = queue.entries.findIndex(
|
|
1194
|
+
(ent) => ent.sessionEntryId === prevUserMsgId,
|
|
1195
|
+
);
|
|
1196
|
+
|
|
1197
|
+
if (idx >= 0 && idx < queue.currentIndex) return idx;
|
|
1198
|
+
return null;
|
|
1199
|
+
} catch {
|
|
1200
|
+
// ignore
|
|
1201
|
+
}
|
|
1202
|
+
return null;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// Session Tree 导航后的自动回滚(无需用户确认,静默处理冲突)
|
|
1206
|
+
function autoRollbackForSessionTree(
|
|
1207
|
+
ctx: any,
|
|
1208
|
+
queue: QueueData,
|
|
1209
|
+
targetIdx: number,
|
|
1210
|
+
): void {
|
|
1211
|
+
if (targetIdx < 0 || targetIdx >= queue.entries.length) return;
|
|
1212
|
+
const targetEntry = queue.entries[targetIdx];
|
|
1213
|
+
|
|
1214
|
+
const plan = planRollback(queue, targetIdx, true);
|
|
1215
|
+
const result = applyRollback(plan);
|
|
1216
|
+
const skippedSet = new Set(result.skipped);
|
|
1217
|
+
|
|
1218
|
+
// Option C:丢弃 target 及之后的 entry
|
|
1219
|
+
queue.entries = queue.entries.slice(0, targetIdx);
|
|
1220
|
+
|
|
1221
|
+
// 若有跳过(冲突或快照丢失)→ 保留残留 entry,保持后续可重试
|
|
1222
|
+
if (result.skipped.length > 0) {
|
|
1223
|
+
const residualChanges: FileChange[] = plan.changes
|
|
1224
|
+
.filter((c) => skippedSet.has(c.path))
|
|
1225
|
+
.map((c) => ({
|
|
1226
|
+
path: c.path,
|
|
1227
|
+
action: c.action,
|
|
1228
|
+
beforeHash: c.beforeHash,
|
|
1229
|
+
afterHash: c.afterHash,
|
|
1230
|
+
viaBash: c.viaBash,
|
|
1231
|
+
}));
|
|
1232
|
+
if (residualChanges.length > 0) {
|
|
1233
|
+
const resIdx = queue.entries.length;
|
|
1234
|
+
queue.entries.push({
|
|
1235
|
+
turnIndex: resIdx,
|
|
1236
|
+
text: "(未回滚残留)",
|
|
1237
|
+
timestamp: new Date().toISOString(),
|
|
1238
|
+
changes: residualChanges,
|
|
1239
|
+
residual: true,
|
|
1240
|
+
});
|
|
1241
|
+
queue.currentIndex = resIdx;
|
|
1242
|
+
} else {
|
|
1243
|
+
queue.currentIndex = queue.entries.length - 1;
|
|
1244
|
+
}
|
|
1245
|
+
} else {
|
|
1246
|
+
queue.currentIndex = queue.entries.length - 1;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
saveQueue(queue);
|
|
1250
|
+
// 重置为 null,使后续 flushTurn 走“新建 entry”分支
|
|
1251
|
+
lastFlushedUserMsgId = null;
|
|
1252
|
+
turnIndex = targetEntry.turnIndex;
|
|
1253
|
+
|
|
1254
|
+
// GC
|
|
1255
|
+
maybeGc(true);
|
|
1256
|
+
|
|
1257
|
+
// 通知
|
|
1258
|
+
const parts: string[] = [];
|
|
1259
|
+
if (result.restored > 0) parts.push(t("format.restored", result.restored));
|
|
1260
|
+
let msg = t("notify.autoRollback", targetEntry.text.slice(0, 30));
|
|
1261
|
+
if (parts.length > 0) msg += `:${parts.join("、")}`;
|
|
1262
|
+
if (result.skipped.length > 0) msg += t("format.skippedConflicts", result.skipped.length);
|
|
1263
|
+
ctx.ui.notify(msg, result.skipped.length > 0 ? "warning" : "info");
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// =============================================================================
|
|
1267
|
+
// TUI 组件:回滚选择器
|
|
1268
|
+
// =============================================================================
|
|
1269
|
+
|
|
1270
|
+
interface QueueItem {
|
|
1271
|
+
entry: QueueEntry;
|
|
1272
|
+
isCurrent: boolean;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function buildQueueItems(queue: QueueData): QueueItem[] {
|
|
1276
|
+
// Option C:entry 不表示当前状态,所有 entry 都是普通检查点;
|
|
1277
|
+
// 「当前状态」由 showRollbackUI 中的 phantomCurrent 表示。
|
|
1278
|
+
return queue.entries.map((e) => ({ entry: e, isCurrent: false }));
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
async function showRollbackUI(pi: ExtensionAPI, ctx: any): Promise<void> {
|
|
1282
|
+
if (!ctx.hasUI) {
|
|
1283
|
+
ctx.ui.notify(t("notify.needInteractive"), "error");
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
if (!currentSessionId) {
|
|
1287
|
+
ctx.ui.notify(t("notify.noSession"), "error");
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
if (isProcessing) {
|
|
1291
|
+
ctx.ui.notify(t("notify.processing"), "warning");
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
const queue = loadQueue(currentSessionId);
|
|
1296
|
+
if (queue.entries.length === 0) {
|
|
1297
|
+
ctx.ui.notify(t("notify.emptyQueue"), "info");
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
if (queue.entries.length === 1) {
|
|
1301
|
+
ctx.ui.notify(t("notify.onlyTurn0"), "info");
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// ── 第一步:选择目标 turn ──
|
|
1306
|
+
// Option C 语义:每个 entry 是一个检查点(该 entry 修改之前的文件状态)。
|
|
1307
|
+
// 展示顺序与 Session Tree 一致:「当前状态」虚顶在最上,下面是各 entry 检查点(倒序,最近在前)。
|
|
1308
|
+
// 过滤掉「会话起点」占位(与首个真实 entry 检查点语义重合)。
|
|
1309
|
+
const allItems = buildQueueItems(queue);
|
|
1310
|
+
const phantomCurrent: QueueItem = {
|
|
1311
|
+
entry: {
|
|
1312
|
+
turnIndex: queue.entries.length,
|
|
1313
|
+
text: t("format.currentState"),
|
|
1314
|
+
timestamp: new Date().toISOString(),
|
|
1315
|
+
changes: [],
|
|
1316
|
+
},
|
|
1317
|
+
isCurrent: true,
|
|
1318
|
+
};
|
|
1319
|
+
const checkpoints = allItems.filter(
|
|
1320
|
+
(it) => !it.entry.residual && !(it.entry.turnIndex === 0 && it.entry.text === "(会话起点)"),
|
|
1321
|
+
);
|
|
1322
|
+
if (checkpoints.length === 0) {
|
|
1323
|
+
ctx.ui.notify(t("notify.noCheckpoints"), "info");
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
const orderedDisplay: QueueItem[] = [phantomCurrent, ...[...checkpoints].reverse()];
|
|
1327
|
+
|
|
1328
|
+
const selectOptions = orderedDisplay.map((item) => {
|
|
1329
|
+
const bashTag = item.entry.changes.some((c) => c.viaBash) ? t("format.bashTag") : "";
|
|
1330
|
+
const curTag = item.isCurrent ? t("format.currentNode") : "";
|
|
1331
|
+
let label: string;
|
|
1332
|
+
if (item.isCurrent) {
|
|
1333
|
+
label = t("format.currentState");
|
|
1334
|
+
} else {
|
|
1335
|
+
label = item.entry.text;
|
|
1336
|
+
}
|
|
1337
|
+
return `${label}${bashTag}${curTag}`;
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
// 循环选择:避免用户误选当前节点
|
|
1341
|
+
let targetEntry: QueueEntry | null = null;
|
|
1342
|
+
while (targetEntry === null) {
|
|
1343
|
+
const choice = await ctx.ui.select(
|
|
1344
|
+
t("select.rollbackTitle", checkpoints.length),
|
|
1345
|
+
selectOptions,
|
|
1346
|
+
);
|
|
1347
|
+
if (!choice) return; // 用户取消
|
|
1348
|
+
const choiceIdx = selectOptions.indexOf(choice);
|
|
1349
|
+
if (choiceIdx < 0) return;
|
|
1350
|
+
const picked = orderedDisplay[choiceIdx];
|
|
1351
|
+
if (picked.isCurrent) {
|
|
1352
|
+
ctx.ui.notify(t("notify.currentNode"), "info");
|
|
1353
|
+
continue; // 重新提示
|
|
1354
|
+
}
|
|
1355
|
+
targetEntry = picked.entry;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// 定位 target 在 queue.entries 中的数组索引(滚动窗口后 turnIndex 与数组下标不一致)
|
|
1359
|
+
const targetArrIdx = queue.entries.findIndex(
|
|
1360
|
+
(e) => e.turnIndex === targetEntry!.turnIndex,
|
|
1361
|
+
);
|
|
1362
|
+
if (targetArrIdx < 0) return;
|
|
1363
|
+
|
|
1364
|
+
// ── 第二步:计算回滚计划 + 脏检查 ──
|
|
1365
|
+
const plan = planRollback(queue, targetArrIdx);
|
|
1366
|
+
if (plan.changes.length === 0) {
|
|
1367
|
+
// 没有文件变更,丢弃 target 及之后的 entry(Option C:target 检查点之前的 entry 保留)
|
|
1368
|
+
queue.entries = queue.entries.slice(0, targetArrIdx);
|
|
1369
|
+
queue.currentIndex = queue.entries.length - 1;
|
|
1370
|
+
saveQueue(queue);
|
|
1371
|
+
maybeGc();
|
|
1372
|
+
lastFlushedUserMsgId = null;
|
|
1373
|
+
ctx.ui.notify(t("notify.rolledBack", targetEntry.text.slice(0, 30)), "info");
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// ── 第三步:单次确认弹窗(确定/取消 + 冲突处理一起) ──
|
|
1378
|
+
const preview = previewRollback(plan, activeWorkspace || process.cwd());
|
|
1379
|
+
let confirmTitle =
|
|
1380
|
+
t("confirm.rollbackTitle", targetEntry.text.slice(0, 30)) + "\n" +
|
|
1381
|
+
t("confirm.willProcess", plan.changes.length) + "\n" +
|
|
1382
|
+
formatPreview(preview);
|
|
1383
|
+
const hasConflict = plan.conflictPaths.length > 0;
|
|
1384
|
+
let confirmButtons: string[];
|
|
1385
|
+
if (hasConflict) {
|
|
1386
|
+
const conflictList = plan.conflictPaths
|
|
1387
|
+
.map((p) => " ⚠ " + path.relative(activeWorkspace || process.cwd(), p))
|
|
1388
|
+
.join("\n");
|
|
1389
|
+
confirmTitle += "\n" + t("confirm.conflictsWarn", plan.conflictPaths.length) + "\n" + conflictList;
|
|
1390
|
+
confirmButtons = [
|
|
1391
|
+
t("confirm.btnForceOverwrite"),
|
|
1392
|
+
t("confirm.btnSkipConflicts"),
|
|
1393
|
+
t("confirm.btnCancel"),
|
|
1394
|
+
];
|
|
1395
|
+
} else {
|
|
1396
|
+
confirmButtons = [t("confirm.btnConfirm"), t("confirm.btnCancel")];
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
const confirm = await ctx.ui.select(confirmTitle, confirmButtons);
|
|
1400
|
+
if (!confirm || confirm === "❌ 取消") return;
|
|
1401
|
+
if (hasConflict && confirm.includes("强制")) {
|
|
1402
|
+
plan.conflictPaths = []; // 强制覆盖:清空冲突 → applyRollback 全部走
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// ── 第五步:执行回滚 ──
|
|
1406
|
+
isProcessing = true;
|
|
1407
|
+
try {
|
|
1408
|
+
const result = applyRollback(plan);
|
|
1409
|
+
const skippedSet = new Set(result.skipped);
|
|
1410
|
+
|
|
1411
|
+
// 截断队列(Option C:丢弃 target 及之后的 entry,保留 target 检查点之前的部分)
|
|
1412
|
+
queue.entries = queue.entries.slice(0, targetArrIdx);
|
|
1413
|
+
|
|
1414
|
+
// 若有跳过(冲突或快照丢失)→ 保留残留 entry,记录被跳过文件的原始 change
|
|
1415
|
+
if (result.skipped.length > 0) {
|
|
1416
|
+
const residualChanges: FileChange[] = plan.changes
|
|
1417
|
+
.filter((c) => skippedSet.has(c.path))
|
|
1418
|
+
.map((c) => ({
|
|
1419
|
+
path: c.path,
|
|
1420
|
+
action: c.action,
|
|
1421
|
+
beforeHash: c.beforeHash,
|
|
1422
|
+
afterHash: c.afterHash,
|
|
1423
|
+
viaBash: c.viaBash,
|
|
1424
|
+
}));
|
|
1425
|
+
if (residualChanges.length > 0) {
|
|
1426
|
+
const resIdx = queue.entries.length;
|
|
1427
|
+
queue.entries.push({
|
|
1428
|
+
turnIndex: resIdx,
|
|
1429
|
+
text: "(未回滚残留)",
|
|
1430
|
+
timestamp: new Date().toISOString(),
|
|
1431
|
+
changes: residualChanges,
|
|
1432
|
+
residual: true,
|
|
1433
|
+
});
|
|
1434
|
+
queue.currentIndex = resIdx;
|
|
1435
|
+
} else {
|
|
1436
|
+
queue.currentIndex = queue.entries.length - 1;
|
|
1437
|
+
}
|
|
1438
|
+
} else {
|
|
1439
|
+
queue.currentIndex = queue.entries.length - 1;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
saveQueue(queue);
|
|
1443
|
+
turnIndex = targetEntry.turnIndex;
|
|
1444
|
+
// 回滚后下一个 turn_end 应新建 entry,而非追加到回滚截断后的最后一条
|
|
1445
|
+
lastFlushedUserMsgId = null;
|
|
1446
|
+
|
|
1447
|
+
// 回滚后回收孤儿快照
|
|
1448
|
+
maybeGc(true);
|
|
1449
|
+
|
|
1450
|
+
const parts: string[] = [];
|
|
1451
|
+
if (preview.restore.length > 0) parts.push(t("format.restored", preview.restore.length));
|
|
1452
|
+
if (preview.create.length > 0) parts.push(t("format.created", preview.create.length));
|
|
1453
|
+
if (preview.remove.length > 0) parts.push(t("format.removed", preview.remove.length));
|
|
1454
|
+
let msg = t("notify.rolledBackWith", targetEntry.text.slice(0, 30), parts.join("、") || t("format.noChange"));
|
|
1455
|
+
if (result.skipped.length > 0) {
|
|
1456
|
+
msg += t("format.skipped", result.skipped.length);
|
|
1457
|
+
}
|
|
1458
|
+
if (result.missingSnapshot.length > 0) {
|
|
1459
|
+
msg += t("format.missingSnapshot", result.missingSnapshot.length);
|
|
1460
|
+
}
|
|
1461
|
+
ctx.ui.notify(msg, result.missingSnapshot.length > 0 ? "warning" : "success");
|
|
1462
|
+
} finally {
|
|
1463
|
+
isProcessing = false;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// =============================================================================
|
|
1468
|
+
// 入口
|
|
1469
|
+
// =============================================================================
|
|
1470
|
+
|
|
1471
|
+
export default function (pi: ExtensionAPI) {
|
|
1472
|
+
ensureDirs();
|
|
1473
|
+
activeWorkspace = null;
|
|
1474
|
+
|
|
1475
|
+
// ── session_start:初始化 Turn 0 ──
|
|
1476
|
+
pi.on("session_start", (_event, ctx) => {
|
|
1477
|
+
if (!ctx.hasUI) return;
|
|
1478
|
+
currentSessionId = ctx.sessionManager.getSessionFile() || null;
|
|
1479
|
+
// 用 sessionId 作为 key
|
|
1480
|
+
if (currentSessionId) {
|
|
1481
|
+
currentSessionId = currentSessionId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
1482
|
+
}
|
|
1483
|
+
turnIndex = 1;
|
|
1484
|
+
lastFlushedUserMsgId = null;
|
|
1485
|
+
|
|
1486
|
+
// 检查工作区是否启用
|
|
1487
|
+
if (isWorkspaceEnabled(ctx.cwd)) {
|
|
1488
|
+
activeWorkspace = path.resolve(ctx.cwd);
|
|
1489
|
+
ctx.ui.setStatus("session-queue", getStatusText());
|
|
1490
|
+
}
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
// ── turn_end:持久化(在此检测新提问,此时 branch 一定含当前 user 消息) ──
|
|
1494
|
+
pi.on("turn_end", (_event, ctx) => {
|
|
1495
|
+
if (!activeWorkspace || !ctx.hasUI) return;
|
|
1496
|
+
flushTurn(ctx);
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
// ── session_tree:用户导航 Session Tree 时立即检测 + 自动回滚 ──
|
|
1500
|
+
// 注意:此时 branch 可能只有 1 条 user 消息(导航点本身),不走 detectSessionTreeNav。
|
|
1501
|
+
// 改为:直接拿 branch 中最后一条 user 消息 id 作为 sessionEntryId 匹配。
|
|
1502
|
+
pi.on("session_tree", (event, ctx) => {
|
|
1503
|
+
if (event.fromExtension) {
|
|
1504
|
+
ctx.ui.notify(t("debug.fromExtension"), "info");
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
if (!activeWorkspace) {
|
|
1508
|
+
ctx.ui.notify(t("debug.activeWorkspaceNull", currentSessionId?.slice(-12) ?? "?"), "info");
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
if (!ctx.hasUI) return;
|
|
1512
|
+
if (!followSessionTreeEnabled) {
|
|
1513
|
+
ctx.ui.notify(t("debug.toggleOff"), "info");
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
if (isProcessing) {
|
|
1517
|
+
ctx.ui.notify(t("debug.isProcessing"), "info");
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
if (event.newLeafId === event.oldLeafId) {
|
|
1521
|
+
ctx.ui.notify(t("debug.noNav", event.newLeafId?.slice(0, 8) ?? "?"), "info");
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
try {
|
|
1525
|
+
const queue = loadQueue(currentSessionId || "");
|
|
1526
|
+
if (queue.entries.length === 0) {
|
|
1527
|
+
ctx.ui.notify(t("debug.emptyQueue"), "info");
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// 关键修复:pi 在用户点击 user 消息时会把 leaf 设置为该消息的 parentId。
|
|
1532
|
+
// 例如点击 q3 → leaf = q3.parentId,这样 branch 不再包含 q3,最后一条 user 消息是 q2,
|
|
1533
|
+
// 会导致间接匹配到 q2,错误地多回滚一个。
|
|
1534
|
+
// 正确的处理顺序:
|
|
1535
|
+
// 优先级 1:getChildren(leafId) 找被点击的 user 消息
|
|
1536
|
+
// 优先级 2:leafEntry 本身为 user 消息(备用,比如点击的就是正在生效的 user)
|
|
1537
|
+
// 优先级 3:branch 倒序查最后一条 user 消息(最后一项备用)
|
|
1538
|
+
const leafEntry = ctx.sessionManager.getLeafEntry?.();
|
|
1539
|
+
const leafId = (leafEntry as any)?.id ?? event.newLeafId ?? null;
|
|
1540
|
+
let navUserMsgId: string | null = null;
|
|
1541
|
+
let navSource = "";
|
|
1542
|
+
|
|
1543
|
+
// 优先级 1:getChildren(leafId) 找 user 消息子节点(即被点击的 user 消息)
|
|
1544
|
+
if (leafId != null) {
|
|
1545
|
+
const children = (ctx.sessionManager as any).getChildren?.(leafId) ?? [];
|
|
1546
|
+
for (const c of children) {
|
|
1547
|
+
if (c.type === "message" && (c as any).message?.role === "user") {
|
|
1548
|
+
navUserMsgId = c.id ?? null;
|
|
1549
|
+
navSource = "getChildren";
|
|
1550
|
+
break;
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
// 优先级 2:leafEntry 本身是 user 消息
|
|
1555
|
+
if (!navUserMsgId) {
|
|
1556
|
+
if (leafEntry && leafEntry.type === "message" && (leafEntry as any).message?.role === "user") {
|
|
1557
|
+
navUserMsgId = (leafEntry as any).id ?? null;
|
|
1558
|
+
navSource = "leafEntry-user";
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
// 优先级 3:branch 倒序查最后一条 user 消息
|
|
1562
|
+
if (!navUserMsgId) {
|
|
1563
|
+
const branch = ctx.sessionManager.getBranch();
|
|
1564
|
+
if (Array.isArray(branch)) {
|
|
1565
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
1566
|
+
const e: any = branch[i];
|
|
1567
|
+
if (e.type === "message" && e.message?.role === "user") {
|
|
1568
|
+
navUserMsgId = e.id ?? null;
|
|
1569
|
+
navSource = "branch";
|
|
1570
|
+
break;
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
ctx.ui.notify(
|
|
1577
|
+
t("debug.nav", navSource, navUserMsgId?.slice(0, 8) ?? "?", leafId?.slice(0, 8) ?? "?", queue.entries.length, queue.currentIndex, currentSessionId?.slice(-12) ?? "?"),
|
|
1578
|
+
"info",
|
|
1579
|
+
);
|
|
1580
|
+
if (!navUserMsgId) {
|
|
1581
|
+
ctx.ui.notify(t("debug.noNavMsg"), "info");
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
// 查找队列中对应的 entry
|
|
1585
|
+
const idx = queue.entries.findIndex(
|
|
1586
|
+
(ent) => ent.sessionEntryId === navUserMsgId,
|
|
1587
|
+
);
|
|
1588
|
+
// 注意:Option C 语义为「entry 检查点 = 该 entry 修改之前的状态」。
|
|
1589
|
+
// 导航到 qN 对应「回滚到 qN 检查点」= 撤销 qN 及之后。
|
|
1590
|
+
// 若 nav 目标是当前 entry(idx === currentIndex),则 qN 本身还未被撤销、后续被撤销。
|
|
1591
|
+
// 但 currentIndex 永远 = entries.length-1,所以 idx >= currentIndex 的情形是“未真正导航”。
|
|
1592
|
+
// 同样:idx > entries.length-1 的越界情形跳过。
|
|
1593
|
+
if (idx < 0) {
|
|
1594
|
+
ctx.ui.notify(t("debug.navNoMatch", navUserMsgId?.slice(0, 8) ?? "?", queue.entries.map(e => (e.sessionEntryId || '?').slice(0, 8)).join(',')), "info");
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
// Option C 语义:entry 是检查点。“导航到 q3” 包含 “q3 本身被撤销”(q3 不再在检查点列表中)。
|
|
1598
|
+
// 所以即使 idx === currentIndex(队列中最后一个 entry),仍然需要 fire。
|
|
1599
|
+
// 唯一需要跳过的是 idx 超出 entries 范围(不可能访问的检查点)。
|
|
1600
|
+
if (idx >= queue.entries.length) {
|
|
1601
|
+
ctx.ui.notify(t("debug.idxOutOfRange", idx, queue.entries.length), "info");
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
ctx.ui.notify(t("debug.rollbackFired", idx, queue.entries.length), "info");
|
|
1605
|
+
isProcessing = true;
|
|
1606
|
+
try {
|
|
1607
|
+
autoRollbackForSessionTree(ctx, queue, idx);
|
|
1608
|
+
} finally {
|
|
1609
|
+
isProcessing = false;
|
|
1610
|
+
}
|
|
1611
|
+
} catch (e: any) {
|
|
1612
|
+
ctx.ui.notify(t("debug.exception", e.message), "warning");
|
|
1613
|
+
}
|
|
1614
|
+
});
|
|
1615
|
+
|
|
1616
|
+
// ── tool_call:JIT 拦截拍照 ──
|
|
1617
|
+
pi.on("tool_call", (event) => {
|
|
1618
|
+
onToolCall(event);
|
|
1619
|
+
});
|
|
1620
|
+
|
|
1621
|
+
// ── tool_result:JIT 拦截收集 ──
|
|
1622
|
+
pi.on("tool_result", (event) => {
|
|
1623
|
+
onToolResult(event);
|
|
1624
|
+
});
|
|
1625
|
+
|
|
1626
|
+
// ── 命令:/rollback(单入口菜单) ──
|
|
1627
|
+
pi.registerCommand("rollback", {
|
|
1628
|
+
description: t("cmd.description"),
|
|
1629
|
+
handler: async (args, ctx) => {
|
|
1630
|
+
// 子命令:/rollback zh | /rollback en 切换语言
|
|
1631
|
+
const sub = args.trim().toLowerCase();
|
|
1632
|
+
if (sub === "zh" || sub === "en") {
|
|
1633
|
+
const config = loadConfig();
|
|
1634
|
+
config.lang = sub;
|
|
1635
|
+
saveConfig(config);
|
|
1636
|
+
setLang(sub);
|
|
1637
|
+
if (ctx.hasUI) {
|
|
1638
|
+
ctx.ui.setStatus("session-queue", getStatusText());
|
|
1639
|
+
ctx.ui.notify(t("notify.langSwitched", sub), "success");
|
|
1640
|
+
}
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
if (sub.length > 0 && sub !== "zh" && sub !== "en") {
|
|
1644
|
+
if (ctx.hasUI) ctx.ui.notify(t("notify.langInvalid", args.trim()), "warning");
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
if (!ctx.hasUI) {
|
|
1649
|
+
ctx.ui.notify(t("notify.needInteractive"), "error");
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// 一直循环,直到用户 Esc 取消或选择某个"完成"项
|
|
1654
|
+
while (true) {
|
|
1655
|
+
// 构建主菜单
|
|
1656
|
+
const queue = currentSessionId ? loadQueue(currentSessionId) : null;
|
|
1657
|
+
const queueSize = queue?.entries.length ?? 0;
|
|
1658
|
+
const config = loadConfig();
|
|
1659
|
+
|
|
1660
|
+
const mainOptions: string[] = [
|
|
1661
|
+
t("menu.rollbackHistory", Math.max(0, queueSize - 1)),
|
|
1662
|
+
t("menu.manageWorkspaces"),
|
|
1663
|
+
t("menu.clearQueue"),
|
|
1664
|
+
activeWorkspace ? t("menu.deleteAndClear") : t("menu.enableCurrent"),
|
|
1665
|
+
t("menu.followSystemTree", followSessionTreeEnabled),
|
|
1666
|
+
t("menu.collectOrphans"),
|
|
1667
|
+
t("menu.exit"),
|
|
1668
|
+
];
|
|
1669
|
+
|
|
1670
|
+
const mainChoice = await ctx.ui.select(
|
|
1671
|
+
t("select.mainTitle"),
|
|
1672
|
+
mainOptions,
|
|
1673
|
+
);
|
|
1674
|
+
if (!mainChoice || mainChoice.startsWith("❌")) return;
|
|
1675
|
+
|
|
1676
|
+
if (mainChoice.startsWith("📋")) {
|
|
1677
|
+
// 唤起回滚选择器,确认/取消后退出菜单
|
|
1678
|
+
await showRollbackUI(pi, ctx);
|
|
1679
|
+
return;
|
|
1680
|
+
} else if (mainChoice.startsWith("📂")) {
|
|
1681
|
+
// 管理工作区
|
|
1682
|
+
const result = await manageWorkspaces(ctx, config);
|
|
1683
|
+
if (result) {
|
|
1684
|
+
// 如果状态变了(启用/暂停),重新加载
|
|
1685
|
+
return; // 状态已变,需要重启 session 才生效
|
|
1686
|
+
}
|
|
1687
|
+
} else if (mainChoice.startsWith("🗑️")) {
|
|
1688
|
+
// 清空当前队列
|
|
1689
|
+
if (!currentSessionId) {
|
|
1690
|
+
ctx.ui.notify(t("notify.noSession"), "info");
|
|
1691
|
+
continue;
|
|
1692
|
+
}
|
|
1693
|
+
if (queueSize === 0) {
|
|
1694
|
+
ctx.ui.notify(t("notify.queueAlreadyEmpty"), "info");
|
|
1695
|
+
continue;
|
|
1696
|
+
}
|
|
1697
|
+
const clearedQueue = loadQueue(currentSessionId);
|
|
1698
|
+
clearedQueue.entries = [];
|
|
1699
|
+
clearedQueue.currentIndex = -1;
|
|
1700
|
+
saveQueue(clearedQueue);
|
|
1701
|
+
lastFlushedUserMsgId = null;
|
|
1702
|
+
// 清空缓存状态,防止残留数据污染下一个 turn
|
|
1703
|
+
beforeMap.clear();
|
|
1704
|
+
afterMap.clear();
|
|
1705
|
+
bashTrackedPaths.clear();
|
|
1706
|
+
turnIndex = 0;
|
|
1707
|
+
// 清空后立即 GC,回收该 session 引用的孤儿快照
|
|
1708
|
+
const gc = runGc();
|
|
1709
|
+
ctx.ui.notify(
|
|
1710
|
+
t("notify.queueCleared", gc.snapDeleted, gc.queueDeleted || undefined),
|
|
1711
|
+
"success",
|
|
1712
|
+
);
|
|
1713
|
+
// 回到主菜单
|
|
1714
|
+
} else if (mainChoice.startsWith("⏸")) {
|
|
1715
|
+
// 删除并清空
|
|
1716
|
+
if (!activeWorkspace) {
|
|
1717
|
+
ctx.ui.notify(t("notify.noWorkspace"), "info");
|
|
1718
|
+
continue;
|
|
1719
|
+
}
|
|
1720
|
+
removeWorkspace(ctx.cwd);
|
|
1721
|
+
activeWorkspace = null;
|
|
1722
|
+
ctx.ui.setStatus("session-queue", undefined); // 状态 1:未启用
|
|
1723
|
+
beforeMap.clear();
|
|
1724
|
+
afterMap.clear();
|
|
1725
|
+
bashTrackedPaths.clear();
|
|
1726
|
+
turnIndex = 0;
|
|
1727
|
+
if (currentSessionId) {
|
|
1728
|
+
try { fs.unlinkSync(queueFilePath(currentSessionId)); } catch {}
|
|
1729
|
+
}
|
|
1730
|
+
// 删除 queue.json 后 GC 回收该 session 独占的孤儿快照
|
|
1731
|
+
const gc = runGc();
|
|
1732
|
+
ctx.ui.notify(
|
|
1733
|
+
t("notify.deletedAndCleared", gc.snapDeleted, gc.queueDeleted || undefined),
|
|
1734
|
+
"success",
|
|
1735
|
+
);
|
|
1736
|
+
return; // 状态已变,退出菜单
|
|
1737
|
+
} else if (mainChoice.startsWith("✅")) {
|
|
1738
|
+
// 启用当前工作区
|
|
1739
|
+
if (activeWorkspace) {
|
|
1740
|
+
ctx.ui.notify(t("notify.workspaceActive"), "info");
|
|
1741
|
+
continue;
|
|
1742
|
+
}
|
|
1743
|
+
const resolved = path.resolve(ctx.cwd);
|
|
1744
|
+
addWorkspace(resolved);
|
|
1745
|
+
activeWorkspace = resolved;
|
|
1746
|
+
ctx.ui.setStatus("session-queue", getStatusText());
|
|
1747
|
+
ctx.ui.notify(t("notify.enabledTracking", resolved), "success");
|
|
1748
|
+
return; // 状态已变,退出菜单
|
|
1749
|
+
} else if (mainChoice.startsWith("🔗")) {
|
|
1750
|
+
// 切换 Session Tree 跟随回滚开关
|
|
1751
|
+
followSessionTreeEnabled = !followSessionTreeEnabled;
|
|
1752
|
+
try {
|
|
1753
|
+
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
|
|
1754
|
+
const cfg = JSON.parse(raw);
|
|
1755
|
+
cfg.followSessionTree = followSessionTreeEnabled;
|
|
1756
|
+
atomicWriteFile(CONFIG_FILE, JSON.stringify(cfg, null, 2));
|
|
1757
|
+
} catch {
|
|
1758
|
+
atomicWriteFile(
|
|
1759
|
+
CONFIG_FILE,
|
|
1760
|
+
JSON.stringify({ workspaces: [], followSessionTree: followSessionTreeEnabled }, null, 2),
|
|
1761
|
+
);
|
|
1762
|
+
}
|
|
1763
|
+
ctx.ui.notify(
|
|
1764
|
+
t(followSessionTreeEnabled ? "notify.followOn" : "notify.followOff"),
|
|
1765
|
+
"info",
|
|
1766
|
+
);
|
|
1767
|
+
// 同步更新状态栏文案
|
|
1768
|
+
ctx.ui.setStatus("session-queue", getStatusText());
|
|
1769
|
+
// 回到主菜单
|
|
1770
|
+
} else if (mainChoice.startsWith("🧹")) {
|
|
1771
|
+
// 手动 GC
|
|
1772
|
+
const gc = runGc();
|
|
1773
|
+
ctx.ui.notify(
|
|
1774
|
+
t("notify.gcResult", gc.snapScanned, gc.snapDeleted, gc.live, gc.queueDeleted || undefined, gc.queueKept || undefined),
|
|
1775
|
+
"success",
|
|
1776
|
+
);
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
},
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
// =============================================================================
|
|
1784
|
+
// 管理工作区:二级菜单
|
|
1785
|
+
// =============================================================================
|
|
1786
|
+
|
|
1787
|
+
async function manageWorkspaces(ctx: any, _config: Config): Promise<boolean> {
|
|
1788
|
+
// 一级:列出已启用的工作区
|
|
1789
|
+
const config = loadConfig();
|
|
1790
|
+
if (config.workspaces.length === 0) {
|
|
1791
|
+
ctx.ui.notify(t("notify.noWorkspaces"), "info");
|
|
1792
|
+
return false;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
// 用绝对路径 + 标记当前激活
|
|
1796
|
+
const wsOptions = config.workspaces.map((ws) => {
|
|
1797
|
+
const isCurrent = activeWorkspace && path.resolve(ws) === activeWorkspace;
|
|
1798
|
+
const tag = isCurrent ? t("menu.workspaceCurrent") : "";
|
|
1799
|
+
return `${ws}${tag}`;
|
|
1800
|
+
});
|
|
1801
|
+
wsOptions.push(t("menu.back"));
|
|
1802
|
+
|
|
1803
|
+
const wsChoice = await ctx.ui.select(
|
|
1804
|
+
t("select.workspacesTitle", config.workspaces.length),
|
|
1805
|
+
wsOptions,
|
|
1806
|
+
);
|
|
1807
|
+
|
|
1808
|
+
if (!wsChoice || wsChoice === t("menu.back")) return false;
|
|
1809
|
+
|
|
1810
|
+
// 从选项中提取路径(精确匹配)
|
|
1811
|
+
const selectedWs = config.workspaces.find((ws) => wsChoice.startsWith(ws));
|
|
1812
|
+
if (!selectedWs) return false;
|
|
1813
|
+
|
|
1814
|
+
// 二级:对该工作区的操作
|
|
1815
|
+
const isCurrent = activeWorkspace && path.resolve(selectedWs) === activeWorkspace;
|
|
1816
|
+
const opOptions: string[] = [];
|
|
1817
|
+
if (isCurrent) {
|
|
1818
|
+
opOptions.push(t("menu.pauseThisWorkspace"));
|
|
1819
|
+
} else {
|
|
1820
|
+
opOptions.push(t("menu.enableThisWorkspace"));
|
|
1821
|
+
}
|
|
1822
|
+
opOptions.push(t("menu.removeFromList"));
|
|
1823
|
+
opOptions.push(t("menu.back"));
|
|
1824
|
+
|
|
1825
|
+
const opChoice = await ctx.ui.select(
|
|
1826
|
+
t("select.workspaceActionTitle", selectedWs),
|
|
1827
|
+
opOptions,
|
|
1828
|
+
);
|
|
1829
|
+
|
|
1830
|
+
if (!opChoice || opChoice === t("menu.back")) return false;
|
|
1831
|
+
|
|
1832
|
+
if (opChoice.startsWith("⏸")) {
|
|
1833
|
+
// 暂停
|
|
1834
|
+
if (activeWorkspace && path.resolve(selectedWs) === activeWorkspace) {
|
|
1835
|
+
removeWorkspace(ctx.cwd);
|
|
1836
|
+
activeWorkspace = null;
|
|
1837
|
+
ctx.ui.setStatus("session-queue", undefined); // 状态 1:未启用
|
|
1838
|
+
if (currentSessionId) {
|
|
1839
|
+
try { fs.unlinkSync(queueFilePath(currentSessionId)); } catch {}
|
|
1840
|
+
}
|
|
1841
|
+
const gc = runGc();
|
|
1842
|
+
ctx.ui.notify(
|
|
1843
|
+
t("notify.paused", selectedWs, gc.snapDeleted, gc.queueDeleted || undefined),
|
|
1844
|
+
"success",
|
|
1845
|
+
);
|
|
1846
|
+
return true;
|
|
1847
|
+
}
|
|
1848
|
+
} else if (opChoice.startsWith("✅")) {
|
|
1849
|
+
// 启用
|
|
1850
|
+
if (!activeWorkspace) {
|
|
1851
|
+
addWorkspace(ctx.cwd);
|
|
1852
|
+
activeWorkspace = path.resolve(ctx.cwd);
|
|
1853
|
+
ctx.ui.setStatus("session-queue", getStatusText());
|
|
1854
|
+
ctx.ui.notify(t("notify.enabled", selectedWs), "success");
|
|
1855
|
+
return true;
|
|
1856
|
+
} else {
|
|
1857
|
+
ctx.ui.notify(t("notify.otherActive"), "warning");
|
|
1858
|
+
}
|
|
1859
|
+
} else if (opChoice.startsWith("🗑️")) {
|
|
1860
|
+
// 删除(从 config 移除)
|
|
1861
|
+
const cfg = loadConfig();
|
|
1862
|
+
cfg.workspaces = cfg.workspaces.filter((w) => w !== selectedWs);
|
|
1863
|
+
saveConfig(cfg);
|
|
1864
|
+
if (activeWorkspace && path.resolve(selectedWs) === activeWorkspace) {
|
|
1865
|
+
activeWorkspace = null;
|
|
1866
|
+
ctx.ui.setStatus("session-queue", undefined);
|
|
1867
|
+
}
|
|
1868
|
+
ctx.ui.notify(t("notify.removedFromList", selectedWs), "success");
|
|
1869
|
+
return false; // 状态未变(只是配置变了),可继续菜单
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
return false;
|
|
1873
|
+
}
|