cc-viewer 1.6.269 → 1.6.271
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 +3 -17
- package/cli.js +13 -2
- package/dist/assets/{App-DzjYA7D5.js → App-LEYaH-CM.js} +1 -1
- package/dist/assets/{MdxEditorPanel-jhYrPElX.js → MdxEditorPanel-B7oOvR3k.js} +1 -1
- package/dist/assets/{Mobile-CsHrWYjl.js → Mobile-Cwf21Pmq.js} +1 -1
- package/dist/assets/{index-CzhQgS4q.js → index-CLfbZzwF.js} +2 -2
- package/dist/assets/index-wU9AHa7Y.css +1 -0
- package/dist/assets/seqResourceLoaders-CmFg0jyW.js +2 -0
- package/dist/assets/seqResourceLoaders-N07Gfom9.css +41 -0
- package/dist/index.html +2 -2
- package/dist/voice-packs/default/A_choice_for_your_consideration_sir.MP3 +0 -0
- package/dist/voice-packs/default/It_is_done_sir.MP3 +0 -0
- package/dist/voice-packs/default/The_plan_awaits_your_approval_sir.MP3 +0 -0
- package/dist/voice-packs/default/pack.json +13 -23
- package/dist/voice-packs/sanguo/ask.MP3 +0 -0
- package/dist/voice-packs/sanguo/end.MP3 +0 -0
- package/dist/voice-packs/sanguo/pack.json +24 -0
- package/dist/voice-packs/sanguo/plan.MP3 +0 -0
- package/lib/ask-bridge.js +128 -7
- package/lib/ask-constants.js +13 -0
- package/lib/ask-store.js +319 -0
- package/lib/ensure-hooks.js +54 -9
- package/lib/sdk-manager.js +4 -1
- package/lib/voice-pack-events.js +51 -5
- package/lib/voice-pack-manager.js +135 -38
- package/package.json +1 -1
- package/pty-manager.js +2 -2
- package/server.js +519 -43
- package/dist/assets/index-_4BCXKKF.css +0 -1
- package/dist/assets/seqResourceLoaders-BZiHSoZE.css +0 -41
- package/dist/assets/seqResourceLoaders-DvOHDZMB.js +0 -2
- package/dist/voice-packs/default/askQuestion.wav +0 -0
- package/dist/voice-packs/default/planApproval.wav +0 -0
- package/dist/voice-packs/default/timeoutWarning5min.wav +0 -0
- package/dist/voice-packs/default/timeoutWarning60s.wav +0 -0
- package/dist/voice-packs/default/turnEnd.wav +0 -0
package/lib/ask-bridge.js
CHANGED
|
@@ -85,7 +85,33 @@ for (const q of questions) {
|
|
|
85
85
|
// 缺失时 server 会 fallback 到自生成 ask_${ts}_${rand}(向后兼容老 Claude Code 版本)。
|
|
86
86
|
const toolUseId = payload?.tool_use_id || null;
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
// 故意不设客户端 req.setTimeout:cc-viewer 走 127.0.0.1 长连接,
|
|
89
|
+
// 无反代/CDN 介入,由 server 端 entry res.on('close') + ASK_HOOK_TIMEOUT
|
|
90
|
+
// 主动结束响应。用户在 GUI 里的等待时长不再受 ask-bridge 60min 硬上限限制。
|
|
91
|
+
//
|
|
92
|
+
// 新协议(Phase 3 短轮询):
|
|
93
|
+
// POST /api/ask-hook with `X-Ask-Poll-Mode: short`
|
|
94
|
+
// 新 server → 立即返 { id, capability: 'short-poll' };client 转 GET 循环
|
|
95
|
+
// 旧 server → 忽略 header 仍走 long-poll,最终返 { answers } 或 { cancelled }
|
|
96
|
+
// GET /api/ask-hook/:id/result?wait=30000
|
|
97
|
+
// 200 + { answers } → 完成
|
|
98
|
+
// 200 + { cancelled, reason } → server 通知 cancel
|
|
99
|
+
// 204 → 重发 GET
|
|
100
|
+
// 404 → entry 真消失(disk 也丢了)→ 重试 POST 一次(重建 entry)
|
|
101
|
+
// 5xx / 网络错误 → 指数退避重试
|
|
102
|
+
//
|
|
103
|
+
// 这样 server 重启 + 网络抖动场景下 ask-bridge 不再直接 fallback terminal,
|
|
104
|
+
// 而是续 GET 等回答(最长 24h,与 server 端 ASK_HOOK_TIMEOUT 同步)。
|
|
105
|
+
// 25s 给反代留 5s buffer:nginx proxy_read_timeout 默认 60s / CloudFlare 100s /
|
|
106
|
+
// AWS ALB 60s — 30s 是边界值激进配置会切。25s 在常见配置下安全且效率仍接近 30s。
|
|
107
|
+
const POLL_WAIT_MS = 25000;
|
|
108
|
+
const MAX_NETWORK_RETRIES = 60; // ~5 min of exponential backoff before giving up
|
|
109
|
+
// 5xx 持久故障(server 真坏)不应该被当作"网络抖动"等 5 分钟 —— hook 进程挂死 5min 会
|
|
110
|
+
// 阻塞 Claude Code 主进程(ensure-hooks 注的 24h timeout 在外侧)。给 5xx 一个独立的小重试
|
|
111
|
+
// 上限:3 次 + 短退避(500ms / 1s / 2s),失败后立即 fallback 让用户走 TUI 而不是干等。
|
|
112
|
+
const MAX_SERVER_5XX_RETRIES = 3;
|
|
113
|
+
|
|
114
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
89
115
|
|
|
90
116
|
function postToViewer() {
|
|
91
117
|
return new Promise((resolve, reject) => {
|
|
@@ -95,10 +121,11 @@ function postToViewer() {
|
|
|
95
121
|
port: Number(port),
|
|
96
122
|
path: '/api/ask-hook',
|
|
97
123
|
method: 'POST',
|
|
98
|
-
rejectUnauthorized: false,
|
|
124
|
+
rejectUnauthorized: false,
|
|
99
125
|
headers: {
|
|
100
126
|
'Content-Type': 'application/json',
|
|
101
127
|
'Content-Length': Buffer.byteLength(body),
|
|
128
|
+
'X-Ask-Poll-Mode': 'short',
|
|
102
129
|
},
|
|
103
130
|
}, (res) => {
|
|
104
131
|
let data = '';
|
|
@@ -118,17 +145,111 @@ function postToViewer() {
|
|
|
118
145
|
});
|
|
119
146
|
});
|
|
120
147
|
req.on('error', reject);
|
|
121
|
-
req.setTimeout(TIMEOUT_MS, () => {
|
|
122
|
-
req.destroy();
|
|
123
|
-
reject(new Error('Timeout'));
|
|
124
|
-
});
|
|
125
148
|
req.write(body);
|
|
126
149
|
req.end();
|
|
127
150
|
});
|
|
128
151
|
}
|
|
129
152
|
|
|
153
|
+
function getPollResult(id, waitMs) {
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
const req = httpClient.request({
|
|
156
|
+
hostname: '127.0.0.1',
|
|
157
|
+
port: Number(port),
|
|
158
|
+
path: `/api/ask-hook/${encodeURIComponent(id)}/result?wait=${waitMs}`,
|
|
159
|
+
method: 'GET',
|
|
160
|
+
rejectUnauthorized: false,
|
|
161
|
+
}, (res) => {
|
|
162
|
+
let data = '';
|
|
163
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
164
|
+
res.on('end', () => {
|
|
165
|
+
if (res.statusCode === 200) {
|
|
166
|
+
try { resolve({ status: 200, body: JSON.parse(data) }); }
|
|
167
|
+
catch { reject(new Error('Invalid GET response JSON')); }
|
|
168
|
+
} else if (res.statusCode === 204) {
|
|
169
|
+
resolve({ status: 204, body: null });
|
|
170
|
+
} else if (res.statusCode === 404) {
|
|
171
|
+
resolve({ status: 404, body: null });
|
|
172
|
+
} else {
|
|
173
|
+
const err = new Error(`HTTP ${res.statusCode}`);
|
|
174
|
+
err.statusCode = res.statusCode;
|
|
175
|
+
reject(err);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
req.on('error', reject);
|
|
180
|
+
req.end();
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function fatal(message) {
|
|
185
|
+
const e = new Error(message);
|
|
186
|
+
e.fatal = true; // 标记不可恢复,catch 不走任何重试逻辑直接 fallback terminal
|
|
187
|
+
return e;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function pollUntilAnswered(askId) {
|
|
191
|
+
let networkRetries = 0;
|
|
192
|
+
let server5xxRetries = 0;
|
|
193
|
+
let postRetried = false;
|
|
194
|
+
while (true) {
|
|
195
|
+
try {
|
|
196
|
+
const { status, body } = await getPollResult(askId, POLL_WAIT_MS);
|
|
197
|
+
if (status === 200) return body; // { answers } 或 { cancelled }
|
|
198
|
+
if (status === 204) { // 无答案,立即重发
|
|
199
|
+
networkRetries = 0;
|
|
200
|
+
server5xxRetries = 0;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (status === 404) {
|
|
204
|
+
// entry 真消失(server 重启 + disk pruned)→ 重 POST 一次重建
|
|
205
|
+
if (postRetried) throw fatal('Ask entry gone (404 after retry)');
|
|
206
|
+
postRetried = true;
|
|
207
|
+
const originalId = askId;
|
|
208
|
+
const reInit = await postToViewer();
|
|
209
|
+
if (reInit?.capability === 'short-poll' && reInit?.id) {
|
|
210
|
+
// server fallback id(toolUseId 校验失败时自生成)会让浏览器持有的 askId 与 hook 端
|
|
211
|
+
// 持有的 askId 分裂 —— 浏览器答的 ws ask-hook-answer 找不到对应 entry,UX 上等同失联。
|
|
212
|
+
// 不一致直接 fallback TUI 比绕一圈安全。fatal flag 跳过外层重试逻辑。
|
|
213
|
+
if (reInit.id !== originalId) {
|
|
214
|
+
throw fatal(`Re-init id mismatch (${originalId} → ${reInit.id})`);
|
|
215
|
+
}
|
|
216
|
+
askId = reInit.id;
|
|
217
|
+
networkRetries = 0;
|
|
218
|
+
server5xxRetries = 0;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (reInit?.answers || reInit?.cancelled) {
|
|
222
|
+
// 重 POST 时 server 已经把答案带回来(罕见但合法 long-poll 路径)
|
|
223
|
+
return reInit;
|
|
224
|
+
}
|
|
225
|
+
throw fatal('Re-init returned unexpected payload');
|
|
226
|
+
}
|
|
227
|
+
throw new Error(`Unexpected status ${status}`);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
// 不可恢复错误(id mismatch / postRetried 后再 404 / 异常 payload)→ 立即放弃。
|
|
230
|
+
// 否则会被下方网络重试逻辑反复重 GET,每次还是同样错误,60 次后才退出 ~5min。
|
|
231
|
+
if (err?.fatal) throw err;
|
|
232
|
+
// 5xx 走独立短重试上限,不与网络抖动共用 60 次配额。
|
|
233
|
+
const is5xx = typeof err?.statusCode === 'number' && err.statusCode >= 500 && err.statusCode < 600;
|
|
234
|
+
if (is5xx) {
|
|
235
|
+
if (++server5xxRetries > MAX_SERVER_5XX_RETRIES) throw err;
|
|
236
|
+
await sleep(Math.min(2000, 500 * Math.pow(2, server5xxRetries - 1)));
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (++networkRetries > MAX_NETWORK_RETRIES) throw err;
|
|
240
|
+
// 指数退避:100ms → 200 → 400 → ... 上限 5s
|
|
241
|
+
await sleep(Math.min(5000, 100 * Math.pow(2, networkRetries - 1)));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
130
246
|
try {
|
|
131
|
-
|
|
247
|
+
let data = await postToViewer();
|
|
248
|
+
// Phase 3: server 看到 X-Ask-Poll-Mode 立即返 ack;旧 server 忽略 header 仍走 long-poll。
|
|
249
|
+
// capability='short-poll' → 进入 GET 循环;否则按 long-poll 返回值(answers / cancelled)直接处理。
|
|
250
|
+
if (data?.capability === 'short-poll' && data?.id) {
|
|
251
|
+
data = await pollUntilAnswered(data.id);
|
|
252
|
+
}
|
|
132
253
|
// 用户在 cc-viewer web UI 主动取消(点 Cancel 按钮 / 在输入框打字打断 pending ask)。
|
|
133
254
|
// server.js 的 ask-cancel handler 会给 hook res 回 200 + { cancelled: true, reason }。
|
|
134
255
|
// 输出 PreToolUse hook deny 让 Claude Code 走兜底链:toolExecution.ts 把 deny.message 包装
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// AskUserQuestion 超时常量的单一来源。
|
|
2
|
+
//
|
|
3
|
+
// server.js 的 hook 路径(ask-bridge → /api/ask-hook)和 sdk-manager.js 的 SDK 路径
|
|
4
|
+
// (canUseTool → _waitForApproval) 都从这里取 ASK_TIMEOUT_MS,保证两条路径行为一致:
|
|
5
|
+
// 用户视角上 "GUI 实质无超时(与 TUI 对齐)"。
|
|
6
|
+
//
|
|
7
|
+
// 前端 AskTimeoutCountdown.NO_TIMEOUT_THRESHOLD_MS(12h)是渲染阈值,本身不需要等于
|
|
8
|
+
// ASK_TIMEOUT_MS —— 只要超过它就 return null 不渲染倒计时。但如果改这里把 ASK_TIMEOUT_MS
|
|
9
|
+
// 降到 12h 以下,必须同步降前端阈值,否则倒计时又会显示出来。
|
|
10
|
+
//
|
|
11
|
+
// 纯常量模块:无任何 node-only 依赖,前端 webpack / 后端 ESM 都能 import。
|
|
12
|
+
|
|
13
|
+
export const ASK_TIMEOUT_MS = 24 * 60 * 60 * 1000;
|
package/lib/ask-store.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// AskUserQuestion 持久化存储:让 server 重启后仍能恢复 pending 状态。
|
|
2
|
+
//
|
|
3
|
+
// 设计原则:
|
|
4
|
+
// - 与 workspace-registry 同款 wx-lockfile + tmp-rename 原子写策略;无新依赖。
|
|
5
|
+
// - 单文件 JSON,schema 简单(pending 数极少,几乎不会有性能压力);
|
|
6
|
+
// 未来需要换 SQLite 时只换实现,API 稳定。
|
|
7
|
+
// - load/save 在 server 持有内存 Map 的"边缘"调用:set/delete 后同步落盘,
|
|
8
|
+
// 保证内存 Map 是权威源、磁盘是镜像。crash 时只丢"未落盘窗口"内的变更。
|
|
9
|
+
// - 启动时 hydrate 出的 entry 的 res 字段为 null(旧连接已死),
|
|
10
|
+
// 等待新的 ask-bridge 重新 POST 同 toolUseId 时复用(已在 server.js:2727 实现)
|
|
11
|
+
// 或浏览器通过 /api/pending-asks 拉取展示。
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, openSync, closeSync, unlinkSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
|
+
import { renameSyncWithRetry } from './file-api.js';
|
|
16
|
+
import { LOG_DIR } from '../findcc.js';
|
|
17
|
+
|
|
18
|
+
const SCHEMA_VERSION = 1;
|
|
19
|
+
|
|
20
|
+
// 进程级一次性 warn 标志:磁盘满 / 权限错误 / SIGPIPE 等持久故障会让每次 ask 都打 warn 刷屏;
|
|
21
|
+
// 限制只 log 第一次,让用户能注意到但不至于淹没日志。reset 不暴露 —— 进程重启自然清零。
|
|
22
|
+
let _loggedPersistError = false;
|
|
23
|
+
|
|
24
|
+
function getStoreFile() { return join(LOG_DIR, 'ask-store.json'); }
|
|
25
|
+
function getLockFile() { return join(LOG_DIR, 'ask-store.lock'); }
|
|
26
|
+
|
|
27
|
+
function sleep(ms) {
|
|
28
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function withLock(fn) {
|
|
32
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
33
|
+
const deadline = Date.now() + 2000;
|
|
34
|
+
const STALE_THRESHOLD = 5000;
|
|
35
|
+
while (true) {
|
|
36
|
+
try {
|
|
37
|
+
const fd = openSync(getLockFile(), 'wx');
|
|
38
|
+
closeSync(fd);
|
|
39
|
+
break;
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (err?.code === 'EEXIST') {
|
|
42
|
+
if (Date.now() < deadline) {
|
|
43
|
+
try {
|
|
44
|
+
const stats = statSync(getLockFile());
|
|
45
|
+
if (Date.now() - stats.mtimeMs > STALE_THRESHOLD) {
|
|
46
|
+
try { unlinkSync(getLockFile()); } catch {}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
sleep(25);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
try { return fn(); } finally {
|
|
58
|
+
try { unlinkSync(getLockFile()); } catch {}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Load all persisted entries from disk.
|
|
64
|
+
* 返回 { [id]: { id, questions, createdAt, status, answers, answeredAt, cancelReason } }
|
|
65
|
+
* status: 'pending' | 'answered' | 'cancelled'
|
|
66
|
+
* 缺文件 / 解析失败均返空对象(容错)。
|
|
67
|
+
*/
|
|
68
|
+
export function loadAskStore() {
|
|
69
|
+
try {
|
|
70
|
+
if (!existsSync(getStoreFile())) return {};
|
|
71
|
+
const raw = readFileSync(getStoreFile(), 'utf-8');
|
|
72
|
+
if (!raw.trim()) return {};
|
|
73
|
+
const data = JSON.parse(raw);
|
|
74
|
+
if (!data || typeof data !== 'object') return {};
|
|
75
|
+
if (data.version !== SCHEMA_VERSION) return {};
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const [id, entry] of Object.entries(data.entries || {})) {
|
|
78
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
79
|
+
if (typeof id !== 'string' || id.length === 0) continue;
|
|
80
|
+
if (!Array.isArray(entry.questions)) continue;
|
|
81
|
+
const status = entry.status === 'answered' || entry.status === 'cancelled' ? entry.status : 'pending';
|
|
82
|
+
out[id] = {
|
|
83
|
+
id,
|
|
84
|
+
questions: entry.questions,
|
|
85
|
+
createdAt: Number(entry.createdAt) || Date.now(),
|
|
86
|
+
status,
|
|
87
|
+
answers: (status === 'answered' && entry.answers && typeof entry.answers === 'object') ? entry.answers : null,
|
|
88
|
+
answeredAt: Number(entry.answeredAt) || null,
|
|
89
|
+
cancelReason: (status === 'cancelled' && typeof entry.cancelReason === 'string') ? entry.cancelReason : null,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
} catch {
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Save the full entries map (atomic).
|
|
100
|
+
* Callers should pass the same shape returned by loadAskStore — extra fields are stripped.
|
|
101
|
+
*/
|
|
102
|
+
export function saveAskStore(entries) {
|
|
103
|
+
const cleaned = {};
|
|
104
|
+
for (const [id, entry] of Object.entries(entries || {})) {
|
|
105
|
+
if (!entry || typeof id !== 'string') continue;
|
|
106
|
+
if (!Array.isArray(entry.questions)) continue;
|
|
107
|
+
const status = entry.status === 'answered' || entry.status === 'cancelled' ? entry.status : 'pending';
|
|
108
|
+
cleaned[id] = {
|
|
109
|
+
id,
|
|
110
|
+
questions: entry.questions,
|
|
111
|
+
createdAt: Number(entry.createdAt) || Date.now(),
|
|
112
|
+
status,
|
|
113
|
+
answers: status === 'answered' ? (entry.answers || null) : null,
|
|
114
|
+
answeredAt: status === 'answered' ? (Number(entry.answeredAt) || Date.now()) : null,
|
|
115
|
+
cancelReason: status === 'cancelled' ? (entry.cancelReason || '') : null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const tmpFile = `${getStoreFile()}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
|
|
119
|
+
try {
|
|
120
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
121
|
+
const body = JSON.stringify({ version: SCHEMA_VERSION, entries: cleaned });
|
|
122
|
+
writeFileSync(tmpFile, body);
|
|
123
|
+
renameSyncWithRetry(tmpFile, getStoreFile());
|
|
124
|
+
} catch (err) {
|
|
125
|
+
try { unlinkSync(tmpFile); } catch {}
|
|
126
|
+
// 持久化失败 ≠ 业务失败:server 主流程不能因此卡住。
|
|
127
|
+
// 调用方(server.js setEntry/deleteEntry)会吞这个错(落盘是 best-effort)。
|
|
128
|
+
// 但用户视角 server 重启后 /api/pending-asks 永远空 → 找不到原因。
|
|
129
|
+
// 进程级首次失败 console.warn 一次方便排查(磁盘满 / 权限 / SIGPIPE)。
|
|
130
|
+
if (!_loggedPersistError) {
|
|
131
|
+
_loggedPersistError = true;
|
|
132
|
+
try {
|
|
133
|
+
console.warn(`[cc-viewer] ask-store persistence failed (will retry silently): ${err?.message || err}`);
|
|
134
|
+
} catch {}
|
|
135
|
+
}
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Mark an entry as answered. If the entry doesn't exist on disk yet, create it with
|
|
142
|
+
* minimal shape (questions=[]) so ask-bridge short-poll can still pick it up after
|
|
143
|
+
* a server restart that lost the in-memory placeholder.
|
|
144
|
+
*
|
|
145
|
+
* First-write-wins: 若 disk 已 answered/cancelled,本调用 noop —— 防止两浏览器同答时
|
|
146
|
+
* last-write-wins 覆盖错乱(A 选 X 落盘 → B 选 Y 又落 → ask-bridge 最终拿 Y 而 A UI 显 X)。
|
|
147
|
+
* Returns true if this call wrote, false if noop'd by existing terminal state.
|
|
148
|
+
*/
|
|
149
|
+
export function markAnswered(id, answers) {
|
|
150
|
+
if (!id || typeof id !== 'string') return false;
|
|
151
|
+
if (!answers || typeof answers !== 'object') return false;
|
|
152
|
+
try {
|
|
153
|
+
return withLock(() => {
|
|
154
|
+
const all = loadAskStore();
|
|
155
|
+
const existing = all[id];
|
|
156
|
+
if (existing && (existing.status === 'answered' || existing.status === 'cancelled')) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
const base = existing || { id, questions: [], createdAt: Date.now() };
|
|
160
|
+
all[id] = {
|
|
161
|
+
...base,
|
|
162
|
+
status: 'answered',
|
|
163
|
+
answers,
|
|
164
|
+
answeredAt: Date.now(),
|
|
165
|
+
cancelReason: null,
|
|
166
|
+
};
|
|
167
|
+
saveAskStore(all);
|
|
168
|
+
return true;
|
|
169
|
+
});
|
|
170
|
+
} catch { return false; }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function markCancelled(id, reason) {
|
|
174
|
+
if (!id || typeof id !== 'string') return false;
|
|
175
|
+
try {
|
|
176
|
+
return withLock(() => {
|
|
177
|
+
const all = loadAskStore();
|
|
178
|
+
const existing = all[id];
|
|
179
|
+
if (existing && (existing.status === 'answered' || existing.status === 'cancelled')) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
const base = existing || { id, questions: [], createdAt: Date.now() };
|
|
183
|
+
all[id] = {
|
|
184
|
+
...base,
|
|
185
|
+
status: 'cancelled',
|
|
186
|
+
answers: null,
|
|
187
|
+
answeredAt: null,
|
|
188
|
+
cancelReason: typeof reason === 'string' ? reason : '',
|
|
189
|
+
};
|
|
190
|
+
saveAskStore(all);
|
|
191
|
+
return true;
|
|
192
|
+
});
|
|
193
|
+
} catch { return false; }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Atomic conditional consume: read entry; if status === 'answered' or 'cancelled',
|
|
198
|
+
* delete and return it (one-shot consumption); otherwise return the entry without
|
|
199
|
+
* deleting (caller can decide to wait/retry).
|
|
200
|
+
*
|
|
201
|
+
* 替代旧的 "无条件 consume + setEntry 写回" 双 withLock 模式 —— 那种模式的两次锁
|
|
202
|
+
* 中间窗口会被 markAnswered 命中 → setEntry 把答案覆盖回 pending。
|
|
203
|
+
*/
|
|
204
|
+
export function consumeIfFinal(id) {
|
|
205
|
+
if (!id || typeof id !== 'string') return null;
|
|
206
|
+
try {
|
|
207
|
+
return withLock(() => {
|
|
208
|
+
const all = loadAskStore();
|
|
209
|
+
const entry = all[id];
|
|
210
|
+
if (!entry) return null;
|
|
211
|
+
if (entry.status === 'answered' || entry.status === 'cancelled') {
|
|
212
|
+
delete all[id];
|
|
213
|
+
saveAskStore(all);
|
|
214
|
+
}
|
|
215
|
+
return entry;
|
|
216
|
+
});
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Legacy unconditional consume (kept for backward compat with callers that
|
|
224
|
+
* truly want "read + delete regardless of status"). New short-poll GET handler
|
|
225
|
+
* uses consumeIfFinal instead — see server.js GET /api/ask-hook/:id/result.
|
|
226
|
+
*/
|
|
227
|
+
export function consume(id) {
|
|
228
|
+
if (!id || typeof id !== 'string') return null;
|
|
229
|
+
try {
|
|
230
|
+
return withLock(() => {
|
|
231
|
+
const all = loadAskStore();
|
|
232
|
+
const entry = all[id];
|
|
233
|
+
if (!entry) return null;
|
|
234
|
+
delete all[id];
|
|
235
|
+
saveAskStore(all);
|
|
236
|
+
return entry;
|
|
237
|
+
});
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Atomic upsert: load → mutate → save under file lock.
|
|
245
|
+
* fields 必须含 questions: [];其余字段(status、createdAt)由本函数兜底。
|
|
246
|
+
*
|
|
247
|
+
* Status guard: 若 disk 已 answered/cancelled(markAnswered/markCancelled 落过),
|
|
248
|
+
* setEntry 不能把 status 倒回 pending —— 否则 setImmediate 排队的延迟 _persistAskEntry
|
|
249
|
+
* 或重 POST 的 placeholder 会覆盖真实终态,导致 ask-bridge 短轮询永远拿不到答案。
|
|
250
|
+
*/
|
|
251
|
+
export function setEntry(id, fields) {
|
|
252
|
+
if (!id || typeof id !== 'string') return;
|
|
253
|
+
if (!fields || !Array.isArray(fields.questions)) return;
|
|
254
|
+
try {
|
|
255
|
+
withLock(() => {
|
|
256
|
+
const all = loadAskStore();
|
|
257
|
+
const existing = all[id];
|
|
258
|
+
if (existing && (existing.status === 'answered' || existing.status === 'cancelled')) {
|
|
259
|
+
return; // 已是终态,setEntry 视作 noop(幂等)
|
|
260
|
+
}
|
|
261
|
+
all[id] = {
|
|
262
|
+
id,
|
|
263
|
+
questions: fields.questions,
|
|
264
|
+
createdAt: Number(fields.createdAt) || Date.now(),
|
|
265
|
+
status: 'pending',
|
|
266
|
+
};
|
|
267
|
+
saveAskStore(all);
|
|
268
|
+
});
|
|
269
|
+
} catch {
|
|
270
|
+
// best-effort:磁盘失败不影响内存 Map 主流程
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function deleteEntry(id) {
|
|
275
|
+
if (!id || typeof id !== 'string') return;
|
|
276
|
+
try {
|
|
277
|
+
withLock(() => {
|
|
278
|
+
const all = loadAskStore();
|
|
279
|
+
if (!(id in all)) return;
|
|
280
|
+
delete all[id];
|
|
281
|
+
saveAskStore(all);
|
|
282
|
+
});
|
|
283
|
+
} catch {}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Replace the entire store atomically. Used on server startup to drop entries
|
|
288
|
+
* older than maxAgeMs (24h-class staleness sweep) without N round-trips.
|
|
289
|
+
*/
|
|
290
|
+
export function replaceAll(entries) {
|
|
291
|
+
try {
|
|
292
|
+
withLock(() => saveAskStore(entries));
|
|
293
|
+
} catch {}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Clean up entries older than maxAgeMs. Returns the surviving entries.
|
|
298
|
+
* Called at server startup to drop truly stale entries (>24h, equivalent to old HOOK_TIMEOUT).
|
|
299
|
+
*
|
|
300
|
+
* Stale 判定用 max(createdAt, answeredAt):刚 answered 的老 entry(createdAt 旧但
|
|
301
|
+
* answeredAt 新)必须保留,否则 ask-bridge 短轮询拿不到答案。
|
|
302
|
+
*/
|
|
303
|
+
export function pruneStale(maxAgeMs = 24 * 60 * 60 * 1000) {
|
|
304
|
+
try {
|
|
305
|
+
return withLock(() => {
|
|
306
|
+
const all = loadAskStore();
|
|
307
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
308
|
+
const survivors = {};
|
|
309
|
+
for (const [id, entry] of Object.entries(all)) {
|
|
310
|
+
const lastTouched = Math.max(Number(entry.createdAt) || 0, Number(entry.answeredAt) || 0);
|
|
311
|
+
if (lastTouched >= cutoff) survivors[id] = entry;
|
|
312
|
+
}
|
|
313
|
+
saveAskStore(survivors);
|
|
314
|
+
return survivors;
|
|
315
|
+
});
|
|
316
|
+
} catch {
|
|
317
|
+
return {};
|
|
318
|
+
}
|
|
319
|
+
}
|
package/lib/ensure-hooks.js
CHANGED
|
@@ -18,6 +18,46 @@ const rootDir = resolve(__dirname, '..');
|
|
|
18
18
|
// "npm uninstall leaves zombie paths" footgun — README documents the cleanup recipe.
|
|
19
19
|
const CCV_HOOK_MARKER = '# cc-viewer-managed';
|
|
20
20
|
|
|
21
|
+
// Claude Code 默认 PreToolUse hook 10min (TOOL_HOOK_EXECUTION_TIMEOUT_MS = 600_000)
|
|
22
|
+
// 强制 abort → ask-bridge 被 SIGTERM → 主进程走 canUseTool → TUI 接管 AskUserQuestion
|
|
23
|
+
// → GUI 端答案失效。Claude Code 单 hook 的 timeout (秒) 优先级最高,本地写 24h 等同无超时。
|
|
24
|
+
// 紧急回退:CCV_HOOK_TIMEOUT_S=0 不写 timeout 字段,恢复 10min 默认行为。
|
|
25
|
+
const HOOK_TIMEOUT_DEFAULT_S = 86400;
|
|
26
|
+
// 7 天硬上限:大值经过 hook.timeout * 1000 后超 Node setTimeout 2^31ms 会立即触发
|
|
27
|
+
// → 反而失效。整数 guard 防 0.5 → 500ms 这种半秒超时的反直觉失败。
|
|
28
|
+
const HOOK_TIMEOUT_MAX_S = 7 * 86400;
|
|
29
|
+
export const HOOK_TIMEOUT_S = (() => {
|
|
30
|
+
const raw = process.env.CCV_HOOK_TIMEOUT_S;
|
|
31
|
+
if (raw === undefined || raw === '') return HOOK_TIMEOUT_DEFAULT_S;
|
|
32
|
+
const n = Number(raw);
|
|
33
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) return HOOK_TIMEOUT_DEFAULT_S;
|
|
34
|
+
return Math.min(n, HOOK_TIMEOUT_MAX_S);
|
|
35
|
+
})();
|
|
36
|
+
const HOOK_TIMEOUT_FIELD = HOOK_TIMEOUT_S > 0 ? { timeout: HOOK_TIMEOUT_S } : {};
|
|
37
|
+
|
|
38
|
+
// 构造与对比两件事必须同源,否则升级路径会漏字段。
|
|
39
|
+
// merge 而非 replace:用户/第三方给同一 hook 追加 if/shell/once/async/asyncRewake 等
|
|
40
|
+
// schema 合法字段时,rewrite 不能整对象覆盖把它们吞掉。
|
|
41
|
+
export function _buildHookObj(command) {
|
|
42
|
+
return { type: 'command', command, ...HOOK_TIMEOUT_FIELD };
|
|
43
|
+
}
|
|
44
|
+
export function _hookObjEqual(existing, desired) {
|
|
45
|
+
if (!existing) return false;
|
|
46
|
+
if (existing.type !== desired.type) return false;
|
|
47
|
+
if (existing.command !== desired.command) return false;
|
|
48
|
+
// timeout 字段:未声明 = 视为 0;HOOK_TIMEOUT_S=0 时 desired 也无字段 → 都视为 0
|
|
49
|
+
const a = Number(existing.timeout) || 0;
|
|
50
|
+
const b = Number(desired.timeout) || 0;
|
|
51
|
+
return a === b;
|
|
52
|
+
}
|
|
53
|
+
function _mergeHookObj(existing, desired) {
|
|
54
|
+
// 保留 existing 中的非冲突字段(if/shell/once/...),desired 字段优先;
|
|
55
|
+
// desired 不含 timeout 时(CCV_HOOK_TIMEOUT_S=0)必须显式 delete existing.timeout 让它消失。
|
|
56
|
+
const merged = { ...(existing || {}), ...desired };
|
|
57
|
+
if (!('timeout' in desired)) delete merged.timeout;
|
|
58
|
+
return merged;
|
|
59
|
+
}
|
|
60
|
+
|
|
21
61
|
export function ensureHooks() {
|
|
22
62
|
try {
|
|
23
63
|
const claudeDir = getClaudeConfigDir();
|
|
@@ -38,16 +78,17 @@ export function ensureHooks() {
|
|
|
38
78
|
// Guard: only execute when CCVIEWER_PORT is set (i.e. launched by cc-viewer)
|
|
39
79
|
const askBridgePath = resolve(rootDir, 'lib', 'ask-bridge.js');
|
|
40
80
|
const askCmd = `[ -n "$CCVIEWER_PORT" ] && node "${askBridgePath}" || true ${CCV_HOOK_MARKER}`;
|
|
81
|
+
const askDesired = _buildHookObj(askCmd);
|
|
41
82
|
const askExisting = settings.hooks.PreToolUse.find(h => h.matcher === 'AskUserQuestion');
|
|
42
83
|
if (askExisting) {
|
|
43
|
-
if ((askExisting.hooks?.[0]
|
|
44
|
-
askExisting.hooks = [
|
|
84
|
+
if (!_hookObjEqual(askExisting.hooks?.[0], askDesired)) {
|
|
85
|
+
askExisting.hooks = [_mergeHookObj(askExisting.hooks?.[0], askDesired)];
|
|
45
86
|
changed = true;
|
|
46
87
|
}
|
|
47
88
|
} else {
|
|
48
89
|
settings.hooks.PreToolUse.push({
|
|
49
90
|
matcher: 'AskUserQuestion',
|
|
50
|
-
hooks: [
|
|
91
|
+
hooks: [askDesired]
|
|
51
92
|
});
|
|
52
93
|
changed = true;
|
|
53
94
|
}
|
|
@@ -72,16 +113,17 @@ export function ensureHooks() {
|
|
|
72
113
|
changed = true;
|
|
73
114
|
}
|
|
74
115
|
}
|
|
116
|
+
const permDesired = _buildHookObj(permCmd);
|
|
75
117
|
const permExisting = settings.hooks.PreToolUse.find(h => h.matcher === permMatcher);
|
|
76
118
|
if (permExisting) {
|
|
77
|
-
if ((permExisting.hooks?.[0]
|
|
78
|
-
permExisting.hooks = [
|
|
119
|
+
if (!_hookObjEqual(permExisting.hooks?.[0], permDesired)) {
|
|
120
|
+
permExisting.hooks = [_mergeHookObj(permExisting.hooks?.[0], permDesired)];
|
|
79
121
|
changed = true;
|
|
80
122
|
}
|
|
81
123
|
} else {
|
|
82
124
|
settings.hooks.PreToolUse.push({
|
|
83
125
|
matcher: permMatcher,
|
|
84
|
-
hooks: [
|
|
126
|
+
hooks: [permDesired]
|
|
85
127
|
});
|
|
86
128
|
changed = true;
|
|
87
129
|
}
|
|
@@ -94,18 +136,19 @@ export function ensureHooks() {
|
|
|
94
136
|
const turnEndCmd = `[ -n "$CCVIEWER_PORT" ] && node "${turnEndBridgePath}" || true ${CCV_HOOK_MARKER}`;
|
|
95
137
|
// Stop hooks use matcher: '' (or unset) since there's no tool name to scope by.
|
|
96
138
|
// Find any existing entry that already points at our bridge to update-in-place.
|
|
139
|
+
const turnEndDesired = _buildHookObj(turnEndCmd);
|
|
97
140
|
const turnEndExisting = settings.hooks.Stop.find(h => {
|
|
98
141
|
const cmd = h.hooks?.[0]?.command || '';
|
|
99
142
|
return cmd.includes('turn-end-bridge.js');
|
|
100
143
|
});
|
|
101
144
|
if (turnEndExisting) {
|
|
102
|
-
if ((turnEndExisting.hooks?.[0]
|
|
103
|
-
turnEndExisting.hooks = [
|
|
145
|
+
if (!_hookObjEqual(turnEndExisting.hooks?.[0], turnEndDesired)) {
|
|
146
|
+
turnEndExisting.hooks = [_mergeHookObj(turnEndExisting.hooks?.[0], turnEndDesired)];
|
|
104
147
|
changed = true;
|
|
105
148
|
}
|
|
106
149
|
} else {
|
|
107
150
|
settings.hooks.Stop.push({
|
|
108
|
-
hooks: [
|
|
151
|
+
hooks: [turnEndDesired],
|
|
109
152
|
});
|
|
110
153
|
changed = true;
|
|
111
154
|
}
|
|
@@ -121,6 +164,8 @@ export function ensureHooks() {
|
|
|
121
164
|
try {
|
|
122
165
|
writeFileSync(tmpPath, JSON.stringify(settings, null, 2));
|
|
123
166
|
renameSync(tmpPath, settingsPath);
|
|
167
|
+
// 透明声明:修改用户全局 settings.json 是高风险操作,启动日志可见让用户能审计
|
|
168
|
+
console.log(`[cc-viewer] updated ${settingsPath} (hook timeout=${HOOK_TIMEOUT_S}s)`);
|
|
124
169
|
} catch (err) {
|
|
125
170
|
try { if (existsSync(tmpPath)) unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
126
171
|
throw err;
|
package/lib/sdk-manager.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { sdkToJSONLEntry, buildStreamingStatus } from './sdk-adapter.js';
|
|
12
|
+
import { ASK_TIMEOUT_MS } from './ask-constants.js';
|
|
12
13
|
|
|
13
14
|
let _query;
|
|
14
15
|
try {
|
|
@@ -395,7 +396,9 @@ async function _handleCanUseTool(toolName, input, options) {
|
|
|
395
396
|
}
|
|
396
397
|
} catch {}
|
|
397
398
|
}
|
|
398
|
-
|
|
399
|
+
// 24h — 与 hook 路径(server.js ASK_HOOK_TIMEOUT_MS)同源,履行"GUI 实质无超时"承诺。
|
|
400
|
+
// 实际常量定义在 lib/ask-constants.js。
|
|
401
|
+
const askTimeoutMs = ASK_TIMEOUT_MS;
|
|
399
402
|
const askStartedAt = Date.now();
|
|
400
403
|
if (_broadcastWs) {
|
|
401
404
|
_broadcastWs({ type: 'sdk-ask-pending', id, questions: input.questions, startedAt: askStartedAt, timeoutMs: askTimeoutMs });
|