cc-viewer 1.6.249 → 1.6.251

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.html CHANGED
@@ -6,7 +6,7 @@
6
6
  <title>Claude Code Viewer</title>
7
7
  <link rel="icon" href="/favicon.ico?v=1">
8
8
  <link rel="shortcut icon" href="/favicon.ico?v=1">
9
- <script type="module" crossorigin src="/assets/index-etQ0_qi5.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-nCqmU6FA.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="/assets/vendor-antd-BeN8xqGk.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-2nbmPewy.js">
12
12
  <link rel="modulepreload" crossorigin href="/assets/vendor-mdxeditor-C7DYEBoH.js">
package/interceptor.js CHANGED
@@ -237,17 +237,32 @@ function resolveResumeChoice(choice) {
237
237
  }
238
238
 
239
239
  // Delta storage: 增量存储开关和状态(默认开启,设置 CCV_DISABLE_DELTA=1 关闭)
240
- // 注意:delta 计算依赖 mainAgent 请求串行(Claude CLI 保证),不做并发互斥
240
+ // 注意:delta 计算原本依赖 mainAgent 请求串行假设。实证发现 teammate 终止 / 多 SSE 通道
241
+ // 注入等情况会让两条 mainAgent 请求 30ms 内连续到达(前一条流式响应未完成时后一条已发起),
242
+ // 导致仅在 completed 时更新 _lastMessagesCount/_lastTailFp 出现状态滞后 → Plan C 漏检。
241
243
  const _deltaStorageEnabled = process.env.CCV_DISABLE_DELTA !== '1';
242
244
  // In-place last-msg replace 检测开关(默认开启,设置 CCV_DISABLE_TAIL_FP_CHECKPOINT=1 关闭)。
243
245
  // 关闭后回退到旧行为(仅按长度算 delta,遇到末位原地替换会丢失"末位换内容"信息)。
244
246
  const _tailFpCheckEnabled = process.env.CCV_DISABLE_TAIL_FP_CHECKPOINT !== '1';
245
- let _lastMessagesCount = 0; // 上一次 mainAgent 写入的完整 messages 数量
246
- let _lastTailFp = ''; // 上一次 mainAgent 末位 message 的指纹(用于 in-place replace 检测)
247
+ // 这两个变量代表"截至本次请求开始前的最新已知态"。请求开始处理时即同步更新(eager),
248
+ // 不再等到 _commitDeltaState(completed 时执行)。Plan C 检测使用进入函数前的快照。
249
+ // 异常分支(请求失败 / 服务端不发送)不会回滚——下一个成功请求覆盖即可,状态不会永久错位。
250
+ // 命名说明:变量名保留 `_last` 前缀(历史命名),但语义已由"上次 commit 后"变为"上次见到的最新态"。
251
+ // 三路并发场景下,连续 3 个请求若 length 非单调(如 257→259→258),_lastMessagesCount 会跟随
252
+ // 最新一次 startRequest,可能让早到的更大值被覆盖;这种情况 Plan C 走 length 不等支路最终
253
+ // 命中 needsCheckpoint 写完整快照,client 拿到正确数据,不破坏正确性。
254
+ let _lastMessagesCount = 0; // 截至最近一次 startRequest 的完整 messages 数量(eager-updated)
255
+ let _lastTailFp = ''; // 截至最近一次 startRequest 的末位 message 指纹(eager-updated)
247
256
  let _mainAgentDeltaCount = 0; // mainAgent 请求计数器(用于触发定期 checkpoint)
248
257
  const CHECKPOINT_INTERVAL = 10; // 每 N 条 mainAgent 请求写一个 checkpoint
249
258
 
250
- /** Delta storage: completed 写入成功后更新状态 */
259
+ /**
260
+ * Delta storage: completed 写入成功后更新状态。
261
+ * eager-update 已在请求开始时把 _lastMessagesCount/_lastTailFp 推到本次值,本函数对同
262
+ * originalLength 的 commit 实际是 no-op(`originalLength > _lastMessagesCount` 判等返回 false)。
263
+ * 保留为防御层:处理 eager 块未跑到的早期 return / throw(例如 _deltaStorageEnabled=false
264
+ * 或 messages 不是数组时不会进入 eager 块),让 commit 路径仍能在 completed 时把状态推上去。
265
+ */
251
266
  function _commitDeltaState(originalLength, originalTailFp) {
252
267
  if (_deltaStorageEnabled && originalLength > 0) {
253
268
  _lastMessagesCount = originalLength;
@@ -615,23 +630,35 @@ export function setupInterceptor() {
615
630
  _deltaOriginalTailFp = messages.length > 0 ? fingerprintMsg(messages[messages.length - 1]) : '';
616
631
  _mainAgentDeltaCount++;
617
632
 
633
+ // 并发竞态修复(详见模块顶部注释 + history.md Unreleased 段 fix(interceptor) 条目):
634
+ // snapshot 上一请求处理时的 count/fp 给 Plan C 用,然后 eager 把模块级状态推到本次值
635
+ // (不等 _commitDeltaState)。BUG 来源:teammate 终止快速串行让 mainAgent 30ms 内连续
636
+ // firing,旧 commit 时序使 Plan C 拿陈旧 prev 漏检 → client doubled-history。
637
+ const _prevMessagesCount = _lastMessagesCount;
638
+ const _prevTailFp = _lastTailFp;
639
+ if (_deltaOriginalMessagesLength > 0) {
640
+ _lastMessagesCount = _deltaOriginalMessagesLength;
641
+ if (_deltaOriginalTailFp !== '') _lastTailFp = _deltaOriginalTailFp;
642
+ }
643
+
618
644
  // In-place last-msg replace 检测:messages.length 不变但末位 fp 不同。
619
645
  // 触发场景:CLI 在 mainAgent 末位"原地替换"user msg(SUGGESTION MODE → 用户真实输入;
620
- // synthetic recap 通道注入;等),wire 上长度未变内容变了。旧逻辑 messages.slice(_lastMessagesCount)
621
- // 算出 delta=[],丢失了"末位换内容"信息 → 客户端重建拿到错误的"前态末位"。
646
+ // synthetic recap 通道注入;teammate 终止快速串行 → SUGGESTION MODE 多次替换;等),
647
+ // wire 上长度未变内容变了。旧逻辑 messages.slice(_lastMessagesCount) 算出 delta=[]
648
+ // 丢失了"末位换内容"信息 → 客户端重建拿到错误的"前态末位"。
622
649
  // 检测命中即强制写 checkpoint,让客户端拿到完整 wire 真实内容。
623
650
  const _sameLenInPlaceReplace =
624
651
  _tailFpCheckEnabled &&
625
- messages.length === _lastMessagesCount &&
626
- _lastMessagesCount > 0 &&
627
- _lastTailFp !== '' &&
652
+ messages.length === _prevMessagesCount &&
653
+ _prevMessagesCount > 0 &&
654
+ _prevTailFp !== '' &&
628
655
  _deltaOriginalTailFp !== '' &&
629
- _deltaOriginalTailFp !== _lastTailFp;
656
+ _deltaOriginalTailFp !== _prevTailFp;
630
657
 
631
658
  // 判断是否需要写 checkpoint
632
659
  const needsCheckpoint =
633
- _lastMessagesCount === 0 || // 进程重启 / 首次请求
634
- messages.length < _lastMessagesCount || // messages 缩短(/clear、context 压缩)
660
+ _prevMessagesCount === 0 || // 进程重启 / 首次请求
661
+ messages.length < _prevMessagesCount || // messages 缩短(/clear、context 压缩)
635
662
  (_mainAgentDeltaCount % CHECKPOINT_INTERVAL === 0) || // 定期 checkpoint
636
663
  _sameLenInPlaceReplace; // in-place last-msg replace 检测
637
664
 
@@ -643,12 +670,16 @@ export function setupInterceptor() {
643
670
  requestEntry._isCheckpoint = true;
644
671
  if (_sameLenInPlaceReplace) {
645
672
  // 诊断字段:标记此 checkpoint 是被 in-place replace 检测触发的(频率约 1-2%,
646
- // 用于在生产 jsonl 里事后核对触发率,不影响重建逻辑)
673
+ // 用于在生产 jsonl 里事后核对触发率,不影响重建逻辑)。
674
+ // 双方协议(KEEP IN SYNC: src/utils/sessionManager.js applyInPlaceLastMsgReplace):
675
+ // 客户端 helper 看到此字段=true(与 _isCheckpoint:true 同时存在)时直接 in-place 替换
676
+ // lastSession.messages 末位,跳过 sessionMerge prefix-overlap 算法(避开 doubled-history)。
677
+ // 字段重命名 / 删除前需同步两端 + 重跑双向回归测试。
647
678
  requestEntry._inPlaceReplaceDetected = true;
648
679
  }
649
680
  } else {
650
- // delta:只保留新增的 messages
651
- const delta = messages.slice(_lastMessagesCount);
681
+ // delta:只保留新增的 messages(必须用 _prevMessagesCount,不是 eager 已更新的 _lastMessagesCount)
682
+ const delta = messages.slice(_prevMessagesCount);
652
683
  requestEntry._deltaFormat = 1;
653
684
  requestEntry._totalMessageCount = messages.length;
654
685
  requestEntry._conversationId = 'mainAgent';
package/lib/git-diff.js CHANGED
@@ -8,6 +8,15 @@ const execFileAsync = promisify(execFile);
8
8
  const UNTRACKED_MAX_BYTES = 5 * 1024 * 1024;
9
9
  const BINARY_PROBE_BYTES = 8192;
10
10
 
11
+ // Reject only `..` as a path *segment* (start, between slashes, or end).
12
+ // Substring match (`'node..modules'.includes('..')`) was rejecting valid paths.
13
+ const PATH_TRAVERSAL = /(?:^|\/)\.\.(?:\/|$)/;
14
+
15
+ // Whitelist for git ref names returned by `git rev-parse --abbrev-ref @{u}`.
16
+ // Defense in depth: git-rev-parse output should never contain shell metacharacters,
17
+ // but we validate before passing to `git log <upstream>..HEAD`.
18
+ const SAFE_REF = /^[A-Za-z0-9_./\-]+$/;
19
+
11
20
  /**
12
21
  * Count inserted lines for an untracked file, matching `git diff --numstat`
13
22
  * semantics (line = `\n`-terminated run; an unterminated trailing run still
@@ -27,7 +36,7 @@ const BINARY_PROBE_BYTES = 8192;
27
36
  * @returns {number} inserted line count; 0 on any error, binary, or size cap
28
37
  */
29
38
  export function countUntrackedLines(cwd, file) {
30
- if (!file || file.includes('..') || file.startsWith('/')) return 0;
39
+ if (!file || PATH_TRAVERSAL.test(file) || file.startsWith('/')) return 0;
31
40
  try {
32
41
  const fp = join(cwd, file);
33
42
  // Reject when the final component is a symlink (lstat doesn't follow).
@@ -51,32 +60,193 @@ export function countUntrackedLines(cwd, file) {
51
60
  }
52
61
  }
53
62
 
63
+ /**
64
+ * Get commits between upstream and HEAD (i.e. local commits not yet pushed).
65
+ * Returns an empty list when:
66
+ * - HEAD is detached (rev-parse --abbrev-ref HEAD prints "HEAD")
67
+ * - Branch has no upstream (@{u} resolution fails)
68
+ * - Working tree is at upstream (no commits ahead)
69
+ *
70
+ * Each commit includes its changed files via a single `git log --name-status` call,
71
+ * to avoid one git invocation per commit.
72
+ *
73
+ * @param {string} cwd
74
+ * @param {object} [opts]
75
+ * @param {number} [opts.maxCommits=100] hard cap to keep payload bounded
76
+ * @returns {Promise<{ commits: Array, hasUpstream: boolean, branch: string|null, upstream: string|null, truncated?: boolean, totalCount?: number }>}
77
+ */
78
+ export async function getUnpushedCommits(cwd, { maxCommits = 100 } = {}) {
79
+ let branch = null;
80
+ try {
81
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, encoding: 'utf-8', timeout: 3000 });
82
+ branch = stdout.trim();
83
+ } catch {
84
+ return { commits: [], hasUpstream: false, branch: null, upstream: null };
85
+ }
86
+ if (!branch || branch === 'HEAD') {
87
+ return { commits: [], hasUpstream: false, branch, upstream: null };
88
+ }
89
+
90
+ let upstream = null;
91
+ try {
92
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], { cwd, encoding: 'utf-8', timeout: 3000 });
93
+ upstream = stdout.trim();
94
+ } catch {
95
+ return { commits: [], hasUpstream: false, branch, upstream: null };
96
+ }
97
+ if (!upstream || !SAFE_REF.test(upstream)) {
98
+ return { commits: [], hasUpstream: false, branch, upstream: null };
99
+ }
100
+
101
+ // Use NUL separators between fields and a sentinel between commits to avoid
102
+ // getting fooled by tabs/newlines inside commit subjects.
103
+ // Format: <hash>\x1f<author>\x1f<date>\x1f<subject>\n
104
+ // Followed by one `<status>\t<path>` line per file (from --name-status).
105
+ // Commits separated by `\x1e` (record separator).
106
+ const COMMIT_SEP = '\x1e';
107
+ const FIELD_SEP = '\x1f';
108
+ let stdout = '';
109
+ try {
110
+ const r = await execFileAsync(
111
+ 'git',
112
+ [
113
+ 'log',
114
+ `--max-count=${maxCommits}`,
115
+ `--pretty=format:${COMMIT_SEP}%H${FIELD_SEP}%an${FIELD_SEP}%aI${FIELD_SEP}%s`,
116
+ '--name-status',
117
+ `${upstream}..HEAD`,
118
+ ],
119
+ { cwd, encoding: 'utf-8', timeout: 8000, maxBuffer: 10 * 1024 * 1024 }
120
+ );
121
+ stdout = r.stdout;
122
+ } catch {
123
+ return { commits: [], hasUpstream: true, branch, upstream };
124
+ }
125
+
126
+ const commits = [];
127
+ const blocks = stdout.split(COMMIT_SEP).filter(Boolean);
128
+ for (const block of blocks) {
129
+ const lines = block.split('\n');
130
+ const header = lines[0] || '';
131
+ const parts = header.split(FIELD_SEP);
132
+ if (parts.length < 4) continue;
133
+ const [hash, author, date, subject] = parts;
134
+ const files = [];
135
+ for (let i = 1; i < lines.length; i++) {
136
+ const line = lines[i];
137
+ if (!line.trim()) continue;
138
+ const tab = line.indexOf('\t');
139
+ if (tab < 0) continue;
140
+ const st = line.substring(0, tab).trim();
141
+ const fp = line.substring(tab + 1);
142
+ if (!fp) continue;
143
+ files.push({ status: st[0] || 'M', file: fp });
144
+ }
145
+ commits.push({
146
+ hash,
147
+ shortHash: hash.substring(0, 7),
148
+ author,
149
+ date,
150
+ subject,
151
+ files,
152
+ });
153
+ }
154
+
155
+ // Detect truncation. 默认 truncated = (commits.length === maxCommits),因为命中 cap 大概率
156
+ // 是被截了;rev-list --count 成功才用真实数据覆盖。这样如果 rev-list 失败/超时(大 repo
157
+ // 可能 >3s),用户至少能看到截断标记,不会被静默隐藏 200 条未推送 commit。
158
+ let totalCount = commits.length;
159
+ let truncated = commits.length === maxCommits;
160
+ if (truncated) {
161
+ try {
162
+ const r = await execFileAsync('git', ['rev-list', '--count', `${upstream}..HEAD`], { cwd, encoding: 'utf-8', timeout: 3000 });
163
+ const parsed = parseInt(r.stdout.trim(), 10);
164
+ if (Number.isFinite(parsed) && parsed > 0) {
165
+ totalCount = parsed;
166
+ truncated = totalCount > commits.length;
167
+ }
168
+ } catch {}
169
+ }
170
+
171
+ return { commits, hasUpstream: true, branch, upstream, truncated, totalCount };
172
+ }
173
+
174
+ /**
175
+ * Validate git commit hash. Accept 7..40 hex chars only.
176
+ * Rejects refs like "HEAD~1", branch names, anything with shell metacharacters.
177
+ * @param {string} hash
178
+ * @returns {boolean}
179
+ */
180
+ export function isValidCommitHash(hash) {
181
+ return typeof hash === 'string' && /^[0-9a-f]{7,40}$/i.test(hash);
182
+ }
183
+
54
184
  /**
55
185
  * Get git diffs for a list of files.
186
+ * When commitHash is provided, diffs come from `git show <hash>` (parent vs hash);
187
+ * otherwise from working tree vs HEAD (default behavior).
188
+ *
56
189
  * @param {string} cwd - working directory (git repo root)
57
190
  * @param {string[]} files - relative file paths
191
+ * @param {string} [commitHash] - optional commit SHA to diff against its first parent
58
192
  * @returns {Promise<Array>} diffs array
59
193
  */
60
- export async function getGitDiffs(cwd, files) {
194
+ export async function getGitDiffs(cwd, files, commitHash) {
195
+ const useCommit = commitHash && isValidCommitHash(commitHash);
61
196
  const diffs = [];
62
197
 
198
+ // For commit-context diffs, get the per-file status table once instead of per file.
199
+ let commitStatusMap = null;
200
+ if (useCommit) {
201
+ try {
202
+ const { stdout } = await execFileAsync(
203
+ 'git',
204
+ ['diff-tree', '-r', '--no-commit-id', '--name-status', '--root', commitHash],
205
+ { cwd, encoding: 'utf-8', timeout: 5000, maxBuffer: 5 * 1024 * 1024 }
206
+ );
207
+ commitStatusMap = new Map();
208
+ for (const line of stdout.split('\n')) {
209
+ if (!line.trim()) continue;
210
+ const tab = line.indexOf('\t');
211
+ if (tab < 0) continue;
212
+ const st = line.substring(0, tab).trim();
213
+ const fp = line.substring(tab + 1);
214
+ commitStatusMap.set(fp, st[0] || 'M');
215
+ }
216
+ } catch {
217
+ commitStatusMap = new Map();
218
+ }
219
+ }
220
+
63
221
  for (const file of files) {
64
222
  // 安全检查:防止路径穿越
65
- if (file.includes('..') || file.startsWith('/')) continue;
223
+ if (PATH_TRAVERSAL.test(file) || file.startsWith('/')) continue;
66
224
 
67
225
  try {
68
- const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain', '--', file], { cwd, encoding: 'utf-8', timeout: 3000 });
69
- if (!statusOutput.trim()) continue;
226
+ let status;
227
+ let is_new;
228
+ let is_deleted;
70
229
 
71
- const status = statusOutput.substring(0, 2).trim();
72
- const is_new = status === 'A' || status === '??';
73
- const is_deleted = status === 'D';
230
+ if (useCommit) {
231
+ status = commitStatusMap.get(file) || 'M';
232
+ is_new = status === 'A';
233
+ is_deleted = status === 'D';
234
+ } else {
235
+ const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain', '--', file], { cwd, encoding: 'utf-8', timeout: 3000 });
236
+ if (!statusOutput.trim()) continue;
237
+ status = statusOutput.substring(0, 2).trim();
238
+ is_new = status === 'A' || status === '??';
239
+ is_deleted = status === 'D';
240
+ }
74
241
 
75
242
  // 检查是否为二进制文件(已删除文件跳过)
76
243
  let is_binary = false;
77
244
  if (!is_deleted) {
78
245
  try {
79
- const { stdout: diffCheck } = await execFileAsync('git', ['diff', '--numstat', 'HEAD', '--', file], { cwd, encoding: 'utf-8', timeout: 3000 });
246
+ const numstatArgs = useCommit
247
+ ? ['diff-tree', '-r', '--no-commit-id', '--numstat', '--root', commitHash, '--', file]
248
+ : ['diff', '--numstat', 'HEAD', '--', file];
249
+ const { stdout: diffCheck } = await execFileAsync('git', numstatArgs, { cwd, encoding: 'utf-8', timeout: 3000 });
80
250
  if (diffCheck.includes('-\t-\t')) {
81
251
  is_binary = true;
82
252
  }
@@ -87,31 +257,45 @@ export async function getGitDiffs(cwd, files) {
87
257
  let new_content = '';
88
258
 
89
259
  if (!is_binary) {
90
- // 获取旧内容(HEAD 版本)
260
+ // 获取旧内容
91
261
  if (!is_new) {
92
262
  try {
93
- const { stdout } = await execFileAsync('git', ['show', `HEAD:${file}`], { cwd, encoding: 'utf-8', timeout: 5000, maxBuffer: 5 * 1024 * 1024 });
263
+ const oldRef = useCommit ? `${commitHash}^:${file}` : `HEAD:${file}`;
264
+ const { stdout } = await execFileAsync('git', ['show', oldRef], { cwd, encoding: 'utf-8', timeout: 5000, maxBuffer: 5 * 1024 * 1024 });
94
265
  old_content = stdout;
95
266
  } catch {
96
267
  old_content = '';
97
268
  }
98
269
  }
99
270
 
100
- // 获取新内容(工作区版本)
271
+ // 获取新内容
101
272
  if (!is_deleted) {
102
- try {
103
- const filePath = join(cwd, file);
104
- if (existsSync(filePath)) {
105
- const stat = statSync(filePath);
106
- if (stat.size > 5 * 1024 * 1024) {
107
- // 文件过大
108
- diffs.push({ file, status, is_large: true, size: stat.size });
273
+ if (useCommit) {
274
+ try {
275
+ const { stdout } = await execFileAsync('git', ['show', `${commitHash}:${file}`], { cwd, encoding: 'utf-8', timeout: 5000, maxBuffer: 5 * 1024 * 1024 });
276
+ new_content = stdout;
277
+ if (Buffer.byteLength(new_content, 'utf-8') > 5 * 1024 * 1024) {
278
+ diffs.push({ file, status, is_large: true, size: Buffer.byteLength(new_content, 'utf-8') });
109
279
  continue;
110
280
  }
111
- new_content = readFileSync(filePath, 'utf-8');
281
+ } catch {
282
+ new_content = '';
283
+ }
284
+ } else {
285
+ try {
286
+ const filePath = join(cwd, file);
287
+ if (existsSync(filePath)) {
288
+ const stat = statSync(filePath);
289
+ if (stat.size > 5 * 1024 * 1024) {
290
+ // 文件过大
291
+ diffs.push({ file, status, is_large: true, size: stat.size });
292
+ continue;
293
+ }
294
+ new_content = readFileSync(filePath, 'utf-8');
295
+ }
296
+ } catch {
297
+ new_content = '';
112
298
  }
113
- } catch {
114
- new_content = '';
115
299
  }
116
300
  }
117
301
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.6.249",
3
+ "version": "1.6.251",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -42,7 +42,7 @@ import { checkAndUpdate } from './lib/updater.js';
42
42
  import { loadPlugins, runWaterfallHook, runParallelHook, getPluginsInfo, getPluginsDir } from './lib/plugin-loader.js';
43
43
  import { uploadPlugins, installPluginFromUrl } from './lib/plugin-manager.js';
44
44
  import { getUserProfile } from './lib/user-profile.js';
45
- import { getGitDiffs, countUntrackedLines } from './lib/git-diff.js';
45
+ import { getGitDiffs, countUntrackedLines, getUnpushedCommits, isValidCommitHash } from './lib/git-diff.js';
46
46
  import { CONTEXT_WINDOW_FILE, readModelContextSize, buildContextWindowEvent, getContextSizeForModel } from './lib/context-watcher.js';
47
47
  import { watchLogFile, startWatching, getWatchedFiles, sendEventToClients, sendToClients } from './lib/log-watcher.js';
48
48
  import { isMainAgentEntry, extractCachedContent } from './lib/kv-cache-analyzer.js';
@@ -3147,7 +3147,10 @@ async function handleRequest(req, res) {
3147
3147
  }
3148
3148
 
3149
3149
  const files = filesParam.split(',').map(f => f.trim()).filter(Boolean);
3150
- const diffs = await getGitDiffs(cwd, files);
3150
+ const commitParam = parsedUrl.searchParams.get('commit');
3151
+ // Reject malformed commit hashes; falsy = working-tree mode
3152
+ const commitHash = commitParam && isValidCommitHash(commitParam) ? commitParam : undefined;
3153
+ const diffs = await getGitDiffs(cwd, files, commitHash);
3151
3154
 
3152
3155
  res.writeHead(200, { 'Content-Type': 'application/json' });
3153
3156
  res.end(JSON.stringify({ diffs }));
@@ -3158,6 +3161,27 @@ async function handleRequest(req, res) {
3158
3161
  return;
3159
3162
  }
3160
3163
 
3164
+ // Local commits ahead of upstream (origin/<branch>..HEAD).
3165
+ // Silent empty result when no upstream / detached HEAD — by design.
3166
+ if (url.startsWith('/api/git-log-unpushed') && method === 'GET') {
3167
+ try {
3168
+ const repoParam = parsedUrl.searchParams.get('repo');
3169
+ const cwd = resolveRepoCwd(repoParam);
3170
+ if (!cwd) {
3171
+ res.writeHead(400, { 'Content-Type': 'application/json' });
3172
+ res.end(JSON.stringify({ error: 'Invalid repo parameter', commits: [], hasUpstream: false }));
3173
+ return;
3174
+ }
3175
+ const result = await getUnpushedCommits(cwd);
3176
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3177
+ res.end(JSON.stringify(result));
3178
+ } catch (err) {
3179
+ res.writeHead(500, { 'Content-Type': 'application/json' });
3180
+ res.end(JSON.stringify({ error: err.message, commits: [], hasUpstream: false }));
3181
+ }
3182
+ return;
3183
+ }
3184
+
3161
3185
  // 插件管理 API
3162
3186
  if (url === '/api/plugins' && method === 'GET') {
3163
3187
  const plugins = getPluginsInfo();