sessix-server 0.1.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/dist/approval/ApprovalProxy.d.ts +86 -0
- package/dist/approval/ApprovalProxy.d.ts.map +1 -0
- package/dist/approval/ApprovalProxy.js +363 -0
- package/dist/approval/ApprovalProxy.js.map +1 -0
- package/dist/hooks/HookInstaller.d.ts +55 -0
- package/dist/hooks/HookInstaller.d.ts.map +1 -0
- package/dist/hooks/HookInstaller.js +215 -0
- package/dist/hooks/HookInstaller.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3115 -0
- package/dist/index.js.map +1 -0
- package/dist/mdns/MdnsService.d.ts +36 -0
- package/dist/mdns/MdnsService.d.ts.map +1 -0
- package/dist/mdns/MdnsService.js +66 -0
- package/dist/mdns/MdnsService.js.map +1 -0
- package/dist/notification/ActivityPushChannel.d.ts +54 -0
- package/dist/notification/ActivityPushChannel.d.ts.map +1 -0
- package/dist/notification/ActivityPushChannel.js +235 -0
- package/dist/notification/ActivityPushChannel.js.map +1 -0
- package/dist/notification/ExpoNotificationChannel.d.ts +17 -0
- package/dist/notification/ExpoNotificationChannel.d.ts.map +1 -0
- package/dist/notification/ExpoNotificationChannel.js +57 -0
- package/dist/notification/ExpoNotificationChannel.js.map +1 -0
- package/dist/notification/MacNotificationChannel.d.ts +22 -0
- package/dist/notification/MacNotificationChannel.d.ts.map +1 -0
- package/dist/notification/MacNotificationChannel.js +33 -0
- package/dist/notification/MacNotificationChannel.js.map +1 -0
- package/dist/notification/NotificationService.d.ts +50 -0
- package/dist/notification/NotificationService.d.ts.map +1 -0
- package/dist/notification/NotificationService.js +177 -0
- package/dist/notification/NotificationService.js.map +1 -0
- package/dist/providers/ExecutionProvider.d.ts +60 -0
- package/dist/providers/ExecutionProvider.d.ts.map +1 -0
- package/dist/providers/ExecutionProvider.js +3 -0
- package/dist/providers/ExecutionProvider.js.map +1 -0
- package/dist/providers/ProcessProvider.d.ts +117 -0
- package/dist/providers/ProcessProvider.d.ts.map +1 -0
- package/dist/providers/ProcessProvider.js +507 -0
- package/dist/providers/ProcessProvider.js.map +1 -0
- package/dist/server.d.ts +32 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +3054 -0
- package/dist/server.js.map +1 -0
- package/dist/session/ProjectReader.d.ts +44 -0
- package/dist/session/ProjectReader.d.ts.map +1 -0
- package/dist/session/ProjectReader.js +471 -0
- package/dist/session/ProjectReader.js.map +1 -0
- package/dist/session/SessionFileWatcher.d.ts +35 -0
- package/dist/session/SessionFileWatcher.d.ts.map +1 -0
- package/dist/session/SessionFileWatcher.js +207 -0
- package/dist/session/SessionFileWatcher.js.map +1 -0
- package/dist/session/SessionManager.d.ts +114 -0
- package/dist/session/SessionManager.d.ts.map +1 -0
- package/dist/session/SessionManager.js +356 -0
- package/dist/session/SessionManager.js.map +1 -0
- package/dist/ws/WsBridge.d.ts +55 -0
- package/dist/ws/WsBridge.d.ts.map +1 -0
- package/dist/ws/WsBridge.js +220 -0
- package/dist/ws/WsBridge.js.map +1 -0
- package/package.json +38 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,3054 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/server.ts
|
|
31
|
+
var server_exports = {};
|
|
32
|
+
__export(server_exports, {
|
|
33
|
+
start: () => start
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(server_exports);
|
|
36
|
+
var import_uuid4 = require("uuid");
|
|
37
|
+
var import_promises4 = require("fs/promises");
|
|
38
|
+
var import_node_os4 = require("os");
|
|
39
|
+
var import_node_path4 = require("path");
|
|
40
|
+
var import_node_child_process2 = require("child_process");
|
|
41
|
+
var import_node_util = require("util");
|
|
42
|
+
|
|
43
|
+
// src/providers/ProcessProvider.ts
|
|
44
|
+
var import_child_process = require("child_process");
|
|
45
|
+
var import_readline = require("readline");
|
|
46
|
+
var import_events = require("events");
|
|
47
|
+
var import_node_os = require("os");
|
|
48
|
+
var import_uuid = require("uuid");
|
|
49
|
+
function findClaudePath() {
|
|
50
|
+
try {
|
|
51
|
+
return (0, import_child_process.execSync)("which claude", { encoding: "utf-8" }).trim();
|
|
52
|
+
} catch {
|
|
53
|
+
const candidates = [
|
|
54
|
+
`${process.env.HOME}/.local/bin/claude`,
|
|
55
|
+
"/usr/local/bin/claude",
|
|
56
|
+
"/opt/homebrew/bin/claude"
|
|
57
|
+
];
|
|
58
|
+
for (const candidate of candidates) {
|
|
59
|
+
try {
|
|
60
|
+
(0, import_child_process.execSync)(`test -x "${candidate}"`);
|
|
61
|
+
return candidate;
|
|
62
|
+
} catch {
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return "claude";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
var CLAUDE_PATH = findClaudePath();
|
|
69
|
+
var ProcessProvider = class {
|
|
70
|
+
/** 活跃会话映射表:sessionId -> { session, process } */
|
|
71
|
+
activeSessions = /* @__PURE__ */ new Map();
|
|
72
|
+
/** 事件发射器,用于分发 Claude 事件流 */
|
|
73
|
+
emitter = new import_events.EventEmitter();
|
|
74
|
+
/** 已发射的 AskUserQuestion toolUseId 集合(避免 partial message 重复触发) */
|
|
75
|
+
emittedQuestionToolUseIds = /* @__PURE__ */ new Set();
|
|
76
|
+
/**
|
|
77
|
+
* 启动新会话或恢复已有会话
|
|
78
|
+
*
|
|
79
|
+
* 会 spawn 一个 `claude` CLI 进程,设置工作目录和环境变量,
|
|
80
|
+
* 并开始监听 stdout 的 NDJSON 输出。
|
|
81
|
+
*/
|
|
82
|
+
async startSession(opts) {
|
|
83
|
+
const { projectPath, message, sessionId: existingSessionId, model, permissionMode, effort, images } = opts;
|
|
84
|
+
const sessionId = existingSessionId ?? (0, import_uuid.v4)();
|
|
85
|
+
if (this.activeSessions.has(sessionId)) {
|
|
86
|
+
await this.killSession(sessionId);
|
|
87
|
+
}
|
|
88
|
+
const projectId = projectPath.split("/").filter(Boolean).pop() ?? "unknown";
|
|
89
|
+
const session = {
|
|
90
|
+
id: sessionId,
|
|
91
|
+
projectId,
|
|
92
|
+
projectPath,
|
|
93
|
+
status: "running",
|
|
94
|
+
createdAt: Date.now(),
|
|
95
|
+
lastActiveAt: Date.now(),
|
|
96
|
+
summary: message.slice(0, 80)
|
|
97
|
+
};
|
|
98
|
+
const resume = opts.resume ?? !!existingSessionId;
|
|
99
|
+
const proc = this.spawnClaudeProcess(sessionId, projectPath, resume, model, permissionMode, effort);
|
|
100
|
+
this.writeUserMessage(proc, message, sessionId, images);
|
|
101
|
+
session.pid = proc.pid;
|
|
102
|
+
this.activeSessions.set(sessionId, { session, process: proc, model, permissionMode, effort });
|
|
103
|
+
proc.on("error", (err) => {
|
|
104
|
+
console.error(`[ProcessProvider] \u4F1A\u8BDD ${sessionId} \u8FDB\u7A0B\u9519\u8BEF:`, err.message);
|
|
105
|
+
this.activeSessions.delete(sessionId);
|
|
106
|
+
const syntheticResult = {
|
|
107
|
+
type: "result",
|
|
108
|
+
subtype: "error",
|
|
109
|
+
result: `\u8FDB\u7A0B\u542F\u52A8\u5931\u8D25: ${err.message}`,
|
|
110
|
+
session_id: sessionId,
|
|
111
|
+
duration_ms: 0,
|
|
112
|
+
is_error: true,
|
|
113
|
+
num_turns: 0
|
|
114
|
+
};
|
|
115
|
+
this.emitter.emit(this.getEventName(sessionId), syntheticResult);
|
|
116
|
+
});
|
|
117
|
+
this.attachStdoutListener(sessionId, proc);
|
|
118
|
+
this.attachStderrListener(sessionId, proc);
|
|
119
|
+
this.attachExitListener(sessionId, proc);
|
|
120
|
+
return session;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* 终止指定会话
|
|
124
|
+
*
|
|
125
|
+
* kill 进程并从活跃映射中移除。
|
|
126
|
+
*/
|
|
127
|
+
async killSession(sessionId) {
|
|
128
|
+
const entry = this.activeSessions.get(sessionId);
|
|
129
|
+
if (!entry) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (entry.process.exitCode === null && entry.process.signalCode === null) {
|
|
133
|
+
try {
|
|
134
|
+
entry.process.stdin?.end();
|
|
135
|
+
} catch {
|
|
136
|
+
}
|
|
137
|
+
entry.process.kill("SIGTERM");
|
|
138
|
+
await new Promise((resolve) => {
|
|
139
|
+
const timeout = setTimeout(() => {
|
|
140
|
+
if (entry.process.exitCode === null && entry.process.signalCode === null) {
|
|
141
|
+
entry.process.kill("SIGKILL");
|
|
142
|
+
}
|
|
143
|
+
resolve();
|
|
144
|
+
}, 3e3);
|
|
145
|
+
entry.process.once("exit", () => {
|
|
146
|
+
clearTimeout(timeout);
|
|
147
|
+
resolve();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
this.activeSessions.delete(sessionId);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* 向已有会话发送新消息
|
|
155
|
+
*
|
|
156
|
+
* 快速路径:进程存活时直接写 stdin(毫秒级响应)。
|
|
157
|
+
* 慢速路径:进程已退出时 respawn 并 --resume。
|
|
158
|
+
*/
|
|
159
|
+
async sendMessage(sessionId, message, permissionMode, images) {
|
|
160
|
+
const entry = this.activeSessions.get(sessionId);
|
|
161
|
+
if (!entry) {
|
|
162
|
+
throw new Error(`\u4F1A\u8BDD ${sessionId} \u4E0D\u5B58\u5728\u6216\u5DF2\u7ED3\u675F`);
|
|
163
|
+
}
|
|
164
|
+
const modeChanged = permissionMode != null && permissionMode !== (entry.permissionMode ?? "default");
|
|
165
|
+
if (!modeChanged && entry.process.exitCode === null && entry.process.signalCode === null && !entry.process.stdin?.destroyed) {
|
|
166
|
+
this.writeUserMessage(entry.process, message, sessionId, images);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (modeChanged) {
|
|
170
|
+
console.log(`[ProcessProvider] \u4F1A\u8BDD ${sessionId}: \u6743\u9650\u6A21\u5F0F\u5207\u6362 ${entry.permissionMode ?? "default"} \u2192 ${permissionMode}\uFF0Crespawn`);
|
|
171
|
+
if (entry.process.exitCode === null && entry.process.signalCode === null) {
|
|
172
|
+
try {
|
|
173
|
+
entry.process.stdin?.end();
|
|
174
|
+
} catch {
|
|
175
|
+
}
|
|
176
|
+
entry.process.kill("SIGTERM");
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
console.log(`[ProcessProvider] \u4F1A\u8BDD ${sessionId}: \u8FDB\u7A0B\u5DF2\u9000\u51FA\uFF0Crespawn \u91CD\u65B0\u542F\u52A8`);
|
|
180
|
+
}
|
|
181
|
+
const savedPendingQuestion = entry.pendingQuestion;
|
|
182
|
+
const newMode = permissionMode ?? entry.permissionMode;
|
|
183
|
+
const proc = this.spawnClaudeProcess(sessionId, entry.session.projectPath, true, entry.model, newMode, entry.effort);
|
|
184
|
+
this.writeUserMessage(proc, message, sessionId, images);
|
|
185
|
+
entry.session.status = "running";
|
|
186
|
+
entry.session.lastActiveAt = Date.now();
|
|
187
|
+
entry.session.pid = proc.pid;
|
|
188
|
+
entry.process = proc;
|
|
189
|
+
entry.permissionMode = newMode;
|
|
190
|
+
entry.pendingQuestion = savedPendingQuestion;
|
|
191
|
+
proc.on("error", (err) => {
|
|
192
|
+
console.error(`[ProcessProvider] \u4F1A\u8BDD ${sessionId} sendMessage \u8FDB\u7A0B\u9519\u8BEF:`, err.message);
|
|
193
|
+
this.activeSessions.delete(sessionId);
|
|
194
|
+
const syntheticResult = {
|
|
195
|
+
type: "result",
|
|
196
|
+
subtype: "error",
|
|
197
|
+
result: `\u6D88\u606F\u53D1\u9001\u5931\u8D25: ${err.message}`,
|
|
198
|
+
session_id: sessionId,
|
|
199
|
+
duration_ms: 0,
|
|
200
|
+
is_error: true,
|
|
201
|
+
num_turns: 0
|
|
202
|
+
};
|
|
203
|
+
this.emitter.emit(this.getEventName(sessionId), syntheticResult);
|
|
204
|
+
});
|
|
205
|
+
this.attachStdoutListener(sessionId, proc);
|
|
206
|
+
this.attachStderrListener(sessionId, proc);
|
|
207
|
+
this.attachExitListener(sessionId, proc);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* 订阅指定会话的 Claude 事件流
|
|
211
|
+
*
|
|
212
|
+
* @returns 取消订阅函数
|
|
213
|
+
*/
|
|
214
|
+
onEvent(sessionId, callback) {
|
|
215
|
+
const eventName = this.getEventName(sessionId);
|
|
216
|
+
this.emitter.on(eventName, callback);
|
|
217
|
+
return () => {
|
|
218
|
+
this.emitter.off(eventName, callback);
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* 获取当前所有活跃会话列表
|
|
223
|
+
*/
|
|
224
|
+
getActiveSessions() {
|
|
225
|
+
return Array.from(this.activeSessions.values()).map((entry) => entry.session);
|
|
226
|
+
}
|
|
227
|
+
// ============================================
|
|
228
|
+
// 私有方法
|
|
229
|
+
// ============================================
|
|
230
|
+
/**
|
|
231
|
+
* 启动 claude CLI 进程(持久模式,stdin 保持开放接收多条消息)
|
|
232
|
+
*/
|
|
233
|
+
spawnClaudeProcess(sessionId, projectPath, resume = false, model, permissionMode, effort) {
|
|
234
|
+
const args = [
|
|
235
|
+
"--input-format",
|
|
236
|
+
"stream-json",
|
|
237
|
+
"--output-format",
|
|
238
|
+
"stream-json",
|
|
239
|
+
"--verbose",
|
|
240
|
+
"--include-partial-messages"
|
|
241
|
+
];
|
|
242
|
+
if (resume) {
|
|
243
|
+
args.push("--resume", sessionId);
|
|
244
|
+
} else {
|
|
245
|
+
args.push("--session-id", sessionId);
|
|
246
|
+
}
|
|
247
|
+
if (model) {
|
|
248
|
+
args.push("--model", model);
|
|
249
|
+
}
|
|
250
|
+
if (permissionMode && permissionMode !== "default") {
|
|
251
|
+
args.push("--permission-mode", permissionMode);
|
|
252
|
+
}
|
|
253
|
+
if (effort) {
|
|
254
|
+
args.push("--effort", effort);
|
|
255
|
+
}
|
|
256
|
+
const env = { ...process.env, SESSIX_SESSION_ID: sessionId };
|
|
257
|
+
delete env.CLAUDECODE;
|
|
258
|
+
const proc = (0, import_child_process.spawn)(CLAUDE_PATH, args, {
|
|
259
|
+
cwd: projectPath,
|
|
260
|
+
env,
|
|
261
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
262
|
+
});
|
|
263
|
+
return proc;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* 向持久进程的 stdin 写入一条用户消息(NDJSON 格式)
|
|
267
|
+
*
|
|
268
|
+
* 写入失败时合成 error result 事件,确保 SessionManager 能感知到失败。
|
|
269
|
+
*/
|
|
270
|
+
writeUserMessage(proc, message, sessionId, images) {
|
|
271
|
+
const content = [];
|
|
272
|
+
if (images?.length) {
|
|
273
|
+
for (const img of images) {
|
|
274
|
+
content.push({
|
|
275
|
+
type: "image",
|
|
276
|
+
source: { type: "base64", media_type: img.media_type, data: img.data }
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
content.push({ type: "text", text: message });
|
|
281
|
+
const payload = JSON.stringify({
|
|
282
|
+
type: "user",
|
|
283
|
+
session_id: "",
|
|
284
|
+
message: { role: "user", content },
|
|
285
|
+
parent_tool_use_id: null
|
|
286
|
+
});
|
|
287
|
+
if (!proc.stdin || proc.stdin.destroyed) {
|
|
288
|
+
console.error(`[ProcessProvider] stdin \u4E0D\u53EF\u7528\uFF0C\u6D88\u606F\u4E22\u5931`);
|
|
289
|
+
if (sessionId) {
|
|
290
|
+
this.emitWriteError(sessionId, "\u8FDB\u7A0B stdin \u5DF2\u5173\u95ED\uFF0C\u6D88\u606F\u672A\u9001\u8FBE");
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
proc.stdin.write(payload + "\n", (err) => {
|
|
295
|
+
if (err && sessionId) {
|
|
296
|
+
console.error(`[ProcessProvider] \u4F1A\u8BDD ${sessionId} stdin \u5199\u5165\u5931\u8D25:`, err.message);
|
|
297
|
+
this.emitWriteError(sessionId, `\u6D88\u606F\u53D1\u9001\u5931\u8D25: ${err.message}`);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* 发出写入失败的合成错误事件
|
|
303
|
+
*/
|
|
304
|
+
emitWriteError(sessionId, message) {
|
|
305
|
+
const syntheticResult = {
|
|
306
|
+
type: "result",
|
|
307
|
+
subtype: "error",
|
|
308
|
+
result: message,
|
|
309
|
+
session_id: sessionId,
|
|
310
|
+
duration_ms: 0,
|
|
311
|
+
is_error: true,
|
|
312
|
+
num_turns: 0
|
|
313
|
+
};
|
|
314
|
+
this.emitter.emit(this.getEventName(sessionId), syntheticResult);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* 挂载 stdout 监听器,逐行解析 NDJSON
|
|
318
|
+
*/
|
|
319
|
+
attachStdoutListener(sessionId, proc) {
|
|
320
|
+
if (!proc.stdout) {
|
|
321
|
+
console.warn(`[ProcessProvider] \u4F1A\u8BDD ${sessionId}: stdout \u4E0D\u53EF\u7528`);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const rl = (0, import_readline.createInterface)({
|
|
325
|
+
input: proc.stdout,
|
|
326
|
+
crlfDelay: Infinity
|
|
327
|
+
});
|
|
328
|
+
const entry = this.activeSessions.get(sessionId);
|
|
329
|
+
if (entry) {
|
|
330
|
+
entry.rl = rl;
|
|
331
|
+
}
|
|
332
|
+
rl.on("line", (line) => {
|
|
333
|
+
const trimmed = line.trim();
|
|
334
|
+
if (!trimmed) return;
|
|
335
|
+
const result = this.parseLine(trimmed);
|
|
336
|
+
if (result.ok) {
|
|
337
|
+
const event = result.value;
|
|
338
|
+
if (event.type === "assistant") {
|
|
339
|
+
for (const block of event.message.content) {
|
|
340
|
+
if (block.type === "tool_use" && block.name === "AskUserQuestion") {
|
|
341
|
+
const input = block.input;
|
|
342
|
+
const question = input.question ?? "";
|
|
343
|
+
if (!question) continue;
|
|
344
|
+
const prevKey = `${block.id}:${question}:${JSON.stringify(input.options ?? [])}`;
|
|
345
|
+
if (this.emittedQuestionToolUseIds.has(prevKey)) continue;
|
|
346
|
+
this.emittedQuestionToolUseIds.add(prevKey);
|
|
347
|
+
this.emitter.emit(this.getQuestionEventName(sessionId), {
|
|
348
|
+
toolUseId: block.id,
|
|
349
|
+
question,
|
|
350
|
+
options: input.options
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
this.updateSessionStatus(sessionId, event);
|
|
356
|
+
this.emitter.emit(this.getEventName(sessionId), event);
|
|
357
|
+
} else {
|
|
358
|
+
console.warn(
|
|
359
|
+
`[ProcessProvider] \u4F1A\u8BDD ${sessionId}: \u65E0\u6CD5\u89E3\u6790\u884C: ${trimmed.substring(0, 100)}`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* 挂载 stderr 监听器,记录日志
|
|
366
|
+
*/
|
|
367
|
+
attachStderrListener(sessionId, proc) {
|
|
368
|
+
if (!proc.stderr) return;
|
|
369
|
+
proc.stderr.on("data", (data) => {
|
|
370
|
+
const text = data.toString().trim();
|
|
371
|
+
if (text) {
|
|
372
|
+
console.error(`[ProcessProvider] \u4F1A\u8BDD ${sessionId} stderr: ${text}`);
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* 挂载进程退出监听器
|
|
378
|
+
*
|
|
379
|
+
* 当进程退出时发出合成的 result 事件,确保 SessionManager 能感知到退出。
|
|
380
|
+
* 正常退出时 Claude 会先通过 stdout 发送真实 result 事件,
|
|
381
|
+
* updateSessionStatus 会将 session.status 更新为 idle/error。
|
|
382
|
+
* 此时合成事件会重复触发,导致手机端出现两张总结卡。
|
|
383
|
+
* 修复:已收到真实 result(status 已为 idle/error)时跳过合成事件。
|
|
384
|
+
* 异常退出时(crash/OOM/killed)没有真实 result 事件,合成事件确保状态正确广播。
|
|
385
|
+
*/
|
|
386
|
+
attachExitListener(sessionId, proc) {
|
|
387
|
+
proc.once("exit", (code, signal) => {
|
|
388
|
+
const entry = this.activeSessions.get(sessionId);
|
|
389
|
+
if (!entry) return;
|
|
390
|
+
if (entry.process !== proc) return;
|
|
391
|
+
if (entry.rl) {
|
|
392
|
+
entry.rl.close();
|
|
393
|
+
entry.rl = void 0;
|
|
394
|
+
}
|
|
395
|
+
entry.session.pid = void 0;
|
|
396
|
+
entry.session.lastActiveAt = Date.now();
|
|
397
|
+
const alreadyHasResult = entry.session.status === "idle" || entry.session.status === "error";
|
|
398
|
+
if (alreadyHasResult) return;
|
|
399
|
+
const isNormal = code === 0 || code === 143 || signal === "SIGTERM";
|
|
400
|
+
entry.session.status = isNormal ? "idle" : "error";
|
|
401
|
+
if (!isNormal) {
|
|
402
|
+
console.error(
|
|
403
|
+
`[ProcessProvider] \u4F1A\u8BDD ${sessionId}: \u8FDB\u7A0B\u5F02\u5E38\u9000\u51FA code=${code} signal=${signal}`
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
const syntheticResult = {
|
|
407
|
+
type: "result",
|
|
408
|
+
subtype: isNormal ? "success" : "error",
|
|
409
|
+
session_id: sessionId,
|
|
410
|
+
is_error: !isNormal,
|
|
411
|
+
result: isNormal ? "" : `\u8FDB\u7A0B\u9000\u51FA code=${code} signal=${signal}`,
|
|
412
|
+
duration_ms: 0,
|
|
413
|
+
num_turns: 0
|
|
414
|
+
};
|
|
415
|
+
this.emitter.emit(this.getEventName(sessionId), syntheticResult);
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* 解析一行 NDJSON 文本为 ClaudeStreamEvent
|
|
420
|
+
*/
|
|
421
|
+
parseLine(line) {
|
|
422
|
+
try {
|
|
423
|
+
const parsed = JSON.parse(line);
|
|
424
|
+
return { ok: true, value: parsed };
|
|
425
|
+
} catch (err) {
|
|
426
|
+
return {
|
|
427
|
+
ok: false,
|
|
428
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* 根据 Claude 事件更新会话状态
|
|
434
|
+
*/
|
|
435
|
+
updateSessionStatus(sessionId, event) {
|
|
436
|
+
const entry = this.activeSessions.get(sessionId);
|
|
437
|
+
if (!entry) return;
|
|
438
|
+
entry.session.lastActiveAt = Date.now();
|
|
439
|
+
switch (event.type) {
|
|
440
|
+
case "system":
|
|
441
|
+
if (event.subtype === "init") {
|
|
442
|
+
entry.session.status = "running";
|
|
443
|
+
}
|
|
444
|
+
break;
|
|
445
|
+
case "assistant":
|
|
446
|
+
entry.session.status = "running";
|
|
447
|
+
break;
|
|
448
|
+
case "result":
|
|
449
|
+
entry.session.status = event.is_error ? "error" : "idle";
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* 根据对话上下文生成下一步建议指令
|
|
455
|
+
*
|
|
456
|
+
* 使用 --output-format text 做一次性调用,返回纯文本结果。
|
|
457
|
+
*/
|
|
458
|
+
async generateSuggestion(context) {
|
|
459
|
+
const prompt = `\u4F60\u662F\u4E00\u4E2A AI \u7F16\u7A0B\u52A9\u624B\u3002\u6839\u636E\u4EE5\u4E0B Claude Code \u5BF9\u8BDD\u4E0A\u4E0B\u6587\uFF0C\u5EFA\u8BAE\u7528\u6237\u4E0B\u4E00\u6B65\u6700\u6709\u4EF7\u503C\u7684\u4E00\u6761\u6307\u4EE4\uFF08\u76F4\u63A5\u7ED9\u51FA\u6307\u4EE4\u5185\u5BB9\uFF0C\u4E0D\u8981\u89E3\u91CA\uFF0C\u4E0D\u8981\u52A0\u5F15\u53F7\uFF09\uFF1A
|
|
460
|
+
|
|
461
|
+
${context}`;
|
|
462
|
+
return new Promise((resolve, reject) => {
|
|
463
|
+
const env = { ...process.env };
|
|
464
|
+
delete env.CLAUDECODE;
|
|
465
|
+
const proc = (0, import_child_process.spawn)(CLAUDE_PATH, ["-p", prompt, "--output-format", "text"], {
|
|
466
|
+
cwd: (0, import_node_os.homedir)(),
|
|
467
|
+
env,
|
|
468
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
469
|
+
});
|
|
470
|
+
proc.stdin.end();
|
|
471
|
+
let output = "";
|
|
472
|
+
proc.stdout?.on("data", (data) => {
|
|
473
|
+
output += data.toString();
|
|
474
|
+
});
|
|
475
|
+
proc.once("exit", (code) => {
|
|
476
|
+
if (code === 0) {
|
|
477
|
+
resolve(output.trim());
|
|
478
|
+
} else {
|
|
479
|
+
reject(new Error(`generateSuggestion \u8FDB\u7A0B\u9000\u51FA\u7801: ${code}`));
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
proc.once("error", reject);
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* 向正在等待中的 AskUserQuestion 提供答案
|
|
487
|
+
*
|
|
488
|
+
* 将答案写入 Claude 进程的 stdin(作为 tool_result),
|
|
489
|
+
* Claude 收到后继续执行。
|
|
490
|
+
*/
|
|
491
|
+
async answerQuestion(sessionId, toolUseId, answer) {
|
|
492
|
+
const entry = this.activeSessions.get(sessionId);
|
|
493
|
+
if (!entry) {
|
|
494
|
+
throw new Error(`\u4F1A\u8BDD ${sessionId} \u4E0D\u5B58\u5728`);
|
|
495
|
+
}
|
|
496
|
+
if (!entry.process.stdin || entry.process.stdin.destroyed) {
|
|
497
|
+
throw new Error(`\u4F1A\u8BDD ${sessionId} stdin \u4E0D\u53EF\u7528`);
|
|
498
|
+
}
|
|
499
|
+
const toolResult = JSON.stringify({
|
|
500
|
+
type: "tool_result",
|
|
501
|
+
tool_use_id: toolUseId,
|
|
502
|
+
content: answer
|
|
503
|
+
});
|
|
504
|
+
await new Promise((resolve, reject) => {
|
|
505
|
+
entry.process.stdin.write(toolResult + "\n", (err) => {
|
|
506
|
+
if (err) reject(err);
|
|
507
|
+
else resolve();
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
console.log(`[ProcessProvider] \u4F1A\u8BDD ${sessionId}: AskUserQuestion \u5DF2\u56DE\u7B54 (toolUseId=${toolUseId})`);
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* 订阅指定会话的 AskUserQuestion 事件
|
|
514
|
+
*
|
|
515
|
+
* @returns 取消订阅函数
|
|
516
|
+
*/
|
|
517
|
+
onQuestion(sessionId, callback) {
|
|
518
|
+
const eventName = this.getQuestionEventName(sessionId);
|
|
519
|
+
this.emitter.on(eventName, callback);
|
|
520
|
+
return () => {
|
|
521
|
+
this.emitter.off(eventName, callback);
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* 生成事件名称
|
|
526
|
+
*/
|
|
527
|
+
getEventName(sessionId) {
|
|
528
|
+
return `claude:${sessionId}`;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* 生成 AskUserQuestion 内部事件名称
|
|
532
|
+
*/
|
|
533
|
+
getQuestionEventName(sessionId) {
|
|
534
|
+
return `question:${sessionId}`;
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
// src/session/SessionManager.ts
|
|
539
|
+
var import_uuid2 = require("uuid");
|
|
540
|
+
var BUFFER_MAX = 5e3;
|
|
541
|
+
var SessionManager = class {
|
|
542
|
+
provider;
|
|
543
|
+
/** 事件回调列表(事件会被转发到 WsBridge) */
|
|
544
|
+
eventCallbacks = [];
|
|
545
|
+
/** 每个会话的事件流取消订阅函数 */
|
|
546
|
+
unsubscribeMap = /* @__PURE__ */ new Map();
|
|
547
|
+
/** 每个会话的事件缓冲区(用于新订阅者重放)*/
|
|
548
|
+
sessionEventBuffers = /* @__PURE__ */ new Map();
|
|
549
|
+
/** AskUserQuestion 问题映射:requestId → resolve 回调 */
|
|
550
|
+
pendingQuestions = /* @__PURE__ */ new Map();
|
|
551
|
+
/**
|
|
552
|
+
* 会话状态缓存(用于追踪 status 变化,检测 oldStatus !== newStatus 时广播)
|
|
553
|
+
*
|
|
554
|
+
* 这是 status 变化的唯一检测源。ProcessProvider 的 session.status 是实际值,
|
|
555
|
+
* 这里只缓存上次广播的值,用于去重。
|
|
556
|
+
*/
|
|
557
|
+
lastBroadcastStatus = /* @__PURE__ */ new Map();
|
|
558
|
+
/** 每个会话的服务器端累计统计 */
|
|
559
|
+
sessionStats = /* @__PURE__ */ new Map();
|
|
560
|
+
/** 每个会话进入 running 状态的 wall-clock 起始时间 */
|
|
561
|
+
runningStartedAt = /* @__PURE__ */ new Map();
|
|
562
|
+
/** assistant 事件合并缓冲区(30ms 窗口内的 assistant 事件合并为一次发送) */
|
|
563
|
+
pendingAssistantEvents = /* @__PURE__ */ new Map();
|
|
564
|
+
constructor(provider) {
|
|
565
|
+
this.provider = provider;
|
|
566
|
+
}
|
|
567
|
+
// ============================================
|
|
568
|
+
// 公开 API
|
|
569
|
+
// ============================================
|
|
570
|
+
/**
|
|
571
|
+
* 创建新会话
|
|
572
|
+
*
|
|
573
|
+
* 调用 provider.startSession(),订阅事件流,
|
|
574
|
+
* 将 ClaudeStreamEvent 包装为 ServerEvent 转发。
|
|
575
|
+
*/
|
|
576
|
+
async createSession(projectPath, message, resumeSessionId, newSessionId, model, permissionMode, effort, images) {
|
|
577
|
+
const session = await this.provider.startSession({
|
|
578
|
+
projectPath,
|
|
579
|
+
message,
|
|
580
|
+
sessionId: resumeSessionId ?? newSessionId,
|
|
581
|
+
resume: !!resumeSessionId,
|
|
582
|
+
model,
|
|
583
|
+
permissionMode,
|
|
584
|
+
effort,
|
|
585
|
+
images
|
|
586
|
+
});
|
|
587
|
+
this.lastBroadcastStatus.set(session.id, session.status);
|
|
588
|
+
this.unsubscribeSession(session.id);
|
|
589
|
+
this.subscribeToSession(session.id);
|
|
590
|
+
console.log(`[SessionManager] \u4F1A\u8BDD\u5DF2\u521B\u5EFA: ${session.id} (\u9879\u76EE: ${projectPath})`);
|
|
591
|
+
return session;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* 发送消息到已有会话
|
|
595
|
+
*/
|
|
596
|
+
async sendMessage(sessionId, message, permissionMode, images) {
|
|
597
|
+
await this.provider.sendMessage(sessionId, message, permissionMode, images);
|
|
598
|
+
this.updateSessionStatus(sessionId, "running");
|
|
599
|
+
console.log(`[SessionManager] \u6D88\u606F\u5DF2\u53D1\u9001\u5230\u4F1A\u8BDD: ${sessionId}`);
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* 终止会话
|
|
603
|
+
*/
|
|
604
|
+
async killSession(sessionId) {
|
|
605
|
+
this.unsubscribeSession(sessionId);
|
|
606
|
+
this.clearPendingQuestions(sessionId);
|
|
607
|
+
this.lastBroadcastStatus.delete(sessionId);
|
|
608
|
+
this.sessionEventBuffers.delete(sessionId);
|
|
609
|
+
this.sessionStats.delete(sessionId);
|
|
610
|
+
const pending = this.pendingAssistantEvents.get(sessionId);
|
|
611
|
+
if (pending) {
|
|
612
|
+
clearTimeout(pending.timer);
|
|
613
|
+
this.pendingAssistantEvents.delete(sessionId);
|
|
614
|
+
}
|
|
615
|
+
await this.provider.killSession(sessionId);
|
|
616
|
+
console.log(`[SessionManager] \u4F1A\u8BDD\u5DF2\u7EC8\u6B62: ${sessionId}`);
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* 获取会话的缓冲事件(用于新订阅者重放)
|
|
620
|
+
*/
|
|
621
|
+
getSessionEvents(sessionId) {
|
|
622
|
+
return this.sessionEventBuffers.get(sessionId) ?? [];
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* 处理 AskUserQuestion 回答(从手机端传来)
|
|
626
|
+
*/
|
|
627
|
+
handleQuestionResponse(requestId, answer) {
|
|
628
|
+
const pending = this.pendingQuestions.get(requestId);
|
|
629
|
+
if (!pending) {
|
|
630
|
+
console.warn(`[SessionManager] \u672A\u627E\u5230\u95EE\u9898\u8BF7\u6C42: ${requestId}`);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
this.pendingQuestions.delete(requestId);
|
|
634
|
+
this.updateSessionStatus(pending.sessionId, "running");
|
|
635
|
+
pending.resolve(answer);
|
|
636
|
+
console.log(`[SessionManager] \u95EE\u9898\u5DF2\u56DE\u7B54: ${requestId}`);
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* 获取所有活跃会话(含服务器端统计)
|
|
640
|
+
*/
|
|
641
|
+
getActiveSessions() {
|
|
642
|
+
return this.provider.getActiveSessions().map((session) => {
|
|
643
|
+
const stats = this.getSessionStats(session.id);
|
|
644
|
+
return stats ? { ...session, stats } : session;
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* 注册事件回调(事件会被转发到 WsBridge)
|
|
649
|
+
*
|
|
650
|
+
* @returns 取消注册的函数
|
|
651
|
+
*/
|
|
652
|
+
onEvent(callback) {
|
|
653
|
+
this.eventCallbacks.push(callback);
|
|
654
|
+
return () => {
|
|
655
|
+
const index = this.eventCallbacks.indexOf(callback);
|
|
656
|
+
if (index !== -1) {
|
|
657
|
+
this.eventCallbacks.splice(index, 1);
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* 清理所有资源
|
|
663
|
+
*/
|
|
664
|
+
destroy() {
|
|
665
|
+
for (const [, unsub] of this.unsubscribeMap) {
|
|
666
|
+
unsub();
|
|
667
|
+
}
|
|
668
|
+
this.unsubscribeMap.clear();
|
|
669
|
+
this.sessionEventBuffers.clear();
|
|
670
|
+
this.sessionStats.clear();
|
|
671
|
+
for (const [, pending] of this.pendingAssistantEvents) {
|
|
672
|
+
clearTimeout(pending.timer);
|
|
673
|
+
}
|
|
674
|
+
this.pendingAssistantEvents.clear();
|
|
675
|
+
this.pendingQuestions.clear();
|
|
676
|
+
this.lastBroadcastStatus.clear();
|
|
677
|
+
this.eventCallbacks.length = 0;
|
|
678
|
+
console.log("[SessionManager] \u5DF2\u9500\u6BC1");
|
|
679
|
+
}
|
|
680
|
+
// ============================================
|
|
681
|
+
// 内部方法
|
|
682
|
+
// ============================================
|
|
683
|
+
/**
|
|
684
|
+
* 订阅指定会话的事件流(包括 AskUserQuestion 问题事件)
|
|
685
|
+
*/
|
|
686
|
+
subscribeToSession(sessionId) {
|
|
687
|
+
const unsubscribeEvent = this.provider.onEvent(sessionId, (event) => {
|
|
688
|
+
this.handleClaudeEvent(sessionId, event);
|
|
689
|
+
});
|
|
690
|
+
const unsubscribeQuestion = this.provider.onQuestion(
|
|
691
|
+
sessionId,
|
|
692
|
+
({ toolUseId, question, options }) => {
|
|
693
|
+
this.handleAskUserQuestion(sessionId, toolUseId, question, options);
|
|
694
|
+
}
|
|
695
|
+
);
|
|
696
|
+
this.unsubscribeMap.set(sessionId, () => {
|
|
697
|
+
unsubscribeEvent();
|
|
698
|
+
unsubscribeQuestion();
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* 取消指定会话的事件订阅
|
|
703
|
+
*/
|
|
704
|
+
unsubscribeSession(sessionId) {
|
|
705
|
+
const unsub = this.unsubscribeMap.get(sessionId);
|
|
706
|
+
if (unsub) {
|
|
707
|
+
unsub();
|
|
708
|
+
this.unsubscribeMap.delete(sessionId);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* 处理来自 provider 的 Claude 事件
|
|
713
|
+
*
|
|
714
|
+
* - 包装为 ServerEvent 转发
|
|
715
|
+
* - assistant 事件在 30ms 窗口内合并后批量发送(减少 WebSocket 帧数)
|
|
716
|
+
* - 检测 status 变化
|
|
717
|
+
*/
|
|
718
|
+
handleClaudeEvent(sessionId, event) {
|
|
719
|
+
const buffer = this.sessionEventBuffers.get(sessionId) ?? [];
|
|
720
|
+
buffer.push(event);
|
|
721
|
+
if (buffer.length > BUFFER_MAX) {
|
|
722
|
+
buffer.splice(0, buffer.length - BUFFER_MAX);
|
|
723
|
+
}
|
|
724
|
+
this.sessionEventBuffers.set(sessionId, buffer);
|
|
725
|
+
if (event.type === "assistant" && Array.isArray(event.message?.content)) {
|
|
726
|
+
const thinkingBlocks = event.message.content.filter((b) => b.type === "thinking");
|
|
727
|
+
if (thinkingBlocks.length > 0) {
|
|
728
|
+
console.log(`[SessionManager] \u{1F9E0} thinking block detected in ${sessionId}: msgId=${event.message.id}, blocks=${thinkingBlocks.length}, len=${thinkingBlocks.map((b) => (b.thinking || "").length).join(",")}`);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
switch (event.type) {
|
|
732
|
+
case "assistant":
|
|
733
|
+
this.bufferAssistantEvent(sessionId, event);
|
|
734
|
+
break;
|
|
735
|
+
case "system":
|
|
736
|
+
this.flushPendingAssistant(sessionId);
|
|
737
|
+
this.emit({ type: "claude_event", sessionId, event });
|
|
738
|
+
if (event.subtype === "init") {
|
|
739
|
+
this.updateSessionStatus(sessionId, "running");
|
|
740
|
+
}
|
|
741
|
+
break;
|
|
742
|
+
case "user":
|
|
743
|
+
this.flushPendingAssistant(sessionId);
|
|
744
|
+
this.emit({ type: "claude_event", sessionId, event });
|
|
745
|
+
break;
|
|
746
|
+
case "result": {
|
|
747
|
+
this.flushPendingAssistant(sessionId);
|
|
748
|
+
this.emit({ type: "claude_event", sessionId, event });
|
|
749
|
+
const stats = this.sessionStats.get(sessionId) ?? {
|
|
750
|
+
totalInputTokens: 0,
|
|
751
|
+
totalOutputTokens: 0,
|
|
752
|
+
totalDurationMs: 0
|
|
753
|
+
};
|
|
754
|
+
if (event.usage) {
|
|
755
|
+
stats.totalInputTokens += event.usage.input_tokens ?? 0;
|
|
756
|
+
stats.totalOutputTokens += event.usage.output_tokens ?? 0;
|
|
757
|
+
}
|
|
758
|
+
if (event.total_cost_usd != null) {
|
|
759
|
+
stats.totalCostUsd = (stats.totalCostUsd ?? 0) + event.total_cost_usd;
|
|
760
|
+
}
|
|
761
|
+
this.sessionStats.set(sessionId, stats);
|
|
762
|
+
if (event.is_error) {
|
|
763
|
+
this.updateSessionStatus(sessionId, "error");
|
|
764
|
+
} else {
|
|
765
|
+
this.updateSessionStatus(sessionId, "idle");
|
|
766
|
+
}
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* 缓冲 assistant 事件到 30ms 窗口
|
|
773
|
+
*/
|
|
774
|
+
bufferAssistantEvent(sessionId, event) {
|
|
775
|
+
let pending = this.pendingAssistantEvents.get(sessionId);
|
|
776
|
+
if (!pending) {
|
|
777
|
+
pending = {
|
|
778
|
+
events: [],
|
|
779
|
+
timer: setTimeout(() => this.flushPendingAssistant(sessionId), 30)
|
|
780
|
+
};
|
|
781
|
+
this.pendingAssistantEvents.set(sessionId, pending);
|
|
782
|
+
}
|
|
783
|
+
pending.events.push(event);
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* 刷新缓冲的 assistant 事件,批量发送
|
|
787
|
+
*/
|
|
788
|
+
flushPendingAssistant(sessionId) {
|
|
789
|
+
const pending = this.pendingAssistantEvents.get(sessionId);
|
|
790
|
+
if (!pending) return;
|
|
791
|
+
clearTimeout(pending.timer);
|
|
792
|
+
this.pendingAssistantEvents.delete(sessionId);
|
|
793
|
+
if (pending.events.length === 1) {
|
|
794
|
+
this.emit({ type: "claude_event", sessionId, event: pending.events[0] });
|
|
795
|
+
} else if (pending.events.length > 1) {
|
|
796
|
+
this.emit({ type: "claude_events", sessionId, events: pending.events });
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* 更新会话状态,如果状态发生变化则广播通知
|
|
801
|
+
*
|
|
802
|
+
* 使用 lastBroadcastStatus 去重,只在状态实际变化时广播。
|
|
803
|
+
*/
|
|
804
|
+
updateSessionStatus(sessionId, newStatus) {
|
|
805
|
+
const lastStatus = this.lastBroadcastStatus.get(sessionId);
|
|
806
|
+
if (lastStatus !== newStatus) {
|
|
807
|
+
if (lastStatus === "running") {
|
|
808
|
+
const startedAt = this.runningStartedAt.get(sessionId);
|
|
809
|
+
if (startedAt) {
|
|
810
|
+
const stats2 = this.sessionStats.get(sessionId) ?? {
|
|
811
|
+
totalInputTokens: 0,
|
|
812
|
+
totalOutputTokens: 0,
|
|
813
|
+
totalDurationMs: 0
|
|
814
|
+
};
|
|
815
|
+
stats2.totalDurationMs += Date.now() - startedAt;
|
|
816
|
+
this.sessionStats.set(sessionId, stats2);
|
|
817
|
+
this.runningStartedAt.delete(sessionId);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
if (newStatus === "running") {
|
|
821
|
+
this.runningStartedAt.set(sessionId, Date.now());
|
|
822
|
+
}
|
|
823
|
+
this.lastBroadcastStatus.set(sessionId, newStatus);
|
|
824
|
+
const stats = this.getSessionStats(sessionId);
|
|
825
|
+
this.emit({
|
|
826
|
+
type: "status_change",
|
|
827
|
+
sessionId,
|
|
828
|
+
status: newStatus,
|
|
829
|
+
stats
|
|
830
|
+
});
|
|
831
|
+
console.log(`[SessionManager] \u4F1A\u8BDD ${sessionId} \u72B6\u6001\u53D8\u5316: ${lastStatus ?? "(\u65E0)"} \u2192 ${newStatus}`);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
/** 获取会话统计(含 runningStartedAt) */
|
|
835
|
+
getSessionStats(sessionId) {
|
|
836
|
+
const runningStartedAt = this.runningStartedAt.get(sessionId);
|
|
837
|
+
const stats = this.sessionStats.get(sessionId);
|
|
838
|
+
if (!stats && !runningStartedAt) return void 0;
|
|
839
|
+
const base = stats ?? { totalInputTokens: 0, totalOutputTokens: 0, totalDurationMs: 0 };
|
|
840
|
+
return runningStartedAt ? { ...base, runningStartedAt } : base;
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* 处理 AskUserQuestion 事件:广播问题请求到手机,等待用户回答
|
|
844
|
+
*/
|
|
845
|
+
handleAskUserQuestion(sessionId, toolUseId, question, options) {
|
|
846
|
+
const existingEntry = Array.from(this.pendingQuestions.entries()).find(
|
|
847
|
+
([, v]) => v.toolUseId === toolUseId
|
|
848
|
+
);
|
|
849
|
+
if (existingEntry) {
|
|
850
|
+
const [existingRequestId] = existingEntry;
|
|
851
|
+
const updatedRequest = {
|
|
852
|
+
id: existingRequestId,
|
|
853
|
+
sessionId,
|
|
854
|
+
toolUseId,
|
|
855
|
+
question,
|
|
856
|
+
options,
|
|
857
|
+
createdAt: Date.now()
|
|
858
|
+
};
|
|
859
|
+
this.emit({ type: "question_request", request: updatedRequest });
|
|
860
|
+
console.log(`[SessionManager] \u4F1A\u8BDD ${sessionId}: AskUserQuestion \u5DF2\u66F4\u65B0 (requestId=${existingRequestId})`);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
const requestId = (0, import_uuid2.v4)();
|
|
864
|
+
const request = {
|
|
865
|
+
id: requestId,
|
|
866
|
+
sessionId,
|
|
867
|
+
toolUseId,
|
|
868
|
+
question,
|
|
869
|
+
options,
|
|
870
|
+
createdAt: Date.now()
|
|
871
|
+
};
|
|
872
|
+
this.updateSessionStatus(sessionId, "waiting_question");
|
|
873
|
+
this.emit({ type: "question_request", request });
|
|
874
|
+
const answerPromise = new Promise((resolve) => {
|
|
875
|
+
this.pendingQuestions.set(requestId, { sessionId, toolUseId, resolve });
|
|
876
|
+
});
|
|
877
|
+
answerPromise.then(async (answer) => {
|
|
878
|
+
try {
|
|
879
|
+
await this.provider.answerQuestion(sessionId, toolUseId, answer);
|
|
880
|
+
} catch (err) {
|
|
881
|
+
console.error(`[SessionManager] answerQuestion \u5931\u8D25 (${sessionId}):`, err);
|
|
882
|
+
}
|
|
883
|
+
}).catch((err) => console.error("[SessionManager] answerPromise rejected:", err));
|
|
884
|
+
console.log(`[SessionManager] \u4F1A\u8BDD ${sessionId}: AskUserQuestion \u5DF2\u63A8\u9001 (requestId=${requestId})`);
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* 清除指定会话的所有待回答问题
|
|
888
|
+
*/
|
|
889
|
+
clearPendingQuestions(sessionId) {
|
|
890
|
+
const toRemove = [];
|
|
891
|
+
for (const [requestId, pending] of this.pendingQuestions) {
|
|
892
|
+
if (pending.sessionId === sessionId) {
|
|
893
|
+
toRemove.push(requestId);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
for (const requestId of toRemove) {
|
|
897
|
+
this.pendingQuestions.delete(requestId);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* 发出 ServerEvent 到所有已注册的回调
|
|
902
|
+
*/
|
|
903
|
+
emit(event) {
|
|
904
|
+
for (const callback of this.eventCallbacks) {
|
|
905
|
+
try {
|
|
906
|
+
callback(event);
|
|
907
|
+
} catch (err) {
|
|
908
|
+
console.error("[SessionManager] \u4E8B\u4EF6\u56DE\u8C03\u6267\u884C\u5F02\u5E38:", err);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
// src/session/SessionFileWatcher.ts
|
|
915
|
+
var import_chokidar = __toESM(require("chokidar"));
|
|
916
|
+
var import_promises = require("fs/promises");
|
|
917
|
+
var import_node_readline = require("readline");
|
|
918
|
+
var SessionFileWatcher = class {
|
|
919
|
+
watchers = /* @__PURE__ */ new Map();
|
|
920
|
+
onEvent;
|
|
921
|
+
/** 文件无变化后自动停止监听的超时时间(10 分钟) */
|
|
922
|
+
IDLE_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
923
|
+
constructor(onEvent) {
|
|
924
|
+
this.onEvent = onEvent;
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* 开始监听指定会话的 JSONL 文件新增内容
|
|
928
|
+
*
|
|
929
|
+
* @param sessionId 会话 ID
|
|
930
|
+
* @param filePath JSONL 文件绝对路径
|
|
931
|
+
* @param byteOffset 已读到的字节位置(跳过历史内容,只推送新行)
|
|
932
|
+
*/
|
|
933
|
+
watch(sessionId, filePath, byteOffset) {
|
|
934
|
+
if (this.watchers.has(sessionId)) return;
|
|
935
|
+
const watcher = import_chokidar.default.watch(filePath, {
|
|
936
|
+
persistent: false,
|
|
937
|
+
// 不阻止进程退出
|
|
938
|
+
usePolling: false,
|
|
939
|
+
// 使用原生 FS 事件,不轮询
|
|
940
|
+
awaitWriteFinish: {
|
|
941
|
+
stabilityThreshold: 300,
|
|
942
|
+
// 写入停止 300ms 后才触发
|
|
943
|
+
pollInterval: 100
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
const entry = {
|
|
947
|
+
filePath,
|
|
948
|
+
byteOffset,
|
|
949
|
+
idleTimer: null,
|
|
950
|
+
watcher
|
|
951
|
+
};
|
|
952
|
+
watcher.on("change", () => {
|
|
953
|
+
this.readNewLines(sessionId).catch((err) => {
|
|
954
|
+
console.error(`[SessionFileWatcher] \u8BFB\u53D6\u5F02\u5E38 ${sessionId}:`, err);
|
|
955
|
+
});
|
|
956
|
+
this.resetIdleTimer(sessionId);
|
|
957
|
+
});
|
|
958
|
+
this.watchers.set(sessionId, entry);
|
|
959
|
+
this.resetIdleTimer(sessionId);
|
|
960
|
+
console.log(`[SessionFileWatcher] \u5F00\u59CB\u76D1\u542C: ${sessionId} (offset=${byteOffset})`);
|
|
961
|
+
}
|
|
962
|
+
/** 停止监听指定会话 */
|
|
963
|
+
unwatch(sessionId) {
|
|
964
|
+
const entry = this.watchers.get(sessionId);
|
|
965
|
+
if (!entry) return;
|
|
966
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
967
|
+
void entry.watcher.close();
|
|
968
|
+
this.watchers.delete(sessionId);
|
|
969
|
+
console.log(`[SessionFileWatcher] \u505C\u6B62\u76D1\u542C: ${sessionId}`);
|
|
970
|
+
}
|
|
971
|
+
/** 停止所有监听(服务关闭时调用) */
|
|
972
|
+
destroy() {
|
|
973
|
+
for (const sessionId of [...this.watchers.keys()]) {
|
|
974
|
+
this.unwatch(sessionId);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
// ============================================
|
|
978
|
+
// 内部方法
|
|
979
|
+
// ============================================
|
|
980
|
+
resetIdleTimer(sessionId) {
|
|
981
|
+
const entry = this.watchers.get(sessionId);
|
|
982
|
+
if (!entry) return;
|
|
983
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
984
|
+
entry.idleTimer = setTimeout(() => {
|
|
985
|
+
console.log(`[SessionFileWatcher] \u7A7A\u95F2\u8D85\u65F6\uFF0C\u505C\u6B62\u76D1\u542C: ${sessionId}`);
|
|
986
|
+
this.unwatch(sessionId);
|
|
987
|
+
}, this.IDLE_TIMEOUT_MS);
|
|
988
|
+
}
|
|
989
|
+
async readNewLines(sessionId) {
|
|
990
|
+
const entry = this.watchers.get(sessionId);
|
|
991
|
+
if (!entry) return;
|
|
992
|
+
let fileHandle;
|
|
993
|
+
let rl;
|
|
994
|
+
try {
|
|
995
|
+
fileHandle = await (0, import_promises.open)(entry.filePath, "r");
|
|
996
|
+
const fileStat = await fileHandle.stat();
|
|
997
|
+
const newSize = fileStat.size;
|
|
998
|
+
if (newSize <= entry.byteOffset) return;
|
|
999
|
+
rl = (0, import_node_readline.createInterface)({
|
|
1000
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1001
|
+
input: fileHandle.createReadStream({ start: entry.byteOffset, encoding: "utf-8" }),
|
|
1002
|
+
crlfDelay: Infinity
|
|
1003
|
+
});
|
|
1004
|
+
const newEvents = [];
|
|
1005
|
+
let isCompleted = false;
|
|
1006
|
+
let isError = false;
|
|
1007
|
+
for await (const line of rl) {
|
|
1008
|
+
if (!line.trim()) continue;
|
|
1009
|
+
const parsed = parseJSONLLine(line, sessionId);
|
|
1010
|
+
if (parsed.type === "event" && parsed.event) {
|
|
1011
|
+
newEvents.push(parsed.event);
|
|
1012
|
+
} else if (parsed.type === "completed") {
|
|
1013
|
+
isCompleted = true;
|
|
1014
|
+
isError = parsed.isError;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
entry.byteOffset = newSize;
|
|
1018
|
+
for (const event of newEvents) {
|
|
1019
|
+
this.onEvent({ type: "claude_event", sessionId, event });
|
|
1020
|
+
}
|
|
1021
|
+
if (isCompleted) {
|
|
1022
|
+
this.onEvent({ type: "status_change", sessionId, status: isError ? "error" : "idle" });
|
|
1023
|
+
this.unwatch(sessionId);
|
|
1024
|
+
}
|
|
1025
|
+
} finally {
|
|
1026
|
+
rl?.close();
|
|
1027
|
+
await fileHandle?.close();
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
};
|
|
1031
|
+
function parseJSONLLine(line, sessionId) {
|
|
1032
|
+
try {
|
|
1033
|
+
const obj = JSON.parse(line);
|
|
1034
|
+
if (obj.type === "user" && obj.message) {
|
|
1035
|
+
const msgContent = obj.message.content;
|
|
1036
|
+
if (typeof msgContent === "string") {
|
|
1037
|
+
if (msgContent.includes("<local-command") || msgContent.includes("<command-name>")) {
|
|
1038
|
+
return { type: "skip" };
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
const normalizedContent = typeof msgContent === "string" ? [{ type: "text", text: msgContent }] : Array.isArray(msgContent) ? msgContent.filter((b) => b.type === "text" && typeof b.text === "string") : [];
|
|
1042
|
+
if (normalizedContent.length === 0) return { type: "skip" };
|
|
1043
|
+
return {
|
|
1044
|
+
type: "event",
|
|
1045
|
+
event: {
|
|
1046
|
+
type: "user",
|
|
1047
|
+
message: { ...obj.message, content: normalizedContent },
|
|
1048
|
+
session_id: sessionId
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
if (obj.type === "assistant" && obj.message) {
|
|
1053
|
+
const content = (obj.message.content ?? []).filter((b) => b.type === "text" || b.type === "tool_use");
|
|
1054
|
+
if (content.length === 0) return { type: "skip" };
|
|
1055
|
+
return {
|
|
1056
|
+
type: "event",
|
|
1057
|
+
event: {
|
|
1058
|
+
type: "assistant",
|
|
1059
|
+
message: {
|
|
1060
|
+
id: obj.message.id ?? obj.uuid ?? "unknown",
|
|
1061
|
+
model: obj.message.model ?? "unknown",
|
|
1062
|
+
role: "assistant",
|
|
1063
|
+
content,
|
|
1064
|
+
stop_reason: obj.message.stop_reason
|
|
1065
|
+
},
|
|
1066
|
+
session_id: sessionId
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
if (obj.type === "result") {
|
|
1071
|
+
return { type: "completed", isError: !!obj.is_error };
|
|
1072
|
+
}
|
|
1073
|
+
return { type: "skip" };
|
|
1074
|
+
} catch {
|
|
1075
|
+
return { type: "skip" };
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// src/ws/WsBridge.ts
|
|
1080
|
+
var import_ws = require("ws");
|
|
1081
|
+
var WsBridge = class _WsBridge {
|
|
1082
|
+
wss;
|
|
1083
|
+
token;
|
|
1084
|
+
heartbeatTimer = null;
|
|
1085
|
+
clientEventCallbacks = [];
|
|
1086
|
+
connectionCallbacks = [];
|
|
1087
|
+
disconnectCallbacks = [];
|
|
1088
|
+
/** 每个连接的最后一次 pong 时间 */
|
|
1089
|
+
lastPongMap = /* @__PURE__ */ new Map();
|
|
1090
|
+
/** 每个连接当前正在查看的会话 ID */
|
|
1091
|
+
viewingSessions = /* @__PURE__ */ new Map();
|
|
1092
|
+
constructor(options) {
|
|
1093
|
+
this.token = options.token;
|
|
1094
|
+
this.wss = new import_ws.WebSocketServer({
|
|
1095
|
+
port: options.port,
|
|
1096
|
+
verifyClient: (info, callback) => {
|
|
1097
|
+
const authorized = this.verifyToken(info.req);
|
|
1098
|
+
if (!authorized) {
|
|
1099
|
+
callback(false, 401, "Unauthorized");
|
|
1100
|
+
} else {
|
|
1101
|
+
callback(true);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
this.wss.on("connection", (ws) => this.handleConnection(ws));
|
|
1106
|
+
this.startHeartbeat();
|
|
1107
|
+
console.log(`[WsBridge] WebSocket \u670D\u52A1\u5DF2\u542F\u52A8\uFF0C\u7AEF\u53E3 ${options.port}`);
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* 异步工厂方法:等待端口监听成功后 resolve,端口占用等错误时 reject。
|
|
1111
|
+
* 使用此方法代替 new WsBridge(),确保 EADDRINUSE 等错误能被调用方的 try-catch 捕获。
|
|
1112
|
+
*/
|
|
1113
|
+
static async create(options) {
|
|
1114
|
+
return new Promise((resolve, reject) => {
|
|
1115
|
+
const bridge = new _WsBridge(options);
|
|
1116
|
+
bridge.wss.once("listening", () => {
|
|
1117
|
+
bridge.wss.on("error", (err) => console.error("[WsBridge] \u670D\u52A1\u8FD0\u884C\u9519\u8BEF:", err));
|
|
1118
|
+
resolve(bridge);
|
|
1119
|
+
});
|
|
1120
|
+
bridge.wss.once("error", reject);
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
// ============================================
|
|
1124
|
+
// 公开 API
|
|
1125
|
+
// ============================================
|
|
1126
|
+
/** 注册客户端事件回调 */
|
|
1127
|
+
onClientEvent(callback) {
|
|
1128
|
+
this.clientEventCallbacks.push(callback);
|
|
1129
|
+
}
|
|
1130
|
+
/** 注册新连接回调(用于自动推送初始数据) */
|
|
1131
|
+
onConnection(callback) {
|
|
1132
|
+
this.connectionCallbacks.push(callback);
|
|
1133
|
+
}
|
|
1134
|
+
/** 注册断开连接回调(任意客户端断开时触发,可通过 getConnectionCount() 判断是否全部断开) */
|
|
1135
|
+
onDisconnect(callback) {
|
|
1136
|
+
this.disconnectCallbacks.push(callback);
|
|
1137
|
+
}
|
|
1138
|
+
/** 广播事件到所有已连接的客户端 */
|
|
1139
|
+
broadcast(event) {
|
|
1140
|
+
const data = JSON.stringify(event);
|
|
1141
|
+
for (const ws of this.wss.clients) {
|
|
1142
|
+
if (ws.readyState === import_ws.WebSocket.OPEN) {
|
|
1143
|
+
ws.send(data);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
/** 发送事件到指定客户端 */
|
|
1148
|
+
send(ws, event) {
|
|
1149
|
+
if (ws.readyState === import_ws.WebSocket.OPEN) {
|
|
1150
|
+
ws.send(JSON.stringify(event));
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
/** 设置指定连接当前正在查看的会话 */
|
|
1154
|
+
setViewingSession(ws, sessionId) {
|
|
1155
|
+
this.viewingSessions.set(ws, sessionId);
|
|
1156
|
+
}
|
|
1157
|
+
/** 清除指定连接的查看状态 */
|
|
1158
|
+
clearViewingSession(ws) {
|
|
1159
|
+
this.viewingSessions.delete(ws);
|
|
1160
|
+
}
|
|
1161
|
+
/** 检查是否有任意连接正在查看指定会话 */
|
|
1162
|
+
isViewingSession(sessionId) {
|
|
1163
|
+
for (const sid of this.viewingSessions.values()) {
|
|
1164
|
+
if (sid === sessionId) return true;
|
|
1165
|
+
}
|
|
1166
|
+
return false;
|
|
1167
|
+
}
|
|
1168
|
+
/** 获取当前活跃连接数 */
|
|
1169
|
+
getConnectionCount() {
|
|
1170
|
+
return this.wss.clients.size;
|
|
1171
|
+
}
|
|
1172
|
+
/** 优雅关闭 WebSocket 服务 */
|
|
1173
|
+
close() {
|
|
1174
|
+
return new Promise((resolve, reject) => {
|
|
1175
|
+
if (this.heartbeatTimer) {
|
|
1176
|
+
clearInterval(this.heartbeatTimer);
|
|
1177
|
+
this.heartbeatTimer = null;
|
|
1178
|
+
}
|
|
1179
|
+
for (const ws of this.wss.clients) {
|
|
1180
|
+
ws.terminate();
|
|
1181
|
+
}
|
|
1182
|
+
this.wss.close((err) => {
|
|
1183
|
+
if (err) {
|
|
1184
|
+
reject(err);
|
|
1185
|
+
} else {
|
|
1186
|
+
console.log("[WsBridge] WebSocket \u670D\u52A1\u5DF2\u5173\u95ED");
|
|
1187
|
+
resolve();
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
// ============================================
|
|
1193
|
+
// 内部方法
|
|
1194
|
+
// ============================================
|
|
1195
|
+
/** 验证连接 token(token 为空字符串时跳过验证) */
|
|
1196
|
+
verifyToken(req) {
|
|
1197
|
+
if (this.token === "") return true;
|
|
1198
|
+
try {
|
|
1199
|
+
const url = new URL(req.url ?? "", `http://${req.headers.host}`);
|
|
1200
|
+
const clientToken = url.searchParams.get("token");
|
|
1201
|
+
return clientToken === this.token;
|
|
1202
|
+
} catch {
|
|
1203
|
+
return false;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
/** 处理新的 WebSocket 连接 */
|
|
1207
|
+
handleConnection(ws) {
|
|
1208
|
+
this.lastPongMap.set(ws, Date.now());
|
|
1209
|
+
console.log(`[WsBridge] \u65B0\u5BA2\u6237\u7AEF\u8FDE\u63A5\uFF0C\u5F53\u524D\u8FDE\u63A5\u6570: ${this.getConnectionCount()}`);
|
|
1210
|
+
for (const callback of this.connectionCallbacks) {
|
|
1211
|
+
try {
|
|
1212
|
+
callback(ws);
|
|
1213
|
+
} catch (err) {
|
|
1214
|
+
console.error("[WsBridge] \u8FDE\u63A5\u56DE\u8C03\u6267\u884C\u5F02\u5E38:", err);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
ws.on("pong", () => {
|
|
1218
|
+
this.lastPongMap.set(ws, Date.now());
|
|
1219
|
+
});
|
|
1220
|
+
ws.on("message", (raw) => {
|
|
1221
|
+
try {
|
|
1222
|
+
const event = JSON.parse(raw.toString());
|
|
1223
|
+
this.dispatchClientEvent(event, ws);
|
|
1224
|
+
} catch (err) {
|
|
1225
|
+
console.error("[WsBridge] \u6D88\u606F\u89E3\u6790\u5931\u8D25:", err);
|
|
1226
|
+
this.send(ws, {
|
|
1227
|
+
type: "error",
|
|
1228
|
+
message: "\u6D88\u606F\u683C\u5F0F\u65E0\u6548",
|
|
1229
|
+
code: "INVALID_MESSAGE"
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
});
|
|
1233
|
+
ws.on("close", () => {
|
|
1234
|
+
this.lastPongMap.delete(ws);
|
|
1235
|
+
this.viewingSessions.delete(ws);
|
|
1236
|
+
setTimeout(() => {
|
|
1237
|
+
console.log(`[WsBridge] \u5BA2\u6237\u7AEF\u65AD\u5F00\uFF0C\u5F53\u524D\u8FDE\u63A5\u6570: ${this.getConnectionCount()}`);
|
|
1238
|
+
for (const cb of this.disconnectCallbacks) {
|
|
1239
|
+
try {
|
|
1240
|
+
cb();
|
|
1241
|
+
} catch (err) {
|
|
1242
|
+
console.error("[WsBridge] \u65AD\u5F00\u56DE\u8C03\u6267\u884C\u5F02\u5E38:", err);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}, 0);
|
|
1246
|
+
});
|
|
1247
|
+
ws.on("error", (err) => {
|
|
1248
|
+
console.error("[WsBridge] \u8FDE\u63A5\u9519\u8BEF:", err.message);
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
/** 分发客户端事件到所有注册的回调 */
|
|
1252
|
+
dispatchClientEvent(event, ws) {
|
|
1253
|
+
for (const callback of this.clientEventCallbacks) {
|
|
1254
|
+
try {
|
|
1255
|
+
callback(event, ws);
|
|
1256
|
+
} catch (err) {
|
|
1257
|
+
console.error("[WsBridge] \u4E8B\u4EF6\u56DE\u8C03\u6267\u884C\u5F02\u5E38:", err);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
/** 启动心跳机制 */
|
|
1262
|
+
startHeartbeat() {
|
|
1263
|
+
this.heartbeatTimer = setInterval(() => {
|
|
1264
|
+
const now = Date.now();
|
|
1265
|
+
this.broadcast({ type: "heartbeat", timestamp: now });
|
|
1266
|
+
for (const ws of this.wss.clients) {
|
|
1267
|
+
const lastPong = this.lastPongMap.get(ws) ?? 0;
|
|
1268
|
+
if (now - lastPong > 45e3) {
|
|
1269
|
+
console.log("[WsBridge] \u68C0\u6D4B\u5230\u6B7B\u8FDE\u63A5\uFF0C\u4E3B\u52A8\u65AD\u5F00");
|
|
1270
|
+
ws.terminate();
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
1273
|
+
if (ws.readyState === import_ws.WebSocket.OPEN) {
|
|
1274
|
+
ws.ping();
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}, 15e3);
|
|
1278
|
+
}
|
|
1279
|
+
};
|
|
1280
|
+
|
|
1281
|
+
// src/approval/ApprovalProxy.ts
|
|
1282
|
+
var import_node_http = __toESM(require("http"));
|
|
1283
|
+
var import_node_fs = __toESM(require("fs"));
|
|
1284
|
+
var import_node_path = __toESM(require("path"));
|
|
1285
|
+
var import_node_os2 = __toESM(require("os"));
|
|
1286
|
+
var import_uuid3 = require("uuid");
|
|
1287
|
+
var ApprovalProxy = class _ApprovalProxy {
|
|
1288
|
+
server;
|
|
1289
|
+
token;
|
|
1290
|
+
port;
|
|
1291
|
+
settingsPath = import_node_path.default.join(import_node_os2.default.homedir(), ".claude", "settings.json");
|
|
1292
|
+
/** 待处理的审批请求:requestId -> { resolve, timer, request } */
|
|
1293
|
+
pendingApprovals = /* @__PURE__ */ new Map();
|
|
1294
|
+
/** 审批请求回调(通知外部推送到手机) */
|
|
1295
|
+
approvalRequestCallbacks = [];
|
|
1296
|
+
/** YOLO 模式状态:sessionId -> enabled */
|
|
1297
|
+
yoloSessions = /* @__PURE__ */ new Map();
|
|
1298
|
+
/** 内存缓存:已被"始终允许"的工具名(避免每次读 settings.json) */
|
|
1299
|
+
alwaysAllowedTools = /* @__PURE__ */ new Set();
|
|
1300
|
+
/** 获取状态信息的回调(由外部注入) */
|
|
1301
|
+
statusInfoProvider = null;
|
|
1302
|
+
constructor(options) {
|
|
1303
|
+
this.token = options.token;
|
|
1304
|
+
this.port = options.port;
|
|
1305
|
+
this.server = import_node_http.default.createServer((req, res) => {
|
|
1306
|
+
this.handleRequest(req, res);
|
|
1307
|
+
});
|
|
1308
|
+
this.server.listen(options.port, () => {
|
|
1309
|
+
console.log(`[ApprovalProxy] HTTP \u5BA1\u6279\u670D\u52A1\u5DF2\u542F\u52A8\uFF0C\u7AEF\u53E3 ${options.port}`);
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
/**
|
|
1313
|
+
* 异步工厂方法:等待端口监听成功后 resolve,端口占用等错误时 reject。
|
|
1314
|
+
*/
|
|
1315
|
+
static async create(options) {
|
|
1316
|
+
return new Promise((resolve, reject) => {
|
|
1317
|
+
const proxy = new _ApprovalProxy(options);
|
|
1318
|
+
proxy.server.once("listening", () => {
|
|
1319
|
+
proxy.server.on("error", (err) => console.error("[ApprovalProxy] \u670D\u52A1\u8FD0\u884C\u9519\u8BEF:", err));
|
|
1320
|
+
resolve(proxy);
|
|
1321
|
+
});
|
|
1322
|
+
proxy.server.once("error", reject);
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
// ============================================
|
|
1326
|
+
// 公开 API
|
|
1327
|
+
// ============================================
|
|
1328
|
+
/** 注册审批请求回调(当有新的审批请求时触发) */
|
|
1329
|
+
onApprovalRequest(callback) {
|
|
1330
|
+
this.approvalRequestCallbacks.push(callback);
|
|
1331
|
+
}
|
|
1332
|
+
/** 设置状态信息提供者(用于 /health 端点) */
|
|
1333
|
+
setStatusInfoProvider(provider) {
|
|
1334
|
+
this.statusInfoProvider = provider;
|
|
1335
|
+
}
|
|
1336
|
+
/** 设置会话的 YOLO 模式(服务端拦截,即使手机断连也生效) */
|
|
1337
|
+
setYoloMode(sessionId, enabled) {
|
|
1338
|
+
this.yoloSessions.set(sessionId, enabled);
|
|
1339
|
+
console.log(`[ApprovalProxy] YOLO \u6A21\u5F0F ${enabled ? "\u5DF2\u542F\u7528" : "\u5DF2\u5173\u95ED"}: ${sessionId}`);
|
|
1340
|
+
}
|
|
1341
|
+
/** 检查会话是否处于 YOLO 模式 */
|
|
1342
|
+
isYoloMode(sessionId) {
|
|
1343
|
+
return this.yoloSessions.get(sessionId) ?? false;
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* 注入审批结果
|
|
1347
|
+
*
|
|
1348
|
+
* 从 pendingApprovals 中取出对应请求,resolve promise,
|
|
1349
|
+
* 让长轮询的 HTTP 响应返回审批结果给 Claude Code hook。
|
|
1350
|
+
*/
|
|
1351
|
+
resolveApproval(requestId, decision) {
|
|
1352
|
+
const pending = this.pendingApprovals.get(requestId);
|
|
1353
|
+
if (!pending) {
|
|
1354
|
+
console.warn(`[ApprovalProxy] \u5BA1\u6279\u8BF7\u6C42 ${requestId} \u4E0D\u5B58\u5728\u6216\u5DF2\u8D85\u65F6`);
|
|
1355
|
+
return false;
|
|
1356
|
+
}
|
|
1357
|
+
clearTimeout(pending.timer);
|
|
1358
|
+
pending.resolve(decision);
|
|
1359
|
+
this.pendingApprovals.delete(requestId);
|
|
1360
|
+
console.log(`[ApprovalProxy] \u5BA1\u6279\u8BF7\u6C42 ${requestId} \u5DF2\u5904\u7406: ${decision.decision}`);
|
|
1361
|
+
return true;
|
|
1362
|
+
}
|
|
1363
|
+
/** 获取当前待处理的审批数量 */
|
|
1364
|
+
getPendingCount() {
|
|
1365
|
+
return this.pendingApprovals.size;
|
|
1366
|
+
}
|
|
1367
|
+
/** 检查指定审批请求是否仍在等待用户决策 */
|
|
1368
|
+
isPending(requestId) {
|
|
1369
|
+
return this.pendingApprovals.has(requestId);
|
|
1370
|
+
}
|
|
1371
|
+
/** 检查工具是否已被"始终允许"(内存缓存 + settings.json 双重检查) */
|
|
1372
|
+
isToolAlwaysAllowed(toolName, projectPath) {
|
|
1373
|
+
if (this.alwaysAllowedTools.has(toolName)) return true;
|
|
1374
|
+
return this.isToolInClaudeSettings(toolName, projectPath);
|
|
1375
|
+
}
|
|
1376
|
+
/** 检查工具是否已在 settings.json permissions.allow 中(检查项目级和全局) */
|
|
1377
|
+
isToolInClaudeSettings(toolName, projectPath) {
|
|
1378
|
+
const checkPath = (filepath) => {
|
|
1379
|
+
try {
|
|
1380
|
+
const raw = import_node_fs.default.readFileSync(filepath, "utf-8");
|
|
1381
|
+
const settings = JSON.parse(raw);
|
|
1382
|
+
const allow = settings?.permissions?.allow ?? [];
|
|
1383
|
+
return allow.some((entry) => {
|
|
1384
|
+
if (entry === toolName) return true;
|
|
1385
|
+
if (entry === `${toolName}(*)`) return true;
|
|
1386
|
+
if (toolName.startsWith(`${entry}__`)) return true;
|
|
1387
|
+
return false;
|
|
1388
|
+
});
|
|
1389
|
+
} catch {
|
|
1390
|
+
return false;
|
|
1391
|
+
}
|
|
1392
|
+
};
|
|
1393
|
+
if (projectPath) {
|
|
1394
|
+
const projectSettingsPath = import_node_path.default.join(projectPath, ".claude", "settings.json");
|
|
1395
|
+
if (checkPath(projectSettingsPath)) return true;
|
|
1396
|
+
}
|
|
1397
|
+
return checkPath(this.settingsPath);
|
|
1398
|
+
}
|
|
1399
|
+
/** 将工具写入 settings.json permissions.allow(项目级或全局) */
|
|
1400
|
+
addToClaudeSettings(projectPath, toolName) {
|
|
1401
|
+
const targetPath = projectPath ? import_node_path.default.join(projectPath, ".claude", "settings.json") : this.settingsPath;
|
|
1402
|
+
try {
|
|
1403
|
+
if (projectPath) {
|
|
1404
|
+
const dir = import_node_path.default.dirname(targetPath);
|
|
1405
|
+
if (!import_node_fs.default.existsSync(dir)) {
|
|
1406
|
+
import_node_fs.default.mkdirSync(dir, { recursive: true });
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
let settings = {};
|
|
1410
|
+
try {
|
|
1411
|
+
settings = JSON.parse(import_node_fs.default.readFileSync(targetPath, "utf-8"));
|
|
1412
|
+
} catch {
|
|
1413
|
+
}
|
|
1414
|
+
if (!settings.permissions) {
|
|
1415
|
+
settings.permissions = {};
|
|
1416
|
+
}
|
|
1417
|
+
const perms = settings.permissions;
|
|
1418
|
+
if (!Array.isArray(perms.allow)) {
|
|
1419
|
+
perms.allow = [];
|
|
1420
|
+
}
|
|
1421
|
+
const allow = perms.allow;
|
|
1422
|
+
const entry = `${toolName}(*)`;
|
|
1423
|
+
if (!allow.includes(entry)) {
|
|
1424
|
+
allow.push(entry);
|
|
1425
|
+
import_node_fs.default.writeFileSync(targetPath, JSON.stringify(settings, null, 2), "utf-8");
|
|
1426
|
+
const label = projectPath ? `${projectPath}/.claude/settings.json` : "~/.claude/settings.json";
|
|
1427
|
+
console.log(`[ApprovalProxy] \u5DF2\u5C06 ${entry} \u5199\u5165 ${label}`);
|
|
1428
|
+
}
|
|
1429
|
+
this.alwaysAllowedTools.add(toolName);
|
|
1430
|
+
} catch (err) {
|
|
1431
|
+
console.error("[ApprovalProxy] \u5199\u5165 settings.json \u5931\u8D25:", err);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
/** 获取指定会话的所有 pending approval requests(用于 subscribe 重发) */
|
|
1435
|
+
getPendingRequestsForSession(sessionId) {
|
|
1436
|
+
const result = [];
|
|
1437
|
+
for (const { request } of this.pendingApprovals.values()) {
|
|
1438
|
+
if (request.sessionId === sessionId) {
|
|
1439
|
+
result.push(request);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
return result;
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* 批量允许所有待处理的审批请求(手机端断线时调用)
|
|
1446
|
+
*/
|
|
1447
|
+
approveAll(reason) {
|
|
1448
|
+
const entries = Array.from(this.pendingApprovals.entries());
|
|
1449
|
+
for (const [requestId, pending] of entries) {
|
|
1450
|
+
clearTimeout(pending.timer);
|
|
1451
|
+
pending.resolve({ decision: "allow" });
|
|
1452
|
+
this.pendingApprovals.delete(requestId);
|
|
1453
|
+
console.log(`[ApprovalProxy] \u5BA1\u6279\u8BF7\u6C42 ${requestId} \u5DF2\u81EA\u52A8\u5141\u8BB8${reason ? `\uFF08${reason}\uFF09` : ""}`);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
/** 优雅关闭 HTTP 服务 */
|
|
1457
|
+
close() {
|
|
1458
|
+
return new Promise((resolve, reject) => {
|
|
1459
|
+
const pendingEntries = Array.from(this.pendingApprovals.entries());
|
|
1460
|
+
for (const [, pending] of pendingEntries) {
|
|
1461
|
+
clearTimeout(pending.timer);
|
|
1462
|
+
pending.resolve({ decision: "deny", reason: "\u670D\u52A1\u5668\u5DF2\u5173\u95ED" });
|
|
1463
|
+
}
|
|
1464
|
+
this.pendingApprovals.clear();
|
|
1465
|
+
this.server.close((err) => {
|
|
1466
|
+
if (err) {
|
|
1467
|
+
reject(err);
|
|
1468
|
+
} else {
|
|
1469
|
+
console.log("[ApprovalProxy] HTTP \u5BA1\u6279\u670D\u52A1\u5DF2\u5173\u95ED");
|
|
1470
|
+
resolve();
|
|
1471
|
+
}
|
|
1472
|
+
});
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
// ============================================
|
|
1476
|
+
// 内部方法
|
|
1477
|
+
// ============================================
|
|
1478
|
+
/** 路由请求 */
|
|
1479
|
+
handleRequest(req, res) {
|
|
1480
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
1481
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
1482
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
1483
|
+
if (req.method === "OPTIONS") {
|
|
1484
|
+
res.writeHead(204);
|
|
1485
|
+
res.end();
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
1489
|
+
const pathname = url.pathname;
|
|
1490
|
+
if (req.method === "POST" && pathname === "/hook/approval") {
|
|
1491
|
+
this.handleApprovalHook(req, res);
|
|
1492
|
+
} else if (req.method === "GET" && pathname === "/health") {
|
|
1493
|
+
this.handleHealth(req, res);
|
|
1494
|
+
} else if (req.method === "GET" && pathname === "/token") {
|
|
1495
|
+
this.handleToken(req, res);
|
|
1496
|
+
} else {
|
|
1497
|
+
this.sendJson(res, 404, { error: "Not Found" });
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* 核心端点:处理 Claude Code hook 的审批请求
|
|
1502
|
+
*
|
|
1503
|
+
* 长轮询实现:
|
|
1504
|
+
* 1. 解析请求 body
|
|
1505
|
+
* 2. 创建 ApprovalRequest 对象
|
|
1506
|
+
* 3. 通知外部(推到手机)
|
|
1507
|
+
* 4. 创建 Promise 并 hold 住 response
|
|
1508
|
+
* 5. 等待 resolveApproval() 被调用或超时
|
|
1509
|
+
*/
|
|
1510
|
+
async handleApprovalHook(req, res) {
|
|
1511
|
+
try {
|
|
1512
|
+
const body = await this.parseJsonBody(req);
|
|
1513
|
+
const payload = body.payload ?? body;
|
|
1514
|
+
const requestId = (0, import_uuid3.v4)();
|
|
1515
|
+
const projectPath = String(body.projectPath ?? "unknown");
|
|
1516
|
+
const toolName = String(payload.tool_name ?? body.tool_name ?? "unknown");
|
|
1517
|
+
const toolInput = payload.tool_input ?? body.tool_input ?? {};
|
|
1518
|
+
const approvalRequest = {
|
|
1519
|
+
id: requestId,
|
|
1520
|
+
sessionId: String(body.sessionId ?? "unknown"),
|
|
1521
|
+
projectPath,
|
|
1522
|
+
toolName,
|
|
1523
|
+
toolInput,
|
|
1524
|
+
description: String(payload.description ?? body.description ?? `${toolName} \u5DE5\u5177\u8C03\u7528\u8BF7\u6C42`),
|
|
1525
|
+
createdAt: Date.now()
|
|
1526
|
+
};
|
|
1527
|
+
console.log(`[ApprovalProxy] \u6536\u5230\u5BA1\u6279\u8BF7\u6C42: ${requestId} (${approvalRequest.toolName})`);
|
|
1528
|
+
if (this.isToolAlwaysAllowed(approvalRequest.toolName, projectPath !== "unknown" ? projectPath : void 0)) {
|
|
1529
|
+
console.log(`[ApprovalProxy] ${approvalRequest.toolName} \u5DF2\u88AB\u59CB\u7EC8\u5141\u8BB8\uFF0C\u76F4\u63A5\u653E\u884C\uFF08\u4E0D\u901A\u77E5\uFF09`);
|
|
1530
|
+
this.sendJson(res, 200, { decision: "allow" });
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
if (this.yoloSessions.get(approvalRequest.sessionId)) {
|
|
1534
|
+
console.log(`[ApprovalProxy] YOLO \u6A21\u5F0F\uFF0C\u81EA\u52A8\u653E\u884C: ${approvalRequest.toolName}`);
|
|
1535
|
+
this.sendJson(res, 200, { decision: "allow" });
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
this.notifyApprovalRequest(approvalRequest);
|
|
1539
|
+
const decision = await new Promise((resolve) => {
|
|
1540
|
+
const timer = setTimeout(() => {
|
|
1541
|
+
console.log(`[ApprovalProxy] \u5BA1\u6279\u8BF7\u6C42 ${requestId} \u5DF2\u8D85\u65F6\uFF0C\u9ED8\u8BA4\u5141\u8BB8`);
|
|
1542
|
+
this.pendingApprovals.delete(requestId);
|
|
1543
|
+
resolve({ decision: "allow" });
|
|
1544
|
+
}, 325e3);
|
|
1545
|
+
this.pendingApprovals.set(requestId, { resolve, timer, request: approvalRequest });
|
|
1546
|
+
});
|
|
1547
|
+
this.sendJson(res, 200, decision);
|
|
1548
|
+
} catch (err) {
|
|
1549
|
+
console.error("[ApprovalProxy] \u5904\u7406\u5BA1\u6279\u8BF7\u6C42\u5931\u8D25:", err);
|
|
1550
|
+
this.sendJson(res, 200, { decision: "deny", reason: "\u670D\u52A1\u5668\u5904\u7406\u8BF7\u6C42\u5931\u8D25" });
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
/** 健康检查端点 */
|
|
1554
|
+
handleHealth(_req, res) {
|
|
1555
|
+
const info = this.statusInfoProvider?.() ?? { connections: 0, activeSessions: 0 };
|
|
1556
|
+
this.sendJson(res, 200, {
|
|
1557
|
+
status: "ok",
|
|
1558
|
+
connections: info.connections,
|
|
1559
|
+
activeSessions: info.activeSessions
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
/** 返回连接 token(仅本机访问) */
|
|
1563
|
+
handleToken(req, res) {
|
|
1564
|
+
const remoteAddress = req.socket.remoteAddress;
|
|
1565
|
+
const isLocal = remoteAddress === "127.0.0.1" || remoteAddress === "::1" || remoteAddress === "::ffff:127.0.0.1";
|
|
1566
|
+
if (!isLocal) {
|
|
1567
|
+
this.sendJson(res, 403, { error: "Forbidden: \u4EC5\u5141\u8BB8\u672C\u673A\u8BBF\u95EE" });
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
this.sendJson(res, 200, { token: this.token });
|
|
1571
|
+
}
|
|
1572
|
+
/** 通知所有注册的审批请求回调 */
|
|
1573
|
+
notifyApprovalRequest(request) {
|
|
1574
|
+
for (const callback of this.approvalRequestCallbacks) {
|
|
1575
|
+
try {
|
|
1576
|
+
callback(request);
|
|
1577
|
+
} catch (err) {
|
|
1578
|
+
console.error("[ApprovalProxy] \u5BA1\u6279\u8BF7\u6C42\u56DE\u8C03\u6267\u884C\u5F02\u5E38:", err);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
/** 手动解析请求的 JSON body(限制最大 1MB 防止滥用) */
|
|
1583
|
+
parseJsonBody(req) {
|
|
1584
|
+
const MAX_BODY_SIZE = 1024 * 1024;
|
|
1585
|
+
return new Promise((resolve, reject) => {
|
|
1586
|
+
const chunks = [];
|
|
1587
|
+
let totalSize = 0;
|
|
1588
|
+
let destroyed = false;
|
|
1589
|
+
req.on("data", (chunk) => {
|
|
1590
|
+
if (destroyed) return;
|
|
1591
|
+
totalSize += chunk.length;
|
|
1592
|
+
if (totalSize > MAX_BODY_SIZE) {
|
|
1593
|
+
destroyed = true;
|
|
1594
|
+
req.destroy();
|
|
1595
|
+
return reject(new Error("\u8BF7\u6C42 body \u8FC7\u5927\uFF08\u8D85\u8FC7 1MB\uFF09"));
|
|
1596
|
+
}
|
|
1597
|
+
chunks.push(chunk);
|
|
1598
|
+
});
|
|
1599
|
+
req.on("end", () => {
|
|
1600
|
+
try {
|
|
1601
|
+
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
1602
|
+
const parsed = JSON.parse(raw);
|
|
1603
|
+
resolve(parsed);
|
|
1604
|
+
} catch {
|
|
1605
|
+
reject(new Error("\u65E0\u6548\u7684 JSON body"));
|
|
1606
|
+
}
|
|
1607
|
+
});
|
|
1608
|
+
req.on("error", (err) => {
|
|
1609
|
+
reject(err);
|
|
1610
|
+
});
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
/** 发送 JSON 响应的辅助方法 */
|
|
1614
|
+
sendJson(res, statusCode, data) {
|
|
1615
|
+
const body = JSON.stringify(data);
|
|
1616
|
+
res.writeHead(statusCode, {
|
|
1617
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
1618
|
+
"Content-Length": Buffer.byteLength(body)
|
|
1619
|
+
});
|
|
1620
|
+
res.end(body);
|
|
1621
|
+
}
|
|
1622
|
+
};
|
|
1623
|
+
|
|
1624
|
+
// src/mdns/MdnsService.ts
|
|
1625
|
+
var import_bonjour_service = __toESM(require("bonjour-service"));
|
|
1626
|
+
var MdnsService = class {
|
|
1627
|
+
bonjour = null;
|
|
1628
|
+
service = null;
|
|
1629
|
+
wsPort;
|
|
1630
|
+
httpPort;
|
|
1631
|
+
version;
|
|
1632
|
+
constructor(options) {
|
|
1633
|
+
this.wsPort = options.wsPort;
|
|
1634
|
+
this.httpPort = options.httpPort;
|
|
1635
|
+
this.version = options.version ?? "0.1.0";
|
|
1636
|
+
}
|
|
1637
|
+
/**
|
|
1638
|
+
* 启动 mDNS 广播
|
|
1639
|
+
*/
|
|
1640
|
+
start() {
|
|
1641
|
+
if (this.bonjour) {
|
|
1642
|
+
console.warn("[MdnsService] \u670D\u52A1\u5DF2\u5728\u8FD0\u884C\u4E2D");
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
this.bonjour = new import_bonjour_service.default();
|
|
1646
|
+
this.service = this.bonjour.publish({
|
|
1647
|
+
name: "Sessix",
|
|
1648
|
+
type: "sessix",
|
|
1649
|
+
port: this.wsPort,
|
|
1650
|
+
txt: {
|
|
1651
|
+
version: this.version,
|
|
1652
|
+
httpPort: String(this.httpPort)
|
|
1653
|
+
}
|
|
1654
|
+
});
|
|
1655
|
+
console.log(`[MdnsService] mDNS \u5E7F\u64AD\u5DF2\u542F\u52A8: _sessix._tcp \u7AEF\u53E3 ${this.wsPort}`);
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* 停止 mDNS 广播
|
|
1659
|
+
*/
|
|
1660
|
+
stop() {
|
|
1661
|
+
if (this.service) {
|
|
1662
|
+
this.service.stop?.(() => {
|
|
1663
|
+
console.log("[MdnsService] \u670D\u52A1\u5E7F\u64AD\u5DF2\u505C\u6B62");
|
|
1664
|
+
});
|
|
1665
|
+
this.service = null;
|
|
1666
|
+
}
|
|
1667
|
+
if (this.bonjour) {
|
|
1668
|
+
this.bonjour.destroy();
|
|
1669
|
+
this.bonjour = null;
|
|
1670
|
+
}
|
|
1671
|
+
console.log("[MdnsService] mDNS \u670D\u52A1\u5DF2\u5173\u95ED");
|
|
1672
|
+
}
|
|
1673
|
+
};
|
|
1674
|
+
|
|
1675
|
+
// src/hooks/HookInstaller.ts
|
|
1676
|
+
var import_promises2 = require("fs/promises");
|
|
1677
|
+
var import_node_path2 = require("path");
|
|
1678
|
+
var import_node_os3 = require("os");
|
|
1679
|
+
var SESSIX_HOOKS_DIR = (0, import_node_path2.join)((0, import_node_os3.homedir)(), ".sessix", "hooks");
|
|
1680
|
+
var HOOK_SCRIPT_PATH = (0, import_node_path2.join)(SESSIX_HOOKS_DIR, "approval-hook.sh");
|
|
1681
|
+
var PERMISSION_ACCEPT_PATH = (0, import_node_path2.join)(SESSIX_HOOKS_DIR, "permission-accept.sh");
|
|
1682
|
+
var CLAUDE_SETTINGS_PATH = (0, import_node_path2.join)((0, import_node_os3.homedir)(), ".claude", "settings.json");
|
|
1683
|
+
var HOOK_COMMAND = "~/.sessix/hooks/approval-hook.sh";
|
|
1684
|
+
var PERMISSION_ACCEPT_COMMAND = "~/.sessix/hooks/permission-accept.sh";
|
|
1685
|
+
var HOOK_SCRIPT_TEMPLATE = `#!/bin/bash
|
|
1686
|
+
# Sessix Approval Hook
|
|
1687
|
+
# \u4EC5\u5728 Sessix \u7BA1\u7406\u7684\u4F1A\u8BDD\u4E2D\u6FC0\u6D3B
|
|
1688
|
+
|
|
1689
|
+
if [ -z "$SESSIX_SESSION_ID" ]; then
|
|
1690
|
+
exit 0
|
|
1691
|
+
fi
|
|
1692
|
+
|
|
1693
|
+
# \u4ECE stdin \u8BFB\u53D6 hook payload
|
|
1694
|
+
PAYLOAD=$(cat)
|
|
1695
|
+
|
|
1696
|
+
# \u83B7\u53D6\u9879\u76EE\u8DEF\u5F84\uFF08\u5F53\u524D\u5DE5\u4F5C\u76EE\u5F55\uFF09
|
|
1697
|
+
PROJECT_PATH="$PWD"
|
|
1698
|
+
|
|
1699
|
+
# \u53D1\u9001\u5BA1\u6279\u8BF7\u6C42\u5230 Sessix \u670D\u52A1\u5668\uFF08\u957F\u8F6E\u8BE2\uFF0C\u8D85\u65F6\u65F6\u95F4 > 300s\uFF09
|
|
1700
|
+
RESPONSE=$(curl -s -X POST "http://localhost:3746/hook/approval" \\
|
|
1701
|
+
-H "Content-Type: application/json" \\
|
|
1702
|
+
-d "{\\"sessionId\\": \\"$SESSIX_SESSION_ID\\", \\"projectPath\\": \\"$PROJECT_PATH\\", \\"payload\\": $PAYLOAD}" \\
|
|
1703
|
+
--max-time 330 \\
|
|
1704
|
+
2>/dev/null)
|
|
1705
|
+
|
|
1706
|
+
if [ $? -ne 0 ] || [ -z "$RESPONSE" ]; then
|
|
1707
|
+
# \u5982\u679C Sessix \u670D\u52A1\u5668\u4E0D\u53EF\u7528\uFF0C\u9ED8\u8BA4\u653E\u884C\uFF08exit 0 = \u6279\u51C6\uFF09
|
|
1708
|
+
exit 0
|
|
1709
|
+
fi
|
|
1710
|
+
|
|
1711
|
+
# \u89E3\u6790\u670D\u52A1\u5668\u54CD\u5E94
|
|
1712
|
+
DECISION=$(echo "$RESPONSE" | grep -o '\\"decision\\":\\"[^"]*\\"' | cut -d'"' -f4)
|
|
1713
|
+
|
|
1714
|
+
if [ "$DECISION" = "allow" ]; then
|
|
1715
|
+
# \u7528\u6237\u6279\u51C6\u6216\u670D\u52A1\u5668\u8D85\u65F6\u81EA\u52A8\u6279\u51C6
|
|
1716
|
+
exit 0
|
|
1717
|
+
elif [ "$DECISION" = "deny" ]; then
|
|
1718
|
+
# \u7528\u6237\u660E\u786E\u62D2\u7EDD
|
|
1719
|
+
exit 1
|
|
1720
|
+
else
|
|
1721
|
+
# \u672A\u77E5\u54CD\u5E94\uFF0C\u9ED8\u8BA4\u653E\u884C
|
|
1722
|
+
exit 0
|
|
1723
|
+
fi
|
|
1724
|
+
`;
|
|
1725
|
+
var PERMISSION_ACCEPT_TEMPLATE = `#!/bin/bash
|
|
1726
|
+
# Sessix PermissionRequest \u515C\u5E95
|
|
1727
|
+
# \u81EA\u52A8\u63A5\u53D7\u6743\u9650\u8BF7\u6C42\uFF0C\u907F\u514D Sessix \u4F1A\u8BDD\u963B\u585E
|
|
1728
|
+
|
|
1729
|
+
if [ -z "$SESSIX_SESSION_ID" ]; then
|
|
1730
|
+
exit 0
|
|
1731
|
+
fi
|
|
1732
|
+
|
|
1733
|
+
# \u8F93\u51FA JSON \u51B3\u7B56\uFF0C\u81EA\u52A8\u63A5\u53D7\u6743\u9650\u8BF7\u6C42
|
|
1734
|
+
echo '{"decision":"allow"}'
|
|
1735
|
+
exit 0
|
|
1736
|
+
`;
|
|
1737
|
+
var HookInstaller = class {
|
|
1738
|
+
/**
|
|
1739
|
+
* 安装 hook
|
|
1740
|
+
*
|
|
1741
|
+
* 1. 创建 ~/.sessix/hooks/ 目录
|
|
1742
|
+
* 2. 写入 approval-hook.sh 脚本
|
|
1743
|
+
* 3. 赋予执行权限
|
|
1744
|
+
* 4. 更新 Claude Code settings.json 添加 hook 配置
|
|
1745
|
+
*/
|
|
1746
|
+
async install() {
|
|
1747
|
+
await (0, import_promises2.mkdir)(SESSIX_HOOKS_DIR, { recursive: true });
|
|
1748
|
+
await (0, import_promises2.writeFile)(HOOK_SCRIPT_PATH, HOOK_SCRIPT_TEMPLATE, "utf-8");
|
|
1749
|
+
await (0, import_promises2.writeFile)(PERMISSION_ACCEPT_PATH, PERMISSION_ACCEPT_TEMPLATE, "utf-8");
|
|
1750
|
+
await (0, import_promises2.chmod)(HOOK_SCRIPT_PATH, 493);
|
|
1751
|
+
await (0, import_promises2.chmod)(PERMISSION_ACCEPT_PATH, 493);
|
|
1752
|
+
await this.addHookToSettings();
|
|
1753
|
+
console.log("[HookInstaller] Hook \u5B89\u88C5\u5B8C\u6210");
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1756
|
+
* 卸载 hook
|
|
1757
|
+
*
|
|
1758
|
+
* 从 Claude Code settings.json 中移除 Sessix hook 配置。
|
|
1759
|
+
* 注意:不删除 hook 脚本文件(保持幂等性,避免误删)。
|
|
1760
|
+
*/
|
|
1761
|
+
async uninstall() {
|
|
1762
|
+
await this.removeHookFromSettings();
|
|
1763
|
+
console.log("[HookInstaller] Hook \u5DF2\u5378\u8F7D");
|
|
1764
|
+
}
|
|
1765
|
+
/**
|
|
1766
|
+
* 检查 hook 是否已安装
|
|
1767
|
+
* 脚本文件和 settings.json 配置必须同时存在才算已安装
|
|
1768
|
+
*/
|
|
1769
|
+
async isInstalled() {
|
|
1770
|
+
let approvalScriptExists = false;
|
|
1771
|
+
let permissionScriptExists = false;
|
|
1772
|
+
try {
|
|
1773
|
+
await (0, import_promises2.access)(HOOK_SCRIPT_PATH);
|
|
1774
|
+
approvalScriptExists = true;
|
|
1775
|
+
} catch {
|
|
1776
|
+
}
|
|
1777
|
+
try {
|
|
1778
|
+
await (0, import_promises2.access)(PERMISSION_ACCEPT_PATH);
|
|
1779
|
+
permissionScriptExists = true;
|
|
1780
|
+
} catch {
|
|
1781
|
+
}
|
|
1782
|
+
const settings = await this.readClaudeSettings();
|
|
1783
|
+
const configExists = this.hasHookConfig(settings);
|
|
1784
|
+
return approvalScriptExists && permissionScriptExists && configExists;
|
|
1785
|
+
}
|
|
1786
|
+
// ============================================
|
|
1787
|
+
// 内部方法
|
|
1788
|
+
// ============================================
|
|
1789
|
+
/**
|
|
1790
|
+
* 向 Claude Code settings.json 添加 Sessix hook 配置
|
|
1791
|
+
*/
|
|
1792
|
+
async addHookToSettings() {
|
|
1793
|
+
let settings = await this.readClaudeSettings();
|
|
1794
|
+
let changed = false;
|
|
1795
|
+
if (!settings.hooks) {
|
|
1796
|
+
settings.hooks = {};
|
|
1797
|
+
}
|
|
1798
|
+
if (!this.hasPreToolUseConfig(settings)) {
|
|
1799
|
+
if (!settings.hooks.PreToolUse) {
|
|
1800
|
+
settings.hooks.PreToolUse = [];
|
|
1801
|
+
}
|
|
1802
|
+
settings.hooks.PreToolUse.push({
|
|
1803
|
+
matcher: "",
|
|
1804
|
+
hooks: [{ type: "command", command: HOOK_COMMAND }]
|
|
1805
|
+
});
|
|
1806
|
+
changed = true;
|
|
1807
|
+
}
|
|
1808
|
+
if (!this.hasPermissionRequestConfig(settings)) {
|
|
1809
|
+
if (!settings.hooks.PermissionRequest) {
|
|
1810
|
+
settings.hooks.PermissionRequest = [];
|
|
1811
|
+
}
|
|
1812
|
+
settings.hooks.PermissionRequest.push({
|
|
1813
|
+
matcher: "",
|
|
1814
|
+
hooks: [{ type: "command", command: PERMISSION_ACCEPT_COMMAND }]
|
|
1815
|
+
});
|
|
1816
|
+
changed = true;
|
|
1817
|
+
}
|
|
1818
|
+
if (changed) {
|
|
1819
|
+
await this.writeClaudeSettings(settings);
|
|
1820
|
+
} else {
|
|
1821
|
+
console.log("[HookInstaller] Hook \u914D\u7F6E\u5DF2\u5B58\u5728\uFF0C\u8DF3\u8FC7");
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
/**
|
|
1825
|
+
* 从 Claude Code settings.json 移除 Sessix hook 配置
|
|
1826
|
+
*/
|
|
1827
|
+
async removeHookFromSettings() {
|
|
1828
|
+
let settings = await this.readClaudeSettings();
|
|
1829
|
+
if (!settings.hooks) return;
|
|
1830
|
+
this.removeHookCommand(settings, "PreToolUse", HOOK_COMMAND);
|
|
1831
|
+
this.removeHookCommand(settings, "PermissionRequest", PERMISSION_ACCEPT_COMMAND);
|
|
1832
|
+
if (Object.keys(settings.hooks).length === 0) {
|
|
1833
|
+
delete settings.hooks;
|
|
1834
|
+
}
|
|
1835
|
+
await this.writeClaudeSettings(settings);
|
|
1836
|
+
}
|
|
1837
|
+
/** 从指定 hook 事件数组中移除包含指定命令的条目 */
|
|
1838
|
+
removeHookCommand(settings, event, command) {
|
|
1839
|
+
if (!Array.isArray(settings.hooks?.[event])) return;
|
|
1840
|
+
settings.hooks[event] = settings.hooks[event].filter(
|
|
1841
|
+
(entry) => !entry?.hooks?.some?.((h) => h.type === "command" && h.command === command)
|
|
1842
|
+
);
|
|
1843
|
+
if (settings.hooks[event].length === 0) {
|
|
1844
|
+
delete settings.hooks[event];
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
/**
|
|
1848
|
+
* 读取 Claude Code settings.json
|
|
1849
|
+
*/
|
|
1850
|
+
async readClaudeSettings() {
|
|
1851
|
+
try {
|
|
1852
|
+
const content = await (0, import_promises2.readFile)(CLAUDE_SETTINGS_PATH, "utf-8");
|
|
1853
|
+
return JSON.parse(content);
|
|
1854
|
+
} catch {
|
|
1855
|
+
return {};
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
/**
|
|
1859
|
+
* 写入 Claude Code settings.json
|
|
1860
|
+
*/
|
|
1861
|
+
async writeClaudeSettings(settings) {
|
|
1862
|
+
await (0, import_promises2.mkdir)((0, import_node_path2.join)((0, import_node_os3.homedir)(), ".claude"), { recursive: true });
|
|
1863
|
+
await (0, import_promises2.writeFile)(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
1864
|
+
}
|
|
1865
|
+
/**
|
|
1866
|
+
* 检查 settings 中是否已包含所有 Sessix hook 配置
|
|
1867
|
+
*/
|
|
1868
|
+
hasHookConfig(settings) {
|
|
1869
|
+
return this.hasPreToolUseConfig(settings) && this.hasPermissionRequestConfig(settings);
|
|
1870
|
+
}
|
|
1871
|
+
/** 检查 PreToolUse 中是否有 approval-hook.sh */
|
|
1872
|
+
hasPreToolUseConfig(settings) {
|
|
1873
|
+
return this.hasHookEntry(settings?.hooks?.PreToolUse, HOOK_COMMAND);
|
|
1874
|
+
}
|
|
1875
|
+
/** 检查 PermissionRequest 中是否有 permission-accept.sh */
|
|
1876
|
+
hasPermissionRequestConfig(settings) {
|
|
1877
|
+
return this.hasHookEntry(settings?.hooks?.PermissionRequest, PERMISSION_ACCEPT_COMMAND);
|
|
1878
|
+
}
|
|
1879
|
+
/** 检查 hook 数组中是否包含指定命令 */
|
|
1880
|
+
hasHookEntry(hookArray, command) {
|
|
1881
|
+
if (!Array.isArray(hookArray)) return false;
|
|
1882
|
+
return hookArray.some(
|
|
1883
|
+
(entry) => entry?.hooks?.some?.((hook) => hook.type === "command" && hook.command === command)
|
|
1884
|
+
);
|
|
1885
|
+
}
|
|
1886
|
+
};
|
|
1887
|
+
|
|
1888
|
+
// src/notification/NotificationService.ts
|
|
1889
|
+
var import_node_path3 = require("path");
|
|
1890
|
+
var NotificationService = class {
|
|
1891
|
+
constructor(sessionManager, expoChannel = null) {
|
|
1892
|
+
this.sessionManager = sessionManager;
|
|
1893
|
+
this.expoChannel = expoChannel;
|
|
1894
|
+
this.unsubscribe = sessionManager.onEvent((event) => this.handleEvent(event));
|
|
1895
|
+
if (expoChannel) {
|
|
1896
|
+
this.channelMap.set("expo", { channel: expoChannel, enabled: true });
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
channelMap = /* @__PURE__ */ new Map();
|
|
1900
|
+
unsubscribe = null;
|
|
1901
|
+
activityPushChannel = null;
|
|
1902
|
+
/** YOLO 模式状态映射:sessionId -> isYoloMode */
|
|
1903
|
+
yoloModeState = /* @__PURE__ */ new Map();
|
|
1904
|
+
/** 每个会话的最新 assistant 文本消息(用于通知正文预览) */
|
|
1905
|
+
latestAssistantText = /* @__PURE__ */ new Map();
|
|
1906
|
+
/** 添加通知渠道(id 唯一,可用于后续动态开关) */
|
|
1907
|
+
addChannel(id, channel, enabled = true) {
|
|
1908
|
+
this.channelMap.set(id, { channel, enabled });
|
|
1909
|
+
}
|
|
1910
|
+
/** 运行时切换指定渠道的启用状态 */
|
|
1911
|
+
setChannelEnabled(id, enabled) {
|
|
1912
|
+
const entry = this.channelMap.get(id);
|
|
1913
|
+
if (entry) entry.enabled = enabled;
|
|
1914
|
+
}
|
|
1915
|
+
/** 注册手机 push token(连接建立时由 WsBridge 调用) */
|
|
1916
|
+
addPushToken(token) {
|
|
1917
|
+
this.expoChannel?.addToken(token);
|
|
1918
|
+
}
|
|
1919
|
+
/** 移除手机 push token(断线时或手机主动注销时调用) */
|
|
1920
|
+
removePushToken(token) {
|
|
1921
|
+
this.expoChannel?.removeToken(token);
|
|
1922
|
+
}
|
|
1923
|
+
/** 更新通知音效偏好 */
|
|
1924
|
+
setSoundPreferences(prefs) {
|
|
1925
|
+
this.expoChannel?.setSoundPreferences(prefs);
|
|
1926
|
+
}
|
|
1927
|
+
/** 设置 ActivityKit Push 渠道(可选,需要 APNs 认证配置) */
|
|
1928
|
+
setActivityPushChannel(channel) {
|
|
1929
|
+
this.activityPushChannel = channel;
|
|
1930
|
+
}
|
|
1931
|
+
/** 注册 ActivityKit push token(由手机端启动 Live Activity 后上报) */
|
|
1932
|
+
addActivityPushToken(sessionId, token) {
|
|
1933
|
+
this.activityPushChannel?.addToken(sessionId, token);
|
|
1934
|
+
}
|
|
1935
|
+
/** 移除 ActivityKit push token */
|
|
1936
|
+
removeActivityPushToken(sessionId) {
|
|
1937
|
+
this.activityPushChannel?.removeToken(sessionId);
|
|
1938
|
+
}
|
|
1939
|
+
/** 更新会话的 YOLO 模式状态 */
|
|
1940
|
+
setYoloMode(sessionId, enabled) {
|
|
1941
|
+
this.yoloModeState.set(sessionId, enabled);
|
|
1942
|
+
}
|
|
1943
|
+
/** 直接触发审批通知(由 ApprovalProxy 回调调用) */
|
|
1944
|
+
notifyApproval(request, pendingCount) {
|
|
1945
|
+
if (this.yoloModeState.get(request.sessionId)) return;
|
|
1946
|
+
const sessionTitle = this.getSessionTitle(request.sessionId);
|
|
1947
|
+
const title = pendingCount > 1 ? `${sessionTitle} \u2014 ${pendingCount} \u9879\u5F85\u5BA1\u6279` : sessionTitle;
|
|
1948
|
+
const body = pendingCount > 1 ? `\u{1F527} \u6700\u65B0: ${request.toolName}: ${request.description}` : `\u{1F527} ${request.toolName}: ${request.description}`;
|
|
1949
|
+
if (this.activityPushChannel?.hasToken(request.sessionId)) {
|
|
1950
|
+
const dangerLevel = this.getDangerLevel(request.toolName);
|
|
1951
|
+
const isYoloMode = this.getYoloMode(request.sessionId);
|
|
1952
|
+
this.activityPushChannel.updateActivityWithAlert(
|
|
1953
|
+
request.sessionId,
|
|
1954
|
+
{
|
|
1955
|
+
status: "waitingApproval",
|
|
1956
|
+
sessionTitle,
|
|
1957
|
+
latestMessage: `${request.toolName}: ${request.description}`,
|
|
1958
|
+
approvalInfo: {
|
|
1959
|
+
requestId: request.id,
|
|
1960
|
+
toolName: request.toolName,
|
|
1961
|
+
description: request.description.slice(0, 80),
|
|
1962
|
+
dangerLevel,
|
|
1963
|
+
pendingCount
|
|
1964
|
+
},
|
|
1965
|
+
isYoloMode,
|
|
1966
|
+
updatedAt: Date.now()
|
|
1967
|
+
},
|
|
1968
|
+
{ title, body }
|
|
1969
|
+
);
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
this.notify({
|
|
1973
|
+
title,
|
|
1974
|
+
body,
|
|
1975
|
+
sound: "Funk",
|
|
1976
|
+
badge: pendingCount,
|
|
1977
|
+
data: {
|
|
1978
|
+
type: "approval_request",
|
|
1979
|
+
sessionId: request.sessionId,
|
|
1980
|
+
requestId: request.id
|
|
1981
|
+
}
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
/** 简单的工具危险等级判断 */
|
|
1985
|
+
getDangerLevel(toolName) {
|
|
1986
|
+
if (toolName === "Bash") return "danger";
|
|
1987
|
+
if (["Write", "Edit", "NotebookEdit"].includes(toolName)) return "write";
|
|
1988
|
+
return "safe";
|
|
1989
|
+
}
|
|
1990
|
+
/** 清理资源 */
|
|
1991
|
+
destroy() {
|
|
1992
|
+
this.unsubscribe?.();
|
|
1993
|
+
this.unsubscribe = null;
|
|
1994
|
+
this.yoloModeState.clear();
|
|
1995
|
+
this.latestAssistantText.clear();
|
|
1996
|
+
}
|
|
1997
|
+
// ============================================
|
|
1998
|
+
// 内部方法
|
|
1999
|
+
// ============================================
|
|
2000
|
+
handleEvent(event) {
|
|
2001
|
+
switch (event.type) {
|
|
2002
|
+
case "claude_event": {
|
|
2003
|
+
this.trackAssistantText(event.sessionId, event.event);
|
|
2004
|
+
break;
|
|
2005
|
+
}
|
|
2006
|
+
case "claude_events": {
|
|
2007
|
+
for (const e of event.events) {
|
|
2008
|
+
this.trackAssistantText(event.sessionId, e);
|
|
2009
|
+
}
|
|
2010
|
+
break;
|
|
2011
|
+
}
|
|
2012
|
+
case "status_change": {
|
|
2013
|
+
if (event.status === "idle") {
|
|
2014
|
+
const sessionTitle = this.getSessionTitle(event.sessionId);
|
|
2015
|
+
const latestMsg = this.latestAssistantText.get(event.sessionId);
|
|
2016
|
+
const body = latestMsg ? `\u2705 ${latestMsg.slice(0, 80)}` : "\u5DF2\u5B8C\u6210\uFF0C\u7B49\u5F85\u4E0B\u4E00\u6B65\u6307\u4EE4";
|
|
2017
|
+
const isYoloMode = this.getYoloMode(event.sessionId);
|
|
2018
|
+
if (this.activityPushChannel?.hasToken(event.sessionId)) {
|
|
2019
|
+
this.activityPushChannel.endActivity(event.sessionId, {
|
|
2020
|
+
status: "idle",
|
|
2021
|
+
sessionTitle,
|
|
2022
|
+
latestMessage: body,
|
|
2023
|
+
isYoloMode,
|
|
2024
|
+
updatedAt: Date.now()
|
|
2025
|
+
});
|
|
2026
|
+
} else {
|
|
2027
|
+
this.notify({
|
|
2028
|
+
title: sessionTitle,
|
|
2029
|
+
body,
|
|
2030
|
+
sound: "Glass",
|
|
2031
|
+
data: { type: "task_complete", sessionId: event.sessionId }
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
2034
|
+
} else if (event.status === "error") {
|
|
2035
|
+
const sessionTitle = this.getSessionTitle(event.sessionId);
|
|
2036
|
+
const latestMsg = this.latestAssistantText.get(event.sessionId);
|
|
2037
|
+
const body = latestMsg ? `\u274C ${latestMsg.slice(0, 80)}` : "\u6267\u884C\u51FA\u9519\uFF0C\u8BF7\u67E5\u770B\u8BE6\u60C5";
|
|
2038
|
+
const isYoloMode = this.getYoloMode(event.sessionId);
|
|
2039
|
+
if (this.activityPushChannel?.hasToken(event.sessionId)) {
|
|
2040
|
+
this.activityPushChannel.endActivity(event.sessionId, {
|
|
2041
|
+
status: "error",
|
|
2042
|
+
sessionTitle,
|
|
2043
|
+
latestMessage: body,
|
|
2044
|
+
isYoloMode,
|
|
2045
|
+
updatedAt: Date.now()
|
|
2046
|
+
});
|
|
2047
|
+
} else {
|
|
2048
|
+
this.notify({
|
|
2049
|
+
title: sessionTitle,
|
|
2050
|
+
body,
|
|
2051
|
+
sound: "Basso",
|
|
2052
|
+
data: { type: "task_error", sessionId: event.sessionId }
|
|
2053
|
+
});
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
break;
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
notify(payload) {
|
|
2061
|
+
for (const { channel, enabled } of this.channelMap.values()) {
|
|
2062
|
+
if (!enabled) continue;
|
|
2063
|
+
channel.send(payload).catch((err) => {
|
|
2064
|
+
console.error("[NotificationService] \u901A\u77E5\u53D1\u9001\u5931\u8D25:", err);
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
/** 从 assistant 事件中提取最新文本消息 */
|
|
2069
|
+
trackAssistantText(sessionId, event) {
|
|
2070
|
+
if (event.type !== "assistant") return;
|
|
2071
|
+
const textBlocks = event.message.content.filter((b) => b.type === "text");
|
|
2072
|
+
const lastText = textBlocks[textBlocks.length - 1];
|
|
2073
|
+
if (lastText && lastText.type === "text" && lastText.text.trim()) {
|
|
2074
|
+
this.latestAssistantText.set(sessionId, lastText.text.trim());
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
/** 获取会话标题:优先 summary,fallback 到项目名 */
|
|
2078
|
+
getSessionTitle(sessionId) {
|
|
2079
|
+
const session = this.sessionManager.getActiveSessions().find((s) => s.id === sessionId);
|
|
2080
|
+
if (!session) return "Unknown";
|
|
2081
|
+
return session.summary ?? (0, import_node_path3.basename)(session.projectPath);
|
|
2082
|
+
}
|
|
2083
|
+
/** 获取会话的 YOLO 模式状态 */
|
|
2084
|
+
getYoloMode(sessionId) {
|
|
2085
|
+
return this.yoloModeState.get(sessionId) ?? false;
|
|
2086
|
+
}
|
|
2087
|
+
};
|
|
2088
|
+
|
|
2089
|
+
// src/notification/MacNotificationChannel.ts
|
|
2090
|
+
var import_node_child_process = require("child_process");
|
|
2091
|
+
var MacNotificationChannel = class {
|
|
2092
|
+
isAvailable() {
|
|
2093
|
+
return process.platform === "darwin";
|
|
2094
|
+
}
|
|
2095
|
+
send(payload) {
|
|
2096
|
+
if (!this.isAvailable()) return Promise.resolve();
|
|
2097
|
+
const title = payload.title.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
2098
|
+
const body = payload.body.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
2099
|
+
const sound = payload.sound ?? "Ping";
|
|
2100
|
+
const script = `display notification "${body}" with title "${title}" sound name "${sound}"`;
|
|
2101
|
+
return new Promise((resolve) => {
|
|
2102
|
+
(0, import_node_child_process.execFile)("osascript", ["-e", script], (err) => {
|
|
2103
|
+
if (err) {
|
|
2104
|
+
console.warn("[MacNotificationChannel] \u53D1\u9001\u901A\u77E5\u5931\u8D25:", err.message);
|
|
2105
|
+
}
|
|
2106
|
+
resolve();
|
|
2107
|
+
});
|
|
2108
|
+
});
|
|
2109
|
+
}
|
|
2110
|
+
};
|
|
2111
|
+
|
|
2112
|
+
// src/notification/ExpoNotificationChannel.ts
|
|
2113
|
+
var EXPO_PUSH_API = "https://exp.host/--/api/v2/push/send";
|
|
2114
|
+
var ExpoNotificationChannel = class {
|
|
2115
|
+
tokens = /* @__PURE__ */ new Set();
|
|
2116
|
+
/** per-token 通知音效偏好 */
|
|
2117
|
+
soundPreferences = /* @__PURE__ */ new Map();
|
|
2118
|
+
isAvailable() {
|
|
2119
|
+
return this.tokens.size > 0;
|
|
2120
|
+
}
|
|
2121
|
+
addToken(token) {
|
|
2122
|
+
this.tokens.add(token);
|
|
2123
|
+
console.log(`[ExpoNotificationChannel] \u5DF2\u6CE8\u518C push token\uFF0C\u5F53\u524D\u8BBE\u5907\u6570: ${this.tokens.size}`);
|
|
2124
|
+
}
|
|
2125
|
+
removeToken(token) {
|
|
2126
|
+
this.tokens.delete(token);
|
|
2127
|
+
this.soundPreferences.delete(token);
|
|
2128
|
+
console.log(`[ExpoNotificationChannel] \u5DF2\u79FB\u9664 push token\uFF0C\u5F53\u524D\u8BBE\u5907\u6570: ${this.tokens.size}`);
|
|
2129
|
+
}
|
|
2130
|
+
/** 更新某个 token 的音效偏好 */
|
|
2131
|
+
setSoundPreferences(prefs) {
|
|
2132
|
+
for (const token of this.tokens) {
|
|
2133
|
+
this.soundPreferences.set(token, prefs);
|
|
2134
|
+
}
|
|
2135
|
+
console.log("[ExpoNotificationChannel] \u5DF2\u66F4\u65B0\u97F3\u6548\u504F\u597D");
|
|
2136
|
+
}
|
|
2137
|
+
async send(payload) {
|
|
2138
|
+
if (this.tokens.size === 0) return;
|
|
2139
|
+
const messages = Array.from(this.tokens).map((to) => {
|
|
2140
|
+
let sound = payload.sound ?? "default";
|
|
2141
|
+
const prefs = this.soundPreferences.get(to);
|
|
2142
|
+
if (prefs) {
|
|
2143
|
+
const notifType = payload.data?.type ?? "";
|
|
2144
|
+
if (notifType === "approval_request" && prefs.approval) sound = prefs.approval;
|
|
2145
|
+
else if (notifType === "task_complete" && prefs.taskComplete) sound = prefs.taskComplete;
|
|
2146
|
+
else if (notifType === "task_error" && prefs.taskError) sound = prefs.taskError;
|
|
2147
|
+
}
|
|
2148
|
+
return {
|
|
2149
|
+
to,
|
|
2150
|
+
title: payload.title,
|
|
2151
|
+
body: payload.body,
|
|
2152
|
+
badge: payload.badge,
|
|
2153
|
+
sound: sound === "none" ? null : sound,
|
|
2154
|
+
data: payload.data ?? {}
|
|
2155
|
+
};
|
|
2156
|
+
});
|
|
2157
|
+
try {
|
|
2158
|
+
console.log("[ExpoNotificationChannel] \u53D1\u9001\u63A8\u9001\uFF0Ctokens:", Array.from(this.tokens));
|
|
2159
|
+
const res = await fetch(EXPO_PUSH_API, {
|
|
2160
|
+
method: "POST",
|
|
2161
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
2162
|
+
body: JSON.stringify(messages)
|
|
2163
|
+
});
|
|
2164
|
+
const body = await res.json();
|
|
2165
|
+
if (!res.ok) {
|
|
2166
|
+
console.warn("[ExpoNotificationChannel] Expo Push API \u8FD4\u56DE\u9519\u8BEF:", res.status, JSON.stringify(body));
|
|
2167
|
+
} else {
|
|
2168
|
+
if (!Array.isArray(body?.data)) {
|
|
2169
|
+
console.warn("[ExpoNotificationChannel] Expo Push API \u54CD\u5E94\u683C\u5F0F\u5F02\u5E38\uFF0C\u7F3A\u5C11 data \u6570\u7EC4:", JSON.stringify(body));
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
for (const ticket of body.data) {
|
|
2173
|
+
if (ticket.status === "error") {
|
|
2174
|
+
console.error(`[ExpoNotificationChannel] \u63A8\u9001\u5931\u8D25: ${ticket.message} (${ticket.details?.error ?? "unknown"})`);
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
} catch (err) {
|
|
2179
|
+
console.warn("[ExpoNotificationChannel] \u53D1\u9001\u63A8\u9001\u5931\u8D25:", err);
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
};
|
|
2183
|
+
|
|
2184
|
+
// src/notification/ActivityPushChannel.ts
|
|
2185
|
+
var http2 = __toESM(require("http2"));
|
|
2186
|
+
var fs2 = __toESM(require("fs"));
|
|
2187
|
+
var crypto = __toESM(require("crypto"));
|
|
2188
|
+
var ActivityPushChannel = class {
|
|
2189
|
+
/** sessionId -> activityPushToken */
|
|
2190
|
+
tokens = /* @__PURE__ */ new Map();
|
|
2191
|
+
teamId;
|
|
2192
|
+
keyId;
|
|
2193
|
+
authKey;
|
|
2194
|
+
apnsHost;
|
|
2195
|
+
/** 缓存的 JWT token + 过期时间 */
|
|
2196
|
+
cachedJwt = null;
|
|
2197
|
+
/** 复用的 HTTP/2 长连接 */
|
|
2198
|
+
http2Client = null;
|
|
2199
|
+
constructor(config) {
|
|
2200
|
+
this.teamId = config.teamId;
|
|
2201
|
+
this.keyId = config.keyId;
|
|
2202
|
+
this.authKey = fs2.readFileSync(config.authKeyPath, "utf-8");
|
|
2203
|
+
this.apnsHost = config.sandbox ? "api.sandbox.push.apple.com" : "api.push.apple.com";
|
|
2204
|
+
console.log(`[ActivityPushChannel] \u5DF2\u521D\u59CB\u5316 (${config.sandbox ? "\u6C99\u7BB1" : "\u751F\u4EA7"}\u6A21\u5F0F)`);
|
|
2205
|
+
}
|
|
2206
|
+
/** 获取或新建 HTTP/2 长连接 */
|
|
2207
|
+
getHttp2Client() {
|
|
2208
|
+
if (this.http2Client && !this.http2Client.destroyed && !this.http2Client.closed) {
|
|
2209
|
+
return this.http2Client;
|
|
2210
|
+
}
|
|
2211
|
+
this.http2Client = http2.connect(`https://${this.apnsHost}`);
|
|
2212
|
+
this.http2Client.on("error", (err) => {
|
|
2213
|
+
console.warn("[ActivityPushChannel] HTTP/2 \u8FDE\u63A5\u9519\u8BEF\uFF0C\u5C06\u5728\u4E0B\u6B21\u8BF7\u6C42\u65F6\u91CD\u5EFA:", err.message);
|
|
2214
|
+
this.http2Client?.destroy();
|
|
2215
|
+
this.http2Client = null;
|
|
2216
|
+
});
|
|
2217
|
+
this.http2Client.on("close", () => {
|
|
2218
|
+
this.http2Client = null;
|
|
2219
|
+
});
|
|
2220
|
+
return this.http2Client;
|
|
2221
|
+
}
|
|
2222
|
+
/** 注册 Activity push token */
|
|
2223
|
+
addToken(sessionId, token) {
|
|
2224
|
+
this.tokens.set(sessionId, token);
|
|
2225
|
+
console.log(`[ActivityPushChannel] \u5DF2\u6CE8\u518C token: session=${sessionId}`);
|
|
2226
|
+
}
|
|
2227
|
+
/** 移除 Activity push token */
|
|
2228
|
+
removeToken(sessionId) {
|
|
2229
|
+
this.tokens.delete(sessionId);
|
|
2230
|
+
}
|
|
2231
|
+
/** 发送 content-state 更新到指定会话的 Live Activity */
|
|
2232
|
+
async updateActivity(sessionId, contentState) {
|
|
2233
|
+
const token = this.tokens.get(sessionId);
|
|
2234
|
+
if (!token) return;
|
|
2235
|
+
const payload = {
|
|
2236
|
+
aps: {
|
|
2237
|
+
timestamp: Math.floor(Date.now() / 1e3),
|
|
2238
|
+
event: "update",
|
|
2239
|
+
"content-state": contentState
|
|
2240
|
+
}
|
|
2241
|
+
};
|
|
2242
|
+
try {
|
|
2243
|
+
await this.sendToAPNs(token, payload);
|
|
2244
|
+
} catch (err) {
|
|
2245
|
+
console.warn(`[ActivityPushChannel] \u66F4\u65B0\u5931\u8D25 session=${sessionId}:`, err);
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
/** 发送带通知的 content-state 更新(审批请求时使用) */
|
|
2249
|
+
async updateActivityWithAlert(sessionId, contentState, alert) {
|
|
2250
|
+
const token = this.tokens.get(sessionId);
|
|
2251
|
+
if (!token) return;
|
|
2252
|
+
const payload = {
|
|
2253
|
+
aps: {
|
|
2254
|
+
timestamp: Math.floor(Date.now() / 1e3),
|
|
2255
|
+
event: "update",
|
|
2256
|
+
"content-state": contentState,
|
|
2257
|
+
alert,
|
|
2258
|
+
sound: "default"
|
|
2259
|
+
}
|
|
2260
|
+
};
|
|
2261
|
+
try {
|
|
2262
|
+
await this.sendToAPNs(token, payload);
|
|
2263
|
+
} catch (err) {
|
|
2264
|
+
console.warn(`[ActivityPushChannel] \u5E26\u63D0\u9192\u66F4\u65B0\u5931\u8D25 session=${sessionId}:`, err);
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
/** 结束指定会话的 Live Activity */
|
|
2268
|
+
async endActivity(sessionId, contentState) {
|
|
2269
|
+
const token = this.tokens.get(sessionId);
|
|
2270
|
+
if (!token) return;
|
|
2271
|
+
const payload = {
|
|
2272
|
+
aps: {
|
|
2273
|
+
timestamp: Math.floor(Date.now() / 1e3),
|
|
2274
|
+
event: "end",
|
|
2275
|
+
"content-state": contentState
|
|
2276
|
+
}
|
|
2277
|
+
};
|
|
2278
|
+
try {
|
|
2279
|
+
await this.sendToAPNs(token, payload);
|
|
2280
|
+
} catch (err) {
|
|
2281
|
+
console.warn(`[ActivityPushChannel] \u7ED3\u675F\u5931\u8D25 session=${sessionId}:`, err);
|
|
2282
|
+
}
|
|
2283
|
+
this.tokens.delete(sessionId);
|
|
2284
|
+
}
|
|
2285
|
+
/** 检查是否有指定会话的 token */
|
|
2286
|
+
hasToken(sessionId) {
|
|
2287
|
+
return this.tokens.has(sessionId);
|
|
2288
|
+
}
|
|
2289
|
+
/** 发送 APNs HTTP/2 请求 */
|
|
2290
|
+
async sendToAPNs(deviceToken, payload) {
|
|
2291
|
+
const topic = "com.kachun.sessix.push-type.liveactivity";
|
|
2292
|
+
const jwt = this.getJWT();
|
|
2293
|
+
const payloadStr = JSON.stringify(payload);
|
|
2294
|
+
return new Promise((resolve, reject) => {
|
|
2295
|
+
let client;
|
|
2296
|
+
try {
|
|
2297
|
+
client = this.getHttp2Client();
|
|
2298
|
+
} catch (err) {
|
|
2299
|
+
return reject(err);
|
|
2300
|
+
}
|
|
2301
|
+
const req = client.request({
|
|
2302
|
+
":method": "POST",
|
|
2303
|
+
":path": `/3/device/${deviceToken}`,
|
|
2304
|
+
"authorization": `bearer ${jwt}`,
|
|
2305
|
+
"apns-topic": topic,
|
|
2306
|
+
"apns-push-type": "liveactivity",
|
|
2307
|
+
"apns-priority": "10",
|
|
2308
|
+
"apns-expiration": String(Math.floor(Date.now() / 1e3) + 30),
|
|
2309
|
+
"content-type": "application/json",
|
|
2310
|
+
"content-length": Buffer.byteLength(payloadStr)
|
|
2311
|
+
});
|
|
2312
|
+
let statusCode = 0;
|
|
2313
|
+
let responseData = "";
|
|
2314
|
+
req.on("response", (headers) => {
|
|
2315
|
+
statusCode = Number(headers[":status"] ?? 0);
|
|
2316
|
+
});
|
|
2317
|
+
req.on("data", (chunk) => {
|
|
2318
|
+
responseData += chunk;
|
|
2319
|
+
});
|
|
2320
|
+
req.on("end", () => {
|
|
2321
|
+
if (statusCode === 200) {
|
|
2322
|
+
resolve();
|
|
2323
|
+
} else {
|
|
2324
|
+
if (statusCode === 0) {
|
|
2325
|
+
this.http2Client?.destroy();
|
|
2326
|
+
this.http2Client = null;
|
|
2327
|
+
}
|
|
2328
|
+
reject(new Error(`APNs \u8FD4\u56DE ${statusCode}: ${responseData}`));
|
|
2329
|
+
}
|
|
2330
|
+
});
|
|
2331
|
+
req.on("error", (err) => {
|
|
2332
|
+
reject(err);
|
|
2333
|
+
});
|
|
2334
|
+
req.write(payloadStr);
|
|
2335
|
+
req.end();
|
|
2336
|
+
});
|
|
2337
|
+
}
|
|
2338
|
+
/** 生成或获取缓存的 APNs JWT token */
|
|
2339
|
+
getJWT() {
|
|
2340
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
2341
|
+
if (this.cachedJwt && this.cachedJwt.expiresAt > now) {
|
|
2342
|
+
return this.cachedJwt.token;
|
|
2343
|
+
}
|
|
2344
|
+
const header = Buffer.from(JSON.stringify({
|
|
2345
|
+
alg: "ES256",
|
|
2346
|
+
kid: this.keyId
|
|
2347
|
+
})).toString("base64url");
|
|
2348
|
+
const claims = Buffer.from(JSON.stringify({
|
|
2349
|
+
iss: this.teamId,
|
|
2350
|
+
iat: now
|
|
2351
|
+
})).toString("base64url");
|
|
2352
|
+
const signingInput = `${header}.${claims}`;
|
|
2353
|
+
const sign = crypto.createSign("SHA256");
|
|
2354
|
+
sign.update(signingInput);
|
|
2355
|
+
const signature = sign.sign(this.authKey, "base64url");
|
|
2356
|
+
const token = `${signingInput}.${signature}`;
|
|
2357
|
+
this.cachedJwt = { token, expiresAt: now + 3e3 };
|
|
2358
|
+
return token;
|
|
2359
|
+
}
|
|
2360
|
+
};
|
|
2361
|
+
|
|
2362
|
+
// src/session/ProjectReader.ts
|
|
2363
|
+
var import_promises3 = require("fs/promises");
|
|
2364
|
+
var import_readline2 = require("readline");
|
|
2365
|
+
var import_path = require("path");
|
|
2366
|
+
var import_os = require("os");
|
|
2367
|
+
var CLAUDE_PROJECTS_DIR = (0, import_path.join)((0, import_os.homedir)(), ".claude", "projects");
|
|
2368
|
+
function getSessionFilePath(projectPath, sessionId) {
|
|
2369
|
+
return (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodeDirName(projectPath), `${sessionId}.jsonl`);
|
|
2370
|
+
}
|
|
2371
|
+
async function getProjects() {
|
|
2372
|
+
try {
|
|
2373
|
+
const dirExists = await directoryExists(CLAUDE_PROJECTS_DIR);
|
|
2374
|
+
if (!dirExists) {
|
|
2375
|
+
return { ok: true, value: [] };
|
|
2376
|
+
}
|
|
2377
|
+
const entries = await (0, import_promises3.readdir)(CLAUDE_PROJECTS_DIR, { withFileTypes: true });
|
|
2378
|
+
const projects = [];
|
|
2379
|
+
for (const entry of entries) {
|
|
2380
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) {
|
|
2381
|
+
continue;
|
|
2382
|
+
}
|
|
2383
|
+
const encodedPath = entry.name;
|
|
2384
|
+
const decodedPath = decodeDirName(encodedPath);
|
|
2385
|
+
const name = decodedPath.split("/").filter(Boolean).pop() ?? encodedPath;
|
|
2386
|
+
const projectDir = (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodedPath);
|
|
2387
|
+
const { count: sessionCount, latestMtime } = await countJsonlFilesWithMtime(projectDir);
|
|
2388
|
+
projects.push({
|
|
2389
|
+
id: encodedPath,
|
|
2390
|
+
path: decodedPath,
|
|
2391
|
+
name,
|
|
2392
|
+
sessionCount,
|
|
2393
|
+
lastActiveAt: latestMtime
|
|
2394
|
+
});
|
|
2395
|
+
}
|
|
2396
|
+
projects.sort((a, b) => a.name.localeCompare(b.name));
|
|
2397
|
+
return { ok: true, value: projects };
|
|
2398
|
+
} catch (err) {
|
|
2399
|
+
return {
|
|
2400
|
+
ok: false,
|
|
2401
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
async function getHistoricalSessions(projectPath) {
|
|
2406
|
+
try {
|
|
2407
|
+
const encodedPath = encodeDirName(projectPath);
|
|
2408
|
+
const projectDir = (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodedPath);
|
|
2409
|
+
const dirExists = await directoryExists(projectDir);
|
|
2410
|
+
if (!dirExists) {
|
|
2411
|
+
return { ok: true, value: [] };
|
|
2412
|
+
}
|
|
2413
|
+
const entries = await (0, import_promises3.readdir)(projectDir, { withFileTypes: true });
|
|
2414
|
+
const jsonlFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
|
|
2415
|
+
const mtimeMap = /* @__PURE__ */ new Map();
|
|
2416
|
+
for (const entry of jsonlFiles) {
|
|
2417
|
+
const sessionId = entry.name.slice(0, -6);
|
|
2418
|
+
const filePath = (0, import_path.join)(projectDir, entry.name);
|
|
2419
|
+
try {
|
|
2420
|
+
const fileStat = await (0, import_promises3.stat)(filePath);
|
|
2421
|
+
mtimeMap.set(sessionId, fileStat.mtimeMs);
|
|
2422
|
+
} catch {
|
|
2423
|
+
mtimeMap.set(sessionId, 0);
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
const uuidDirs = entries.filter(
|
|
2427
|
+
(e) => e.isDirectory() && UUID_RE.test(e.name) && !mtimeMap.has(e.name)
|
|
2428
|
+
);
|
|
2429
|
+
for (const entry of uuidDirs) {
|
|
2430
|
+
try {
|
|
2431
|
+
const fileStat = await (0, import_promises3.stat)((0, import_path.join)(projectDir, entry.name));
|
|
2432
|
+
mtimeMap.set(entry.name, fileStat.mtimeMs);
|
|
2433
|
+
} catch {
|
|
2434
|
+
mtimeMap.set(entry.name, 0);
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
const indexPath = (0, import_path.join)(projectDir, "sessions-index.json");
|
|
2438
|
+
const sessionMap = /* @__PURE__ */ new Map();
|
|
2439
|
+
try {
|
|
2440
|
+
const indexContent = await (0, import_promises3.readFile)(indexPath, "utf-8");
|
|
2441
|
+
const indexData = JSON.parse(indexContent);
|
|
2442
|
+
if (indexData.version === 1 && Array.isArray(indexData.entries)) {
|
|
2443
|
+
for (const entry of indexData.entries) {
|
|
2444
|
+
const mtime = mtimeMap.get(entry.sessionId) ?? entry.fileMtime ?? (entry.modified ? new Date(entry.modified).getTime() : 0);
|
|
2445
|
+
sessionMap.set(entry.sessionId, {
|
|
2446
|
+
sessionId: entry.sessionId,
|
|
2447
|
+
lastModified: mtime,
|
|
2448
|
+
summary: entry.summary,
|
|
2449
|
+
firstPrompt: entry.firstPrompt,
|
|
2450
|
+
messageCount: entry.messageCount
|
|
2451
|
+
});
|
|
2452
|
+
}
|
|
2453
|
+
await Promise.all(
|
|
2454
|
+
Array.from(sessionMap.values()).filter((s) => (s.messageCount ?? 0) > 0 && !s.summary && !s.firstPrompt).map(async (s) => {
|
|
2455
|
+
const filePath = (0, import_path.join)(projectDir, `${s.sessionId}.jsonl`);
|
|
2456
|
+
const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
|
|
2457
|
+
if (firstPrompt) s.firstPrompt = firstPrompt;
|
|
2458
|
+
})
|
|
2459
|
+
);
|
|
2460
|
+
}
|
|
2461
|
+
} catch {
|
|
2462
|
+
}
|
|
2463
|
+
const uuidDirSet = new Set(uuidDirs.map((e) => e.name));
|
|
2464
|
+
for (const [sessionId, mtime] of mtimeMap) {
|
|
2465
|
+
if (!sessionMap.has(sessionId)) {
|
|
2466
|
+
if (uuidDirSet.has(sessionId)) {
|
|
2467
|
+
sessionMap.set(sessionId, { sessionId, lastModified: mtime, messageCount: -1 });
|
|
2468
|
+
} else {
|
|
2469
|
+
const filePath = (0, import_path.join)(projectDir, `${sessionId}.jsonl`);
|
|
2470
|
+
const firstPrompt = await extractFirstPrompt(filePath).catch(() => void 0);
|
|
2471
|
+
sessionMap.set(sessionId, { sessionId, lastModified: mtime, firstPrompt });
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
const sessions = Array.from(sessionMap.values()).filter((s) => {
|
|
2476
|
+
if (s.messageCount === 0) return false;
|
|
2477
|
+
if (s.messageCount === -1) return true;
|
|
2478
|
+
if (s.firstPrompt === void 0 && s.messageCount === void 0) return false;
|
|
2479
|
+
return true;
|
|
2480
|
+
});
|
|
2481
|
+
sessions.sort((a, b) => b.lastModified - a.lastModified);
|
|
2482
|
+
return { ok: true, value: sessions };
|
|
2483
|
+
} catch (err) {
|
|
2484
|
+
return {
|
|
2485
|
+
ok: false,
|
|
2486
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
2487
|
+
};
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
async function getSessionHistory(projectPath, sessionId) {
|
|
2491
|
+
try {
|
|
2492
|
+
const encodedPath = encodeDirName(projectPath);
|
|
2493
|
+
const filePath = (0, import_path.join)(CLAUDE_PROJECTS_DIR, encodedPath, `${sessionId}.jsonl`);
|
|
2494
|
+
const raw = await (0, import_promises3.readFile)(filePath, "utf-8").catch((err) => {
|
|
2495
|
+
if (err.code === "ENOENT") return null;
|
|
2496
|
+
throw err;
|
|
2497
|
+
});
|
|
2498
|
+
if (raw === null) return { ok: false, error: new Error("ENOENT") };
|
|
2499
|
+
const lines = raw.split("\n").filter((l) => l.trim());
|
|
2500
|
+
const events = [];
|
|
2501
|
+
for (const line of lines) {
|
|
2502
|
+
try {
|
|
2503
|
+
const obj = JSON.parse(line);
|
|
2504
|
+
const type = obj.type;
|
|
2505
|
+
if (type === "user" && obj.message) {
|
|
2506
|
+
const msgContent = obj.message.content;
|
|
2507
|
+
if (typeof msgContent === "string") {
|
|
2508
|
+
if (msgContent.includes("<local-command") || msgContent.includes("<command-name>")) continue;
|
|
2509
|
+
} else if (Array.isArray(msgContent)) {
|
|
2510
|
+
const hasText = msgContent.some(
|
|
2511
|
+
(b) => b.type === "text" && !b.text?.includes("<local-command") && !b.text?.includes("<command-name>")
|
|
2512
|
+
);
|
|
2513
|
+
if (!hasText) continue;
|
|
2514
|
+
}
|
|
2515
|
+
const normalizedContent = typeof msgContent === "string" ? [{ type: "text", text: msgContent }] : Array.isArray(msgContent) ? msgContent.filter((b) => b.type === "text" && typeof b.text === "string") : [];
|
|
2516
|
+
if (normalizedContent.length === 0) continue;
|
|
2517
|
+
events.push({
|
|
2518
|
+
type: "user",
|
|
2519
|
+
message: {
|
|
2520
|
+
...obj.message,
|
|
2521
|
+
content: normalizedContent
|
|
2522
|
+
},
|
|
2523
|
+
session_id: sessionId
|
|
2524
|
+
});
|
|
2525
|
+
} else if (type === "assistant" && obj.message) {
|
|
2526
|
+
const content = (obj.message.content ?? []).filter(
|
|
2527
|
+
(b) => b.type === "text" || b.type === "tool_use" || b.type === "thinking"
|
|
2528
|
+
);
|
|
2529
|
+
if (content.length === 0) continue;
|
|
2530
|
+
events.push({
|
|
2531
|
+
type: "assistant",
|
|
2532
|
+
message: {
|
|
2533
|
+
id: obj.message.id ?? obj.uuid ?? `hist-${events.length}`,
|
|
2534
|
+
model: obj.message.model ?? "unknown",
|
|
2535
|
+
role: "assistant",
|
|
2536
|
+
content,
|
|
2537
|
+
stop_reason: obj.message.stop_reason,
|
|
2538
|
+
usage: obj.message.usage
|
|
2539
|
+
},
|
|
2540
|
+
session_id: sessionId
|
|
2541
|
+
});
|
|
2542
|
+
}
|
|
2543
|
+
} catch {
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
if (events.length > 0) {
|
|
2547
|
+
let totalInputTokens = 0;
|
|
2548
|
+
let totalOutputTokens = 0;
|
|
2549
|
+
for (const ev of events) {
|
|
2550
|
+
if (ev.type === "assistant" && ev.message.usage) {
|
|
2551
|
+
totalInputTokens += ev.message.usage.input_tokens ?? 0;
|
|
2552
|
+
totalOutputTokens += ev.message.usage.output_tokens ?? 0;
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
if (totalInputTokens > 0 || totalOutputTokens > 0) {
|
|
2556
|
+
events.push({
|
|
2557
|
+
type: "result",
|
|
2558
|
+
subtype: "success",
|
|
2559
|
+
is_error: false,
|
|
2560
|
+
duration_ms: 0,
|
|
2561
|
+
num_turns: events.filter((e) => e.type === "user").length,
|
|
2562
|
+
result: "",
|
|
2563
|
+
session_id: sessionId,
|
|
2564
|
+
usage: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens }
|
|
2565
|
+
});
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
return { ok: true, value: events };
|
|
2569
|
+
} catch (err) {
|
|
2570
|
+
return {
|
|
2571
|
+
ok: false,
|
|
2572
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
2573
|
+
};
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
async function extractFirstPrompt(filePath) {
|
|
2577
|
+
let fileHandle;
|
|
2578
|
+
try {
|
|
2579
|
+
fileHandle = await (0, import_promises3.open)(filePath, "r");
|
|
2580
|
+
const rl = (0, import_readline2.createInterface)({
|
|
2581
|
+
input: fileHandle.createReadStream({ encoding: "utf-8" }),
|
|
2582
|
+
crlfDelay: Infinity
|
|
2583
|
+
});
|
|
2584
|
+
let lineCount = 0;
|
|
2585
|
+
for await (const line of rl) {
|
|
2586
|
+
if (++lineCount > 20) break;
|
|
2587
|
+
if (!line.trim()) continue;
|
|
2588
|
+
try {
|
|
2589
|
+
const obj = JSON.parse(line);
|
|
2590
|
+
if (obj.type === "user" && obj.message) {
|
|
2591
|
+
const msgContent = obj.message.content;
|
|
2592
|
+
let text = "";
|
|
2593
|
+
if (typeof msgContent === "string") {
|
|
2594
|
+
text = msgContent;
|
|
2595
|
+
} else if (Array.isArray(msgContent)) {
|
|
2596
|
+
const textBlock = msgContent.find((b) => b.type === "text" && typeof b.text === "string");
|
|
2597
|
+
text = textBlock?.text ?? "";
|
|
2598
|
+
}
|
|
2599
|
+
if (text && !text.includes("<local-command") && !text.includes("<command-name>")) {
|
|
2600
|
+
text = text.replace(/<ide_opened_file>[\s\S]*?<\/ide_opened_file>/gi, "");
|
|
2601
|
+
text = text.replace(/<[^>]+>/g, "").trim();
|
|
2602
|
+
rl.close();
|
|
2603
|
+
return text.length > 80 ? text.slice(0, 80) + "..." : text;
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
} catch {
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
} catch {
|
|
2610
|
+
} finally {
|
|
2611
|
+
await fileHandle?.close();
|
|
2612
|
+
}
|
|
2613
|
+
return void 0;
|
|
2614
|
+
}
|
|
2615
|
+
function decodeDirName(dirName) {
|
|
2616
|
+
const placeholder = "\0";
|
|
2617
|
+
const escaped = dirName.replace(/--/g, placeholder);
|
|
2618
|
+
const decoded = escaped.replace(/-/g, "/");
|
|
2619
|
+
return decoded.replace(new RegExp(placeholder, "g"), "-");
|
|
2620
|
+
}
|
|
2621
|
+
function encodeDirName(path2) {
|
|
2622
|
+
const escaped = path2.replace(/-/g, "--");
|
|
2623
|
+
return escaped.replace(/\//g, "-");
|
|
2624
|
+
}
|
|
2625
|
+
async function directoryExists(dirPath) {
|
|
2626
|
+
try {
|
|
2627
|
+
const s = await (0, import_promises3.stat)(dirPath);
|
|
2628
|
+
return s.isDirectory();
|
|
2629
|
+
} catch {
|
|
2630
|
+
return false;
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2634
|
+
async function countJsonlFilesWithMtime(dirPath) {
|
|
2635
|
+
try {
|
|
2636
|
+
const entries = await (0, import_promises3.readdir)(dirPath, { withFileTypes: true });
|
|
2637
|
+
const jsonlNames = new Set(
|
|
2638
|
+
entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => e.name.slice(0, -6))
|
|
2639
|
+
);
|
|
2640
|
+
const uuidDirs = entries.filter(
|
|
2641
|
+
(e) => e.isDirectory() && UUID_RE.test(e.name) && !jsonlNames.has(e.name)
|
|
2642
|
+
);
|
|
2643
|
+
let latestMtime = 0;
|
|
2644
|
+
const allEntries = [
|
|
2645
|
+
...entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")),
|
|
2646
|
+
...uuidDirs
|
|
2647
|
+
];
|
|
2648
|
+
for (const entry of allEntries) {
|
|
2649
|
+
try {
|
|
2650
|
+
const fileStat = await (0, import_promises3.stat)((0, import_path.join)(dirPath, entry.name));
|
|
2651
|
+
if (fileStat.mtimeMs > latestMtime) latestMtime = fileStat.mtimeMs;
|
|
2652
|
+
} catch {
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
return { count: jsonlNames.size + uuidDirs.length, latestMtime };
|
|
2656
|
+
} catch {
|
|
2657
|
+
return { count: 0, latestMtime: 0 };
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
// src/server.ts
|
|
2662
|
+
var import_promises5 = require("fs/promises");
|
|
2663
|
+
var WS_PORT = 3745;
|
|
2664
|
+
var HTTP_PORT = 3746;
|
|
2665
|
+
var execAsync = (0, import_node_util.promisify)(import_node_child_process2.exec);
|
|
2666
|
+
async function killPortProcess(port) {
|
|
2667
|
+
try {
|
|
2668
|
+
const { stdout } = await execAsync(`lsof -ti :${port}`);
|
|
2669
|
+
const pids = stdout.trim().split("\n").filter((p) => p && /^\d+$/.test(p));
|
|
2670
|
+
if (pids.length > 0) {
|
|
2671
|
+
await execAsync(`kill -9 ${pids.join(" ")}`);
|
|
2672
|
+
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
2673
|
+
}
|
|
2674
|
+
} catch {
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
async function createWithRetry(label, port, factory) {
|
|
2678
|
+
try {
|
|
2679
|
+
return await factory();
|
|
2680
|
+
} catch (err) {
|
|
2681
|
+
if (err?.code === "EADDRINUSE") {
|
|
2682
|
+
console.warn(`[Server] \u7AEF\u53E3 ${port} \u88AB\u5360\u7528\uFF0C\u5C1D\u8BD5\u91CA\u653E\u65E7\u8FDB\u7A0B...`);
|
|
2683
|
+
await killPortProcess(port);
|
|
2684
|
+
console.log(`[Server] \u91CD\u65B0\u542F\u52A8 ${label}...`);
|
|
2685
|
+
return await factory();
|
|
2686
|
+
}
|
|
2687
|
+
throw err;
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
async function start(opts = {}) {
|
|
2691
|
+
const configDir = (0, import_node_path4.join)((0, import_node_os4.homedir)(), ".sessix");
|
|
2692
|
+
const tokenFile = (0, import_node_path4.join)(configDir, "token");
|
|
2693
|
+
let token;
|
|
2694
|
+
if (opts.token !== void 0) {
|
|
2695
|
+
token = opts.token;
|
|
2696
|
+
} else {
|
|
2697
|
+
const envToken = process.env.SESSIX_TOKEN;
|
|
2698
|
+
if (envToken !== void 0) {
|
|
2699
|
+
token = envToken;
|
|
2700
|
+
} else {
|
|
2701
|
+
try {
|
|
2702
|
+
token = (await (0, import_promises4.readFile)(tokenFile, "utf8")).trim();
|
|
2703
|
+
} catch {
|
|
2704
|
+
token = (0, import_uuid4.v4)();
|
|
2705
|
+
await (0, import_promises4.mkdir)(configDir, { recursive: true });
|
|
2706
|
+
await (0, import_promises4.writeFile)(tokenFile, token, "utf8");
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
const provider = new ProcessProvider();
|
|
2711
|
+
const sessionManager = new SessionManager(provider);
|
|
2712
|
+
const expoChannel = new ExpoNotificationChannel();
|
|
2713
|
+
const notificationService = new NotificationService(sessionManager, expoChannel);
|
|
2714
|
+
notificationService.addChannel("expo", expoChannel, opts.enableExpoPush !== false);
|
|
2715
|
+
notificationService.addChannel("mac", new MacNotificationChannel(), opts.enableMacNotification !== false);
|
|
2716
|
+
if (opts.activityPush) {
|
|
2717
|
+
try {
|
|
2718
|
+
const activityChannel = new ActivityPushChannel(opts.activityPush);
|
|
2719
|
+
notificationService.setActivityPushChannel(activityChannel);
|
|
2720
|
+
console.log("[Server] ActivityKit Push \u5DF2\u542F\u7528");
|
|
2721
|
+
} catch (err) {
|
|
2722
|
+
console.warn("[Server] ActivityKit Push \u521D\u59CB\u5316\u5931\u8D25:", err);
|
|
2723
|
+
console.log("[Server] \u7EE7\u7EED\u542F\u52A8\uFF08Live Activity \u540E\u53F0\u63A8\u9001\u4E0D\u53EF\u7528\uFF09");
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
const wsBridge = await createWithRetry(
|
|
2727
|
+
"WsBridge",
|
|
2728
|
+
WS_PORT,
|
|
2729
|
+
() => WsBridge.create({ port: WS_PORT, token })
|
|
2730
|
+
);
|
|
2731
|
+
const sessionFileWatcher = new SessionFileWatcher((event) => {
|
|
2732
|
+
wsBridge.broadcast(event);
|
|
2733
|
+
});
|
|
2734
|
+
const approvalProxy = await createWithRetry(
|
|
2735
|
+
"ApprovalProxy",
|
|
2736
|
+
HTTP_PORT,
|
|
2737
|
+
() => ApprovalProxy.create({ port: HTTP_PORT, token })
|
|
2738
|
+
);
|
|
2739
|
+
wsBridge.onConnection(async (ws) => {
|
|
2740
|
+
const result = await getProjects();
|
|
2741
|
+
if (result.ok) {
|
|
2742
|
+
wsBridge.send(ws, { type: "project_list", projects: result.value });
|
|
2743
|
+
}
|
|
2744
|
+
wsBridge.send(ws, {
|
|
2745
|
+
type: "session_list",
|
|
2746
|
+
sessions: sessionManager.getActiveSessions()
|
|
2747
|
+
});
|
|
2748
|
+
});
|
|
2749
|
+
wsBridge.onClientEvent(async (event, ws) => {
|
|
2750
|
+
try {
|
|
2751
|
+
switch (event.type) {
|
|
2752
|
+
case "create_session": {
|
|
2753
|
+
await (0, import_promises4.mkdir)(event.projectPath, { recursive: true });
|
|
2754
|
+
await sessionManager.createSession(
|
|
2755
|
+
event.projectPath,
|
|
2756
|
+
event.message,
|
|
2757
|
+
event.resumeSessionId,
|
|
2758
|
+
event.newSessionId,
|
|
2759
|
+
event.model,
|
|
2760
|
+
event.permissionMode,
|
|
2761
|
+
event.effort,
|
|
2762
|
+
event.images
|
|
2763
|
+
);
|
|
2764
|
+
wsBridge.broadcast({
|
|
2765
|
+
type: "session_list",
|
|
2766
|
+
sessions: sessionManager.getActiveSessions()
|
|
2767
|
+
});
|
|
2768
|
+
break;
|
|
2769
|
+
}
|
|
2770
|
+
case "send_message": {
|
|
2771
|
+
await sessionManager.sendMessage(event.sessionId, event.message, event.permissionMode, event.images);
|
|
2772
|
+
wsBridge.broadcast({
|
|
2773
|
+
type: "session_list",
|
|
2774
|
+
sessions: sessionManager.getActiveSessions()
|
|
2775
|
+
});
|
|
2776
|
+
break;
|
|
2777
|
+
}
|
|
2778
|
+
case "kill_session": {
|
|
2779
|
+
wsBridge.broadcast({ type: "status_change", sessionId: event.sessionId, status: "idle" });
|
|
2780
|
+
await sessionManager.killSession(event.sessionId);
|
|
2781
|
+
wsBridge.broadcast({
|
|
2782
|
+
type: "session_list",
|
|
2783
|
+
sessions: sessionManager.getActiveSessions()
|
|
2784
|
+
});
|
|
2785
|
+
break;
|
|
2786
|
+
}
|
|
2787
|
+
case "approve": {
|
|
2788
|
+
approvalProxy.resolveApproval(event.requestId, { decision: "allow" });
|
|
2789
|
+
break;
|
|
2790
|
+
}
|
|
2791
|
+
case "reject": {
|
|
2792
|
+
const decision = { decision: "deny", reason: event.reason };
|
|
2793
|
+
approvalProxy.resolveApproval(event.requestId, decision);
|
|
2794
|
+
break;
|
|
2795
|
+
}
|
|
2796
|
+
case "answer_question": {
|
|
2797
|
+
sessionManager.handleQuestionResponse(event.requestId, event.answer);
|
|
2798
|
+
break;
|
|
2799
|
+
}
|
|
2800
|
+
case "subscribe": {
|
|
2801
|
+
wsBridge.send(ws, {
|
|
2802
|
+
type: "session_list",
|
|
2803
|
+
sessions: sessionManager.getActiveSessions()
|
|
2804
|
+
});
|
|
2805
|
+
const bufferedEvents = sessionManager.getSessionEvents(event.sessionId);
|
|
2806
|
+
if (bufferedEvents.length > 0) {
|
|
2807
|
+
wsBridge.send(ws, {
|
|
2808
|
+
type: "session_history",
|
|
2809
|
+
sessionId: event.sessionId,
|
|
2810
|
+
events: bufferedEvents
|
|
2811
|
+
});
|
|
2812
|
+
}
|
|
2813
|
+
for (const req of approvalProxy.getPendingRequestsForSession(event.sessionId)) {
|
|
2814
|
+
wsBridge.send(ws, { type: "approval_request", request: req });
|
|
2815
|
+
}
|
|
2816
|
+
break;
|
|
2817
|
+
}
|
|
2818
|
+
case "list_projects": {
|
|
2819
|
+
const result = await getProjects();
|
|
2820
|
+
if (result.ok) {
|
|
2821
|
+
wsBridge.send(ws, { type: "project_list", projects: result.value });
|
|
2822
|
+
} else {
|
|
2823
|
+
wsBridge.send(ws, {
|
|
2824
|
+
type: "error",
|
|
2825
|
+
message: `\u83B7\u53D6\u9879\u76EE\u5217\u8868\u5931\u8D25: ${result.error.message}`,
|
|
2826
|
+
code: "PROJECT_LIST_ERROR"
|
|
2827
|
+
});
|
|
2828
|
+
}
|
|
2829
|
+
break;
|
|
2830
|
+
}
|
|
2831
|
+
case "list_sessions": {
|
|
2832
|
+
wsBridge.send(ws, {
|
|
2833
|
+
type: "session_list",
|
|
2834
|
+
sessions: sessionManager.getActiveSessions().filter(
|
|
2835
|
+
(s) => s.projectPath === event.projectPath
|
|
2836
|
+
)
|
|
2837
|
+
});
|
|
2838
|
+
break;
|
|
2839
|
+
}
|
|
2840
|
+
case "list_project_sessions": {
|
|
2841
|
+
const histResult = await getHistoricalSessions(event.projectPath);
|
|
2842
|
+
if (histResult.ok) {
|
|
2843
|
+
wsBridge.send(ws, {
|
|
2844
|
+
type: "project_sessions",
|
|
2845
|
+
projectPath: event.projectPath,
|
|
2846
|
+
sessions: histResult.value
|
|
2847
|
+
});
|
|
2848
|
+
} else {
|
|
2849
|
+
wsBridge.send(ws, {
|
|
2850
|
+
type: "error",
|
|
2851
|
+
message: `\u83B7\u53D6\u9879\u76EE\u4F1A\u8BDD\u5931\u8D25: ${histResult.error.message}`,
|
|
2852
|
+
code: "PROJECT_SESSIONS_ERROR"
|
|
2853
|
+
});
|
|
2854
|
+
}
|
|
2855
|
+
break;
|
|
2856
|
+
}
|
|
2857
|
+
case "load_session_history": {
|
|
2858
|
+
const historyResult = await getSessionHistory(event.projectPath, event.sessionId);
|
|
2859
|
+
if (!historyResult.ok) {
|
|
2860
|
+
wsBridge.send(ws, {
|
|
2861
|
+
type: "error",
|
|
2862
|
+
message: `\u8BFB\u53D6\u4F1A\u8BDD\u5386\u53F2\u5931\u8D25: ${historyResult.error.message}`,
|
|
2863
|
+
code: "SESSION_HISTORY_ERROR",
|
|
2864
|
+
sessionId: event.sessionId
|
|
2865
|
+
});
|
|
2866
|
+
} else if (historyResult.value.length > 0) {
|
|
2867
|
+
wsBridge.send(ws, {
|
|
2868
|
+
type: "session_history",
|
|
2869
|
+
sessionId: event.sessionId,
|
|
2870
|
+
events: historyResult.value
|
|
2871
|
+
});
|
|
2872
|
+
const activeSession = sessionManager.getActiveSessions().find((s) => s.id === event.sessionId);
|
|
2873
|
+
const isStreaming = activeSession?.status === "running" || activeSession?.status === "waiting_approval";
|
|
2874
|
+
if (!isStreaming) {
|
|
2875
|
+
const filePath = getSessionFilePath(event.projectPath, event.sessionId);
|
|
2876
|
+
try {
|
|
2877
|
+
const fileStat = await (0, import_promises5.stat)(filePath);
|
|
2878
|
+
sessionFileWatcher.watch(event.sessionId, filePath, fileStat.size);
|
|
2879
|
+
} catch {
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
break;
|
|
2884
|
+
}
|
|
2885
|
+
case "suggest_next_prompt": {
|
|
2886
|
+
const historyResult = await getSessionHistory(event.projectPath, event.sessionId);
|
|
2887
|
+
let context = "\uFF08\u6682\u65E0\u5BF9\u8BDD\u5386\u53F2\uFF09";
|
|
2888
|
+
if (historyResult.ok && historyResult.value.length > 0) {
|
|
2889
|
+
const recent = historyResult.value.slice(-10);
|
|
2890
|
+
context = recent.map((e) => {
|
|
2891
|
+
if (e.type === "assistant") {
|
|
2892
|
+
const text = e.message.content.filter((b) => b.type === "text").map((b) => b.text).join("");
|
|
2893
|
+
return `Assistant: ${text.substring(0, 300)}`;
|
|
2894
|
+
}
|
|
2895
|
+
if (e.type === "user") {
|
|
2896
|
+
const text = e.message.content.filter((b) => b.type === "text" && !!b.text).map((b) => b.text).join("");
|
|
2897
|
+
return text ? `User: ${text.substring(0, 300)}` : null;
|
|
2898
|
+
}
|
|
2899
|
+
return null;
|
|
2900
|
+
}).filter(Boolean).join("\n");
|
|
2901
|
+
}
|
|
2902
|
+
const suggestion = await provider.generateSuggestion(context);
|
|
2903
|
+
wsBridge.send(ws, {
|
|
2904
|
+
type: "prompt_suggestion",
|
|
2905
|
+
sessionId: event.sessionId,
|
|
2906
|
+
suggestion
|
|
2907
|
+
});
|
|
2908
|
+
break;
|
|
2909
|
+
}
|
|
2910
|
+
case "register_push_token": {
|
|
2911
|
+
notificationService.addPushToken(event.token);
|
|
2912
|
+
break;
|
|
2913
|
+
}
|
|
2914
|
+
case "unregister_push_token": {
|
|
2915
|
+
notificationService.removePushToken(event.token);
|
|
2916
|
+
break;
|
|
2917
|
+
}
|
|
2918
|
+
case "update_notification_sounds": {
|
|
2919
|
+
notificationService.setSoundPreferences(event.preferences);
|
|
2920
|
+
break;
|
|
2921
|
+
}
|
|
2922
|
+
case "register_activity_push_token": {
|
|
2923
|
+
notificationService.addActivityPushToken(event.sessionId, event.token);
|
|
2924
|
+
break;
|
|
2925
|
+
}
|
|
2926
|
+
case "unregister_activity_push_token": {
|
|
2927
|
+
notificationService.removeActivityPushToken(event.sessionId);
|
|
2928
|
+
break;
|
|
2929
|
+
}
|
|
2930
|
+
case "set_yolo_mode": {
|
|
2931
|
+
notificationService.setYoloMode(event.sessionId, event.enabled);
|
|
2932
|
+
approvalProxy.setYoloMode(event.sessionId, event.enabled);
|
|
2933
|
+
break;
|
|
2934
|
+
}
|
|
2935
|
+
case "viewing_session": {
|
|
2936
|
+
wsBridge.setViewingSession(ws, event.sessionId);
|
|
2937
|
+
break;
|
|
2938
|
+
}
|
|
2939
|
+
case "left_session": {
|
|
2940
|
+
wsBridge.clearViewingSession(ws);
|
|
2941
|
+
break;
|
|
2942
|
+
}
|
|
2943
|
+
case "always_allow_tool": {
|
|
2944
|
+
approvalProxy.addToClaudeSettings(event.projectPath, event.toolName);
|
|
2945
|
+
break;
|
|
2946
|
+
}
|
|
2947
|
+
default: {
|
|
2948
|
+
wsBridge.send(ws, {
|
|
2949
|
+
type: "error",
|
|
2950
|
+
message: `\u672A\u77E5\u7684\u4E8B\u4EF6\u7C7B\u578B: ${event.type}`,
|
|
2951
|
+
code: "UNKNOWN_EVENT"
|
|
2952
|
+
});
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
} catch (err) {
|
|
2956
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2957
|
+
console.error("[Server] \u5904\u7406\u5BA2\u6237\u7AEF\u4E8B\u4EF6\u5F02\u5E38:", message);
|
|
2958
|
+
const errorCodeMap = {
|
|
2959
|
+
create_session: "SESSION_CREATE_ERROR",
|
|
2960
|
+
send_message: "SEND_MESSAGE_ERROR",
|
|
2961
|
+
kill_session: "KILL_SESSION_ERROR",
|
|
2962
|
+
approve: "APPROVE_ERROR",
|
|
2963
|
+
reject: "REJECT_ERROR",
|
|
2964
|
+
answer_question: "ANSWER_QUESTION_ERROR",
|
|
2965
|
+
suggest_next_prompt: "SUGGEST_PROMPT_ERROR"
|
|
2966
|
+
};
|
|
2967
|
+
const code = errorCodeMap[event.type] ?? "INTERNAL_ERROR";
|
|
2968
|
+
wsBridge.send(ws, { type: "error", message, code });
|
|
2969
|
+
}
|
|
2970
|
+
});
|
|
2971
|
+
sessionManager.onEvent((event) => {
|
|
2972
|
+
wsBridge.broadcast(event);
|
|
2973
|
+
});
|
|
2974
|
+
wsBridge.onDisconnect(() => {
|
|
2975
|
+
if (wsBridge.getConnectionCount() === 0 && approvalProxy.getPendingCount() > 0) {
|
|
2976
|
+
approvalProxy.approveAll("\u624B\u673A\u7AEF\u5DF2\u65AD\u5F00");
|
|
2977
|
+
}
|
|
2978
|
+
});
|
|
2979
|
+
approvalProxy.onApprovalRequest((request) => {
|
|
2980
|
+
wsBridge.broadcast({ type: "approval_request", request });
|
|
2981
|
+
setTimeout(() => {
|
|
2982
|
+
if (!approvalProxy.isPending(request.id)) return;
|
|
2983
|
+
if (wsBridge.isViewingSession(request.sessionId)) return;
|
|
2984
|
+
if (wsBridge.getConnectionCount() > 0) return;
|
|
2985
|
+
const pendingCount = approvalProxy.getPendingRequestsForSession(request.sessionId).length;
|
|
2986
|
+
notificationService.notifyApproval(request, pendingCount);
|
|
2987
|
+
}, 5e3);
|
|
2988
|
+
setTimeout(() => {
|
|
2989
|
+
if (!approvalProxy.isPending(request.id)) return;
|
|
2990
|
+
if (wsBridge.isViewingSession(request.sessionId)) return;
|
|
2991
|
+
if (wsBridge.getConnectionCount() > 0) return;
|
|
2992
|
+
console.log(`[Server] \u5BA1\u6279\u8BF7\u6C42 ${request.id} 60\u79D2\u672A\u5904\u7406\uFF0C\u91CD\u8BD5\u63A8\u9001`);
|
|
2993
|
+
const pendingCount = approvalProxy.getPendingRequestsForSession(request.sessionId).length;
|
|
2994
|
+
notificationService.notifyApproval(request, pendingCount);
|
|
2995
|
+
}, 6e4);
|
|
2996
|
+
});
|
|
2997
|
+
approvalProxy.setStatusInfoProvider(() => ({
|
|
2998
|
+
connections: wsBridge.getConnectionCount(),
|
|
2999
|
+
activeSessions: sessionManager.getActiveSessions().length
|
|
3000
|
+
}));
|
|
3001
|
+
const mdnsService = new MdnsService({ wsPort: WS_PORT, httpPort: HTTP_PORT });
|
|
3002
|
+
mdnsService.start();
|
|
3003
|
+
const hookInstaller = new HookInstaller();
|
|
3004
|
+
try {
|
|
3005
|
+
const installed = await hookInstaller.isInstalled();
|
|
3006
|
+
if (!installed) {
|
|
3007
|
+
await hookInstaller.install();
|
|
3008
|
+
console.log("[Server] Sessix hook \u5DF2\u5B89\u88C5\u5230 Claude Code");
|
|
3009
|
+
} else {
|
|
3010
|
+
console.log("[Server] Sessix hook \u5DF2\u5B58\u5728\uFF0C\u8DF3\u8FC7\u5B89\u88C5");
|
|
3011
|
+
}
|
|
3012
|
+
} catch (err) {
|
|
3013
|
+
console.error("[Server] Hook \u5B89\u88C5\u5931\u8D25:", err);
|
|
3014
|
+
console.log("[Server] \u7EE7\u7EED\u542F\u52A8\uFF08hook \u529F\u80FD\u53EF\u80FD\u4E0D\u53EF\u7528\uFF09");
|
|
3015
|
+
}
|
|
3016
|
+
const stop = async () => {
|
|
3017
|
+
console.log("[Server] \u6B63\u5728\u4F18\u96C5\u5173\u95ED...");
|
|
3018
|
+
const errors = [];
|
|
3019
|
+
const attempt = async (fn, label) => {
|
|
3020
|
+
try {
|
|
3021
|
+
await fn();
|
|
3022
|
+
} catch (err) {
|
|
3023
|
+
console.error(`[Server] \u5173\u95ED ${label} \u51FA\u9519:`, err);
|
|
3024
|
+
errors.push(err);
|
|
3025
|
+
}
|
|
3026
|
+
};
|
|
3027
|
+
await attempt(() => mdnsService.stop(), "mDNS");
|
|
3028
|
+
await attempt(() => wsBridge.close(), "WebSocket");
|
|
3029
|
+
await attempt(() => approvalProxy.close(), "ApprovalProxy");
|
|
3030
|
+
await attempt(() => sessionManager.destroy(), "SessionManager");
|
|
3031
|
+
await attempt(() => notificationService.destroy(), "NotificationService");
|
|
3032
|
+
await attempt(() => sessionFileWatcher.destroy(), "SessionFileWatcher");
|
|
3033
|
+
if (errors.length > 0) {
|
|
3034
|
+
console.error(`[Server] \u5173\u95ED\u5B8C\u6210\uFF0C${errors.length} \u4E2A\u9519\u8BEF`);
|
|
3035
|
+
throw errors[0];
|
|
3036
|
+
}
|
|
3037
|
+
console.log("[Server] \u6240\u6709\u670D\u52A1\u5DF2\u5173\u95ED");
|
|
3038
|
+
};
|
|
3039
|
+
return {
|
|
3040
|
+
token,
|
|
3041
|
+
wsPort: WS_PORT,
|
|
3042
|
+
httpPort: HTTP_PORT,
|
|
3043
|
+
getActiveSessions: () => sessionManager.getActiveSessions(),
|
|
3044
|
+
getConnectionCount: () => wsBridge.getConnectionCount(),
|
|
3045
|
+
stop,
|
|
3046
|
+
setMacNotification: (enabled) => notificationService.setChannelEnabled("mac", enabled),
|
|
3047
|
+
setExpoPush: (enabled) => notificationService.setChannelEnabled("expo", enabled),
|
|
3048
|
+
onServerEvent: (cb) => sessionManager.onEvent(cb)
|
|
3049
|
+
};
|
|
3050
|
+
}
|
|
3051
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
3052
|
+
0 && (module.exports = {
|
|
3053
|
+
start
|
|
3054
|
+
});
|