refactor: 用 StopFailure 事件替代定时轮询,移除空闲检测定时器

- 移除 heartbeat 空闲检测机制(PreToolUse 空 matcher)
- 新增 StopFailure hooks 处理 API 错误中断场景
- PostToolUse 仅保留 Edit(Write 通过 PreToolUse 快照实现)
- 移除 extension.ts 中 idleTimer/idleCooldown/heartbeatWatcher
- 更新 README hooks 配置文档
- 添加 MIT LICENSE
This commit is contained in:
2026-06-24 17:58:15 +08:00
parent 50b3868cc5
commit fa44163ca3
4 changed files with 132 additions and 22 deletions
+26 -3
View File
@@ -1,12 +1,24 @@
{ {
"hooks": { "hooks": {
"PostToolUse": [ "PreToolUse": [
{ {
"matcher": "Write|Edit", "matcher": "Write",
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "node -e \"const fs=require('fs'),p=require('path'),d=p.join(process.env.CLAUDE_PLUGIN_ROOT||'.','.claude','hooks');let b='';process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{try{const j=JSON.parse(b);const e={type:'edit',filePath:j.tool_input?.file_path||j.tool_response?.filePath||'',content:j.tool_input?.content||'',oldString:j.tool_input?.old_string||'',newString:j.tool_input?.new_string||'',timestamp:Date.now(),toolName:j.tool_name};fs.mkdirSync(d,{recursive:true});const t=p.join(d,'pending.json'),tmp=t+'.tmp';fs.writeFileSync(tmp,JSON.stringify(e));fs.renameSync(tmp,t)}catch{}})\"", "command": "node -e \"const fs=require('fs'),p=require('path');let b='';process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{try{const j=JSON.parse(b);const fp=j.tool_input?.file_path;if(!fp)return;const d=p.join(process.env.CLAUDE_PLUGIN_ROOT||'.','.claude','hooks');fs.mkdirSync(d,{recursive:true});let oc='';try{oc=fs.readFileSync(fp,'utf-8')}catch(e){}const s={originalContent:oc,timestamp:Date.now()};const f=p.join(d,'pre-write-snapshot.json'),tmp=f+'.tmp';fs.writeFileSync(tmp,JSON.stringify(s));fs.renameSync(tmp,f)}catch(e){}})\"",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "node -e \"const fs=require('fs'),p=require('path'),d=p.join(process.env.CLAUDE_PLUGIN_ROOT||'.','.claude','hooks');let b='';process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{try{const j=JSON.parse(b);const e={type:'edit',filePath:j.tool_input?.file_path||'',content:j.tool_input?.content||'',oldString:j.tool_input?.old_string||'',newString:j.tool_input?.new_string||'',timestamp:Date.now(),toolName:'Edit'};fs.mkdirSync(d,{recursive:true});const t=p.join(d,'pending.json'),tmp=t+'.tmp';fs.writeFileSync(tmp,JSON.stringify(e));fs.renameSync(tmp,t)}catch{}})\"",
"timeout": 5 "timeout": 5
} }
] ]
@@ -22,6 +34,17 @@
} }
] ]
} }
],
"StopFailure": [
{
"hooks": [
{
"type": "command",
"command": "node -e \"const fs=require('fs'),p=require('path'),d=p.join(process.env.CLAUDE_PLUGIN_ROOT||'.','.claude','hooks');try{fs.mkdirSync(d,{recursive:true});const t=p.join(d,'pending.json'),tmp=t+'.tmp';fs.writeFileSync(tmp,JSON.stringify({type:'stopFailure',filePath:'',content:'',oldString:'',newString:'',timestamp:Date.now(),toolName:'StopFailure'}));fs.renameSync(tmp,t)}catch{}\"",
"timeout": 5
}
]
}
] ]
} }
} }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Gmaker689
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+41 -9
View File
@@ -22,13 +22,25 @@
```json ```json
{ {
"hooks": { "hooks": {
"PostToolUse": [ "PreToolUse": [
{ {
"matcher": "Write|Edit", "matcher": "Write",
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "node -e \"const fs=require('fs'),p=require('path'),d=p.join(process.env.CLAUDE_PLUGIN_ROOT||'.','.claude','hooks');let b='';process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{try{const j=JSON.parse(b);const e={type:'edit',filePath:j.tool_input?.file_path||j.tool_response?.filePath||'',content:j.tool_input?.content||'',oldString:j.tool_input?.old_string||'',newString:j.tool_input?.new_string||'',timestamp:Date.now(),toolName:j.tool_name};fs.mkdirSync(d,{recursive:true});const t=p.join(d,'pending.json'),tmp=t+'.tmp';fs.writeFileSync(tmp,JSON.stringify(e));fs.renameSync(tmp,t)}catch{}})\"", "command": "node -e \"const fs=require('fs'),p=require('path');let b='';process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{try{const j=JSON.parse(b);const fp=j.tool_input?.file_path;if(!fp)return;const d=p.join(process.env.CLAUDE_PLUGIN_ROOT||'.','.claude','hooks');fs.mkdirSync(d,{recursive:true});let oc='';try{oc=fs.readFileSync(fp,'utf-8')}catch(e){}const s={originalContent:oc,timestamp:Date.now()};const f=p.join(d,'pre-write-snapshot.json'),tmp=f+'.tmp';fs.writeFileSync(tmp,JSON.stringify(s));fs.renameSync(tmp,f)}catch(e){}})\"",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "node -e \"const fs=require('fs'),p=require('path'),d=p.join(process.env.CLAUDE_PLUGIN_ROOT||'.','.claude','hooks');let b='';process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{try{const j=JSON.parse(b);const e={type:'edit',filePath:j.tool_input?.file_path||'',content:j.tool_input?.content||'',oldString:j.tool_input?.old_string||'',newString:j.tool_input?.new_string||'',timestamp:Date.now(),toolName:'Edit'};fs.mkdirSync(d,{recursive:true});const t=p.join(d,'pending.json'),tmp=t+'.tmp';fs.writeFileSync(tmp,JSON.stringify(e));fs.renameSync(tmp,t)}catch{}})\"",
"timeout": 5 "timeout": 5
} }
] ]
@@ -44,20 +56,40 @@
} }
] ]
} }
],
"StopFailure": [
{
"hooks": [
{
"type": "command",
"command": "node -e \"const fs=require('fs'),p=require('path'),d=p.join(process.env.CLAUDE_PLUGIN_ROOT||'.','.claude','hooks');try{fs.mkdirSync(d,{recursive:true});const t=p.join(d,'pending.json'),tmp=t+'.tmp';fs.writeFileSync(tmp,JSON.stringify({type:'stopFailure',filePath:'',content:'',oldString:'',newString:'',timestamp:Date.now(),toolName:'StopFailure'}));fs.renameSync(tmp,t)}catch{}\"",
"timeout": 5
}
]
}
] ]
} }
} }
``` ```
### Hooks 说明
| Hook | 匹配器 | 作用 |
| --- | --- | --- |
| **PreToolUse** | `Write` | 写入前保存文件原始内容快照,用于 diff 对比 |
| **PostToolUse** | `Edit` | 记录编辑信息(oldString/newString),用于精确 diff |
| **Stop** | — | Claude 正常完成响应 → 触发变更总览 |
| **StopFailure** | — | API 错误导致中断 → 触发变更总览 |
## 命令 ## 命令
| 命令 | 快捷键 | 功能 | | 命令 | 快捷键 | 功能 |
| --------------------------------- | ---------------- | ---------------------------------------- | | --- | --- | --- |
| `AI Diff: 显示所有变更文件列表` | `Ctrl+Shift+D` | QuickPick 总览 + Accept All / Reject All | | `AI Diff: 显示所有变更文件列表` | `Ctrl+Shift+D` | QuickPick 总览 + Accept All / Reject All |
| `AI Diff: 下一个变更` | `Alt+↓` | 跳转下一个 pending 文件 | | `AI Diff: 下一个变更` | `Alt+↓` | 跳转下一个 pending 文件 |
| `AI Diff: 上一个变更` | `Alt+↑` | 跳转上一个 pending 文件 | | `AI Diff: 上一个变更` | `Alt+↑` | 跳转上一个 pending 文件 |
| `AI Diff: 接受所有变更` | `Ctrl+Shift+A` | Accept All | | `AI Diff: 接受所有变更` | `Ctrl+Shift+A` | Accept All |
| `AI Diff: 拒绝所有变更` | `Ctrl+Shift+R` | Reject All | | `AI Diff: 拒绝所有变更` | `Ctrl+Shift+R` | Reject All |
Diff 编辑器打开后,状态栏右侧自动出现 **Accept / Reject / 接受全部** 按钮。 Diff 编辑器打开后,状态栏右侧自动出现 **Accept / Reject / 接受全部** 按钮。
+44 -10
View File
@@ -30,6 +30,10 @@ export function activate(context: vscode.ExtensionContext) {
hookHandler = new HookHandler(changeSetManager, diffViewer); hookHandler = new HookHandler(changeSetManager, diffViewer);
statusBar = new StatusBarManager(changeSetManager, diffViewer); statusBar = new StatusBarManager(changeSetManager, diffViewer);
// ★ 设置 hook 目录路径,供 snapshotManager 读取 PreToolUse 快照
const hookDir = getHookDir();
if (hookDir) changeSetManager.setHookDir(hookDir);
// DiffViewer 操作后刷新状态栏 // DiffViewer 操作后刷新状态栏
diffViewer.onDiffAction = () => refreshUI(); diffViewer.onDiffAction = () => refreshUI();
@@ -95,6 +99,24 @@ function registerCommands(context: vscode.ExtensionContext) {
vscode.commands.registerCommand(COMMANDS.PREV_DIFF, () => navigateDiff(-1)) vscode.commands.registerCommand(COMMANDS.PREV_DIFF, () => navigateDiff(-1))
); );
// ★ 逐次编辑导航
context.subscriptions.push(
vscode.commands.registerCommand('aiDiffPreview.nextEdit', () => diffViewer.showNextEdit())
);
context.subscriptions.push(
vscode.commands.registerCommand('aiDiffPreview.prevEdit', () => diffViewer.showPrevEdit())
);
context.subscriptions.push(
vscode.commands.registerCommand('aiDiffPreview.showEditAtIndex', () => {
const session = diffViewer.getActiveSession();
if (session) {
const set = changeSetManager.getCurrentSet();
const fc = set?.changes.find(c => c.id === session.fileId);
if (fc) diffViewer.showEditAtIndex(fc, 0);
}
})
);
// Diff 界面状态栏按钮 // Diff 界面状态栏按钮
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand('aiDiffPreview.acceptCurrentDiff', async () => { vscode.commands.registerCommand('aiDiffPreview.acceptCurrentDiff', async () => {
@@ -150,6 +172,14 @@ function refreshUI(): void {
vscode.commands.executeCommand('setContext', 'aiDiffPreview.isActive', hasPending); vscode.commands.executeCommand('setContext', 'aiDiffPreview.isActive', hasPending);
} }
// ---- Hook 目录 ----
function getHookDir(): string | null {
const folder = vscode.workspace.workspaceFolders?.[0];
if (!folder) return null;
return path.join(folder.uri.fsPath, '.claude', 'hooks');
}
// ---- Hook 监听 ---- // ---- Hook 监听 ----
function registerHookFileWatcher(context: vscode.ExtensionContext) { function registerHookFileWatcher(context: vscode.ExtensionContext) {
@@ -160,11 +190,13 @@ function registerHookFileWatcher(context: vscode.ExtensionContext) {
if (!fs.existsSync(hookDir)) fs.mkdirSync(hookDir, { recursive: true }); if (!fs.existsSync(hookDir)) fs.mkdirSync(hookDir, { recursive: true });
const pendingFile = path.join(hookDir, 'pending.json'); const pendingFile = path.join(hookDir, 'pending.json');
const watcher = vscode.workspace.createFileSystemWatcher(
// ★ 监听 pending.json(Edit/Stop/StopFailure 事件)
const pendingWatcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(hookDir, 'pending.json') new vscode.RelativePattern(hookDir, 'pending.json')
); );
const handle = () => { const handlePending = () => {
try { try {
if (!fs.existsSync(pendingFile)) return; if (!fs.existsSync(pendingFile)) return;
const content = fs.readFileSync(pendingFile, 'utf-8'); const content = fs.readFileSync(pendingFile, 'utf-8');
@@ -172,18 +204,20 @@ function registerHookFileWatcher(context: vscode.ExtensionContext) {
const event: ClaudeHookEvent = JSON.parse(content); const event: ClaudeHookEvent = JSON.parse(content);
if (Date.now() - event.timestamp > 10000) return; if (Date.now() - event.timestamp > 10000) return;
if (event.toolName === 'Stop') { if (event.toolName === 'Stop' || event.toolName === 'StopFailure') {
// ★ 对话结束(正常完成或 API 失败)→ 触发 diff 总览
hookHandler.handleStop(); hookHandler.handleStop();
} else if (event.toolName === 'Edit' || event.toolName === 'Write') { } else if (event.toolName === 'Edit') {
hookHandler.handleEdit(event); hookHandler.handleEdit(event);
} }
refreshUI(); refreshUI();
} catch (e) { console.error('[AI Diff] 处理触发文件失败:', e); } } catch (e) { console.error('[AI Diff] 处理 pending 文件失败:', e); }
}; };
watcher.onDidChange(handle); pendingWatcher.onDidChange(handlePending);
watcher.onDidCreate(handle); pendingWatcher.onDidCreate(handlePending);
context.subscriptions.push(watcher); context.subscriptions.push(pendingWatcher);
console.log(`[AI Diff] Hook 监听已启动: ${hookDir}`); console.log(`[AI Diff] Hook 监听已启动: ${hookDir}`);
} }
@@ -199,8 +233,8 @@ function checkPendingHookFile() {
fs.unlinkSync(pendingFile); fs.unlinkSync(pendingFile);
const event: ClaudeHookEvent = JSON.parse(content); const event: ClaudeHookEvent = JSON.parse(content);
if (Date.now() - event.timestamp < 15000) { if (Date.now() - event.timestamp < 15000) {
if (event.toolName === 'Stop') hookHandler.handleStop(); if (event.toolName === 'Stop' || event.toolName === 'StopFailure') hookHandler.handleStop();
else if (event.toolName === 'Edit' || event.toolName === 'Write') hookHandler.handleEdit(event); else if (event.toolName === 'Edit') hookHandler.handleEdit(event);
refreshUI(); refreshUI();
} }
} catch (e) { console.error('[AI Diff] 处理 pending hook 失败:', e); } } catch (e) { console.error('[AI Diff] 处理 pending hook 失败:', e); }