@bastdewfn/cc-remote 1.0.9

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/relay.js ADDED
@@ -0,0 +1,851 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.CoreRelay = void 0;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const http = __importStar(require("http"));
40
+ const core_1 = require("./core");
41
+ class CoreRelay {
42
+ constructor(deps) {
43
+ this.server = null;
44
+ this.pendingApprovals = new Map();
45
+ this.wxPendingApprovals = new Map();
46
+ this.diag = {};
47
+ this.config = deps.config;
48
+ this.configPath = deps.configPath;
49
+ this.targetDir = deps.targetDir;
50
+ this.feishuReplier = deps.feishuReplier;
51
+ this.weixinReplier = deps.weixinReplier;
52
+ this.onStatus = deps.onStatus;
53
+ }
54
+ start(port) {
55
+ const relayPort = port ?? this.config.relayPort ?? 19200;
56
+ this.server = http.createServer((req, res) => this._handleRequest(req, res));
57
+ this.server.listen(relayPort, '127.0.0.1', () => {
58
+ this.onStatus(`\r\n🔗 HTTP Relay 已启动: 127.0.0.1:${relayPort}\r\n`);
59
+ });
60
+ this.server.on('error', (err) => {
61
+ if (err.code === 'EADDRINUSE') {
62
+ throw new Error(`端口 ${relayPort} 已被占用,无法启动 Relay`);
63
+ }
64
+ throw err;
65
+ });
66
+ }
67
+ stop() {
68
+ if (this.server) {
69
+ this.server.close();
70
+ this.server = null;
71
+ }
72
+ }
73
+ get running() {
74
+ return this.server !== null;
75
+ }
76
+ // ---- Private ----
77
+ _log(msg) {
78
+ const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
79
+ const logFile = (0, core_1.dataPath)(this.targetDir, `relay-${date}.log`);
80
+ try {
81
+ fs.appendFileSync(logFile, `[${new Date().toISOString()}] ${msg}\n`);
82
+ }
83
+ catch { /* ignore */ }
84
+ }
85
+ _handleRequest(req, res) {
86
+ if (req.method === 'POST' && req.url === '/notify') {
87
+ this._handleNotify(req, res);
88
+ }
89
+ else if (req.method === 'POST' && req.url === '/approval-create') {
90
+ this._handleApprovalCreate(req, res);
91
+ }
92
+ else if (req.method === 'POST' && req.url === '/hooks/pre-tool-use') {
93
+ this._handlePreToolUse(req, res);
94
+ }
95
+ else if (req.method === 'POST' && req.url === '/hooks/stop-notify') {
96
+ this._handleStopNotify(req, res);
97
+ }
98
+ else if (req.method === 'POST' && req.url === '/feishu-mode') {
99
+ this._handleFeishuMode(req, res);
100
+ }
101
+ else if (req.method === 'GET' && req.url === '/doctor') {
102
+ this._handleDoctor(req, res);
103
+ }
104
+ else {
105
+ res.writeHead(404);
106
+ res.end();
107
+ }
108
+ }
109
+ // ---- /notify ----
110
+ _handleNotify(req, res) {
111
+ let body = '';
112
+ req.on('data', (chunk) => { body += chunk; });
113
+ req.on('end', async () => {
114
+ this._log('POST /notify body=' + body);
115
+ try {
116
+ const payload = JSON.parse(body);
117
+ const { chatId } = payload;
118
+ this._log(`chatId=${chatId}, type=${payload.type || 'text'}`);
119
+ if (!chatId) {
120
+ this._log('400 missing chatId');
121
+ res.writeHead(400);
122
+ res.end(JSON.stringify({ ok: false, error: 'missing chatId' }));
123
+ return;
124
+ }
125
+ if (payload.type === 'post' && payload.title && payload.paragraphs) {
126
+ await this.feishuReplier.sendPost(chatId, payload.title, payload.paragraphs);
127
+ this._log('sendPost OK');
128
+ }
129
+ else if (payload.type === 'card' && payload.card) {
130
+ await this.feishuReplier.sendCard(chatId, payload.card);
131
+ this._log('sendCard OK');
132
+ }
133
+ else if (payload.text) {
134
+ await this.feishuReplier.sendText(chatId, payload.text);
135
+ this._log('sendText OK');
136
+ }
137
+ else {
138
+ this._log('400 no valid content');
139
+ res.writeHead(400);
140
+ res.end(JSON.stringify({ ok: false, error: 'no valid content' }));
141
+ return;
142
+ }
143
+ res.writeHead(200, { 'Content-Type': 'application/json' });
144
+ res.end(JSON.stringify({ ok: true }));
145
+ }
146
+ catch (err) {
147
+ this._log('500 error: ' + (err instanceof Error ? err.message : String(err)));
148
+ res.writeHead(500, { 'Content-Type': 'application/json' });
149
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
150
+ }
151
+ });
152
+ }
153
+ // ---- /approval-create ----
154
+ _handleApprovalCreate(req, res) {
155
+ let body = '';
156
+ req.on('data', (chunk) => { body += chunk; });
157
+ req.on('end', async () => {
158
+ this._log('POST /approval-create body=' + body);
159
+ try {
160
+ const { chatId, requestId, toolName, toolInput } = JSON.parse(body);
161
+ if (this.config.enableFeishu === false) {
162
+ this._log('approval-create: feishu disabled, skip');
163
+ res.writeHead(200, { 'Content-Type': 'application/json' });
164
+ res.end(JSON.stringify({ ok: true, skipped: true }));
165
+ return;
166
+ }
167
+ if (chatId && requestId && toolName) {
168
+ const safeId = requestId.replace(/[^a-zA-Z0-9_-]/g, '_');
169
+ const card = (0, core_1.buildApprovalCard)(toolName, toolInput, safeId);
170
+ await this.feishuReplier.sendCard(chatId, card);
171
+ this._log('approval card sent for ' + toolName);
172
+ }
173
+ res.writeHead(200, { 'Content-Type': 'application/json' });
174
+ res.end(JSON.stringify({ ok: true }));
175
+ }
176
+ catch (err) {
177
+ this._log('approval-create error: ' + (err instanceof Error ? err.message : String(err)));
178
+ res.writeHead(500);
179
+ res.end(JSON.stringify({ ok: false }));
180
+ }
181
+ });
182
+ }
183
+ // ---- /hooks/pre-tool-use ----
184
+ _handlePreToolUse(req, res) {
185
+ req.socket.setTimeout(0);
186
+ let body = '';
187
+ req.on('data', (chunk) => { body += chunk; });
188
+ req.on('end', async () => {
189
+ this._log('POST /hooks/pre-tool-use body=' + body);
190
+ try {
191
+ const hookInput = JSON.parse(body);
192
+ const toolName = hookInput.tool_name || '';
193
+ const toolInput = hookInput.tool_input || {};
194
+ const permissionMode = hookInput.permission_mode || 'default';
195
+ const projectDir = hookInput.project || this.targetDir;
196
+ const contextPath = (0, core_1.dataPathFor)(projectDir, 'cc_remote_context.json');
197
+ let ctx = {};
198
+ if (fs.existsSync(contextPath)) {
199
+ try {
200
+ ctx = JSON.parse(fs.readFileSync(contextPath, 'utf-8'));
201
+ }
202
+ catch { /* ignore */ }
203
+ }
204
+ // close mode → empty response → Claude falls back to native
205
+ if (ctx.mode !== 'remote') {
206
+ this._log('pre-tool-use: mode not remote, skip');
207
+ res.writeHead(204);
208
+ res.end();
209
+ return;
210
+ }
211
+ if (permissionMode === 'bypassPermissions') {
212
+ this._log(`pre-tool-use: bypassPermissions, allow ${toolName}`);
213
+ res.writeHead(200, { 'Content-Type': 'application/json' });
214
+ res.end(JSON.stringify({
215
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'bypassPermissions mode' },
216
+ }));
217
+ return;
218
+ }
219
+ const allowPatterns = (0, core_1.loadAllowPatterns)(projectDir);
220
+ for (const pattern of allowPatterns) {
221
+ if ((0, core_1.matchesPermission)(toolName, toolInput, pattern)) {
222
+ this._log(`pre-tool-use: matched "${pattern}", allow ${toolName}`);
223
+ res.writeHead(200, { 'Content-Type': 'application/json' });
224
+ res.end(JSON.stringify({
225
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: `匹配权限规则: ${pattern}` },
226
+ }));
227
+ return;
228
+ }
229
+ }
230
+ // Safe tools: auto-allow without approval
231
+ if ((0, core_1.isSafeTool)(toolName)) {
232
+ this._log(`pre-tool-use: safe tool ${toolName}, auto-allow`);
233
+ res.writeHead(200, { 'Content-Type': 'application/json' });
234
+ res.end(JSON.stringify({
235
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: `安全工具自动放行` },
236
+ }));
237
+ if (toolName === 'AskUserQuestion') {
238
+ const channel = ctx.channel || 'feishu';
239
+ if (channel === 'weixin') {
240
+ const wxUserId = ctx.userId || '';
241
+ if (wxUserId) {
242
+ await this.weixinReplier?.sendText(wxUserId, (0, core_1.formatAskUserQuestion)(toolInput), ctx.contextToken);
243
+ }
244
+ }
245
+ else {
246
+ const chatId = ctx.chatId || '';
247
+ if (chatId) {
248
+ const card = (0, core_1.buildAskUserQuestionCard)(toolInput);
249
+ await this.feishuReplier.sendCard(chatId, card);
250
+ }
251
+ }
252
+ }
253
+ if (toolName === 'ExitPlanMode') {
254
+ const channel = ctx.channel || 'feishu';
255
+ if (channel === 'weixin') {
256
+ const wxUserId = ctx.userId || '';
257
+ if (wxUserId) {
258
+ await this.weixinReplier?.sendText(wxUserId, (0, core_1.formatExitPlanMode)(toolInput), ctx.contextToken);
259
+ }
260
+ }
261
+ else {
262
+ const chatId = ctx.chatId || '';
263
+ if (chatId) {
264
+ const card = (0, core_1.buildExitPlanModeCard)(toolInput);
265
+ await this.feishuReplier.sendCard(chatId, card);
266
+ }
267
+ }
268
+ }
269
+ return;
270
+ }
271
+ const channel = ctx.channel || 'feishu';
272
+ let decision;
273
+ // ---- WeChat text-based approval ----
274
+ if (channel === 'weixin') {
275
+ const wxUserId = ctx.userId || '';
276
+ if (!wxUserId) {
277
+ this._log('pre-tool-use: no weixin userId, deny');
278
+ res.writeHead(200, { 'Content-Type': 'application/json' });
279
+ res.end(JSON.stringify({
280
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: '无微信会话上下文' },
281
+ }));
282
+ return;
283
+ }
284
+ const approvalText = `🟢 工具审批: ${toolName}\n${JSON.stringify(toolInput, null, 2)}\n\n回复 go=允许 / no=拒绝 / ok=始终允许`;
285
+ await this.weixinReplier?.sendText(wxUserId, approvalText, ctx.contextToken);
286
+ this._log(`weixin approval text sent for ${toolName}, userId=${wxUserId}`);
287
+ const timeoutMs = (this.config.approvalTimeoutS || 1800) * 1000;
288
+ decision = await new Promise((resolve) => {
289
+ const timer = setTimeout(() => {
290
+ this.wxPendingApprovals.delete(wxUserId);
291
+ this._log(`weixin approval timeout for ${toolName}`);
292
+ resolve('deny');
293
+ }, timeoutMs);
294
+ this.wxPendingApprovals.set(wxUserId, { userId: wxUserId, toolName, resolve, timer });
295
+ });
296
+ }
297
+ else {
298
+ // ---- Feishu card-based approval ----
299
+ const chatId = ctx.chatId || '';
300
+ if (!chatId) {
301
+ this._log('pre-tool-use: no chatId, deny');
302
+ res.writeHead(200, { 'Content-Type': 'application/json' });
303
+ res.end(JSON.stringify({
304
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: '无飞书会话上下文' },
305
+ }));
306
+ return;
307
+ }
308
+ const rawId = `${chatId}:${toolName}:${Date.now()}`;
309
+ const requestId = rawId.replace(/[^a-zA-Z0-9_-]/g, '_');
310
+ const card = (0, core_1.buildApprovalCard)(toolName, JSON.stringify(toolInput, null, 2), requestId);
311
+ await this.feishuReplier.sendCard(chatId, card);
312
+ this._log(`approval card sent for ${toolName}, requestId=${requestId}`);
313
+ const timeoutMs = (this.config.approvalTimeoutS || 1800) * 1000;
314
+ decision = await new Promise((resolve) => {
315
+ const timer = setTimeout(() => {
316
+ this.pendingApprovals.delete(requestId);
317
+ this._log(`approval timeout for ${toolName}`);
318
+ resolve('deny');
319
+ }, timeoutMs);
320
+ this.pendingApprovals.set(requestId, { resolve, timer, projectDir });
321
+ });
322
+ }
323
+ // Handle always_allow for WeChat (Feishu does it in handleCardAction)
324
+ if (decision === 'always_allow' && channel === 'weixin') {
325
+ const localSettingsPath = path.join(projectDir, '.claude', 'settings.local.json');
326
+ try {
327
+ let settings = { permissions: { allow: [] } };
328
+ if (fs.existsSync(localSettingsPath)) {
329
+ settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
330
+ }
331
+ if (!settings.permissions)
332
+ settings.permissions = {};
333
+ if (!Array.isArray(settings.permissions.allow))
334
+ settings.permissions.allow = [];
335
+ if (!settings.permissions.allow.includes(toolName)) {
336
+ settings.permissions.allow.push(toolName);
337
+ fs.writeFileSync(localSettingsPath, JSON.stringify(settings, null, 2));
338
+ this._log(`added "${toolName}" to permissions.allow`);
339
+ }
340
+ }
341
+ catch (e) {
342
+ this._log(`failed to update settings: ${e}`);
343
+ }
344
+ }
345
+ const resDecision = (decision === 'deny') ? 'deny' : 'allow';
346
+ const resReason = decision === 'always_allow' ? '用户始终允许' : decision === 'allow' ? '用户允许' : '用户拒绝';
347
+ this._log(`pre-tool-use done: ${toolName} -> ${resDecision} (${resReason})`);
348
+ res.writeHead(200, { 'Content-Type': 'application/json' });
349
+ res.end(JSON.stringify({
350
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: resDecision, permissionDecisionReason: resReason },
351
+ }));
352
+ }
353
+ catch (err) {
354
+ this._log('pre-tool-use error: ' + (err instanceof Error ? err.message : String(err)));
355
+ res.writeHead(200, { 'Content-Type': 'application/json' });
356
+ res.end(JSON.stringify({
357
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: '审批处理异常' },
358
+ }));
359
+ }
360
+ });
361
+ }
362
+ // ---- /hooks/stop-notify ----
363
+ _handleStopNotify(req, res) {
364
+ let body = '';
365
+ req.on('data', (chunk) => { body += chunk; });
366
+ req.on('end', async () => {
367
+ this._log('POST /hooks/stop-notify body=' + body);
368
+ try {
369
+ const hookInput = JSON.parse(body);
370
+ const projectDir = hookInput.project || this.targetDir;
371
+ const contextPath = (0, core_1.dataPathFor)(projectDir, 'cc_remote_context.json');
372
+ let context = {};
373
+ if (fs.existsSync(contextPath)) {
374
+ try {
375
+ context = JSON.parse(fs.readFileSync(contextPath, 'utf-8'));
376
+ }
377
+ catch { /* ignore */ }
378
+ }
379
+ if (context.mode !== 'remote') {
380
+ this._log('stop-notify: mode not remote, skip');
381
+ res.writeHead(200, { 'Content-Type': 'application/json' });
382
+ res.end(JSON.stringify({ ok: true, skipped: true }));
383
+ return;
384
+ }
385
+ const channel = context.channel || 'feishu';
386
+ const chatId = context.chatId;
387
+ const wxUserId = context.userId;
388
+ const wxContextToken = context.contextToken;
389
+ if (channel === 'weixin') {
390
+ if (!wxUserId) {
391
+ this._log('stop-notify: no weixin userId');
392
+ res.writeHead(200, { 'Content-Type': 'application/json' });
393
+ res.end(JSON.stringify({ ok: true, skipped: true }));
394
+ return;
395
+ }
396
+ }
397
+ else {
398
+ if (!chatId) {
399
+ this._log('stop-notify: no chatId');
400
+ res.writeHead(200, { 'Content-Type': 'application/json' });
401
+ res.end(JSON.stringify({ ok: true, skipped: true }));
402
+ return;
403
+ }
404
+ }
405
+ // Parse transcript
406
+ const allMessages = [];
407
+ if (hookInput.transcript_path && fs.existsSync(hookInput.transcript_path)) {
408
+ try {
409
+ const raw = fs.readFileSync(hookInput.transcript_path, 'utf-8');
410
+ const lines = raw.split('\n');
411
+ for (const line of lines) {
412
+ const trimmed = line.trim();
413
+ if (!trimmed)
414
+ continue;
415
+ try {
416
+ allMessages.push(JSON.parse(trimmed));
417
+ }
418
+ catch { /* skip */ }
419
+ }
420
+ }
421
+ catch { /* ignore */ }
422
+ }
423
+ // Extract last assistant text
424
+ let replyText = '';
425
+ for (let i = allMessages.length - 1; i >= 0; i--) {
426
+ const msg = allMessages[i];
427
+ if (msg.role === 'assistant' || msg.type === 'assistant') {
428
+ const content = msg.content || msg.message?.content;
429
+ if (Array.isArray(content)) {
430
+ const parts = [];
431
+ for (const block of content) {
432
+ if (block.type === 'text' && typeof block.text === 'string')
433
+ parts.push(block.text);
434
+ }
435
+ replyText = parts.join('\n');
436
+ }
437
+ else if (typeof content === 'string') {
438
+ replyText = content;
439
+ }
440
+ if (replyText)
441
+ break;
442
+ }
443
+ }
444
+ // Find last user message boundary
445
+ let scanFrom = 0;
446
+ for (let i = allMessages.length - 1; i >= 0; i--) {
447
+ const msg = allMessages[i];
448
+ if (msg.type === 'user' || msg.role === 'user') {
449
+ const blocks = msg.message?.content;
450
+ if (Array.isArray(blocks)) {
451
+ const hasText = blocks.some((b) => b.type === 'text' && typeof b.text === 'string' && b.text.trim());
452
+ if (hasText) {
453
+ scanFrom = i;
454
+ break;
455
+ }
456
+ }
457
+ else if (typeof msg.message?.content === 'string' && msg.message.content.trim()) {
458
+ scanFrom = i;
459
+ break;
460
+ }
461
+ }
462
+ }
463
+ const toolCalls = [];
464
+ for (let idx = scanFrom; idx < allMessages.length; idx++) {
465
+ const msg = allMessages[idx];
466
+ if (msg.type === 'assistant') {
467
+ const blocks = msg.message?.content ?? [];
468
+ for (const b of blocks) {
469
+ if (b.type === 'tool_use' && typeof b.name === 'string') {
470
+ toolCalls.push({ name: b.name, ok: true, input: b.input ?? {} });
471
+ }
472
+ }
473
+ }
474
+ if (msg.type === 'user') {
475
+ const blocks = msg.message?.content ?? [];
476
+ for (const b of blocks) {
477
+ if (b.type === 'tool_result') {
478
+ for (let j = 0; j < toolCalls.length; j++) {
479
+ if (!toolCalls[j].matched) {
480
+ if (b.is_error)
481
+ toolCalls[j].ok = false;
482
+ const content = b.content;
483
+ if (typeof content === 'string') {
484
+ toolCalls[j].output = content;
485
+ }
486
+ else if (Array.isArray(content)) {
487
+ const parts = [];
488
+ for (const c of content) {
489
+ if (c.type === 'text' && typeof c.text === 'string')
490
+ parts.push(c.text);
491
+ else if (typeof c.text === 'string')
492
+ parts.push(c.text);
493
+ }
494
+ toolCalls[j].output = parts.join('\n');
495
+ }
496
+ const tur = msg.toolUseResult;
497
+ if (tur && typeof tur === 'object') {
498
+ if (toolCalls[j].name === 'Edit' || toolCalls[j].name === 'Write') {
499
+ const oldS = String(tur.oldString ?? '');
500
+ const newS = String(tur.newString ?? '');
501
+ if (oldS || newS)
502
+ toolCalls[j].output = oldS + '\n' + newS;
503
+ }
504
+ else if (toolCalls[j].output === undefined && typeof tur.stdout === 'string') {
505
+ toolCalls[j].output = tur.stdout;
506
+ }
507
+ }
508
+ toolCalls[j].matched = true;
509
+ break;
510
+ }
511
+ }
512
+ }
513
+ }
514
+ }
515
+ }
516
+ // Extract usage
517
+ let usage = null;
518
+ let durationMs = 0;
519
+ for (let i = allMessages.length - 1; i >= 0; i--) {
520
+ if (allMessages[i].type === 'result') {
521
+ usage = allMessages[i].usage;
522
+ durationMs = allMessages[i].duration_ms ?? 0;
523
+ break;
524
+ }
525
+ }
526
+ if (!replyText && toolCalls.length === 0) {
527
+ res.writeHead(200, { 'Content-Type': 'application/json' });
528
+ res.end(JSON.stringify({ ok: true, skipped: true }));
529
+ return;
530
+ }
531
+ let sent = false;
532
+ if (channel === 'weixin') {
533
+ const text = replyText.trim() || 'Claude 回复';
534
+ const msgId = await this.weixinReplier?.sendText(wxUserId, text, wxContextToken);
535
+ sent = !!msgId;
536
+ this._log(`stop-notify weixin ${sent ? 'sent OK' : 'FAILED'}`);
537
+ }
538
+ else {
539
+ let payload;
540
+ if (toolCalls.length > 0) {
541
+ const elements = [];
542
+ if (replyText) {
543
+ elements.push({ tag: 'markdown', content: replyText });
544
+ elements.push({ tag: 'hr' });
545
+ }
546
+ for (const t of toolCalls) {
547
+ if (t.name === 'Read' || t.name === 'Grep' || t.name === 'Glob')
548
+ continue;
549
+ const status = t.ok ? '✓' : '✗';
550
+ const parts = [];
551
+ const inp = t.input;
552
+ if (inp.file_path)
553
+ parts.push(`📄 ${String(inp.file_path)}`);
554
+ else if (inp.command)
555
+ parts.push(`💻 ${String(inp.command)}`);
556
+ else if (inp.url)
557
+ parts.push(`🔗 ${String(inp.url)}`);
558
+ else if (inp.pattern)
559
+ parts.push(`🔍 ${String(inp.pattern)}`);
560
+ else if (inp.query)
561
+ parts.push(`🔍 ${String(inp.query)}`);
562
+ if (t.output)
563
+ parts.push(`\`\`\`\n${t.output}\n\`\`\``);
564
+ elements.push({ tag: 'markdown', content: `🔧 **${t.name}** ${status}\n${parts.join('\n')}` });
565
+ elements.push({ tag: 'hr' });
566
+ }
567
+ if (usage) {
568
+ const dur = durationMs ? `${(durationMs / 1000).toFixed(1)}s` : 'N/A';
569
+ elements.push({ tag: 'markdown', content: `📊 Token: 输入 ${(0, core_1.fmtK)(usage.input_tokens)} | 输出 ${(0, core_1.fmtK)(usage.output_tokens)} | 缓存读 ${(0, core_1.fmtK)(usage.cache_read_input_tokens)} | 耗时 ${dur}` });
570
+ }
571
+ payload = {
572
+ chatId,
573
+ type: 'card',
574
+ card: {
575
+ schema: '2.0',
576
+ config: { wide_screen_mode: true },
577
+ header: { title: { tag: 'plain_text', content: 'Claude 执行完成' }, template: 'green' },
578
+ body: { elements },
579
+ },
580
+ };
581
+ }
582
+ else {
583
+ payload = { chatId, type: 'text', text: replyText.trim() || 'Claude 回复' };
584
+ }
585
+ if (payload.type === 'text') {
586
+ const msgId = await this.feishuReplier.sendText(payload.chatId, payload.text);
587
+ sent = !!msgId;
588
+ }
589
+ else {
590
+ const msgId = await this.feishuReplier.sendCard(payload.chatId, payload.card);
591
+ sent = !!msgId;
592
+ }
593
+ this._log(`stop-notify feishu ${sent ? 'sent OK' : 'FAILED'}`);
594
+ }
595
+ // Cleanup: preserve mode, channel, user ids, clear messageId
596
+ try {
597
+ const preserved = { mode: context.mode, channel };
598
+ if (channel === 'weixin') {
599
+ if (context.userId)
600
+ preserved.userId = context.userId;
601
+ if (context.contextToken)
602
+ preserved.contextToken = context.contextToken;
603
+ }
604
+ else {
605
+ if (context.chatId)
606
+ preserved.chatId = context.chatId;
607
+ if (context.openId)
608
+ preserved.openId = context.openId;
609
+ }
610
+ fs.writeFileSync(contextPath, JSON.stringify(preserved));
611
+ }
612
+ catch { /* ignore */ }
613
+ res.writeHead(200, { 'Content-Type': 'application/json' });
614
+ res.end(JSON.stringify({ ok: true }));
615
+ }
616
+ catch (err) {
617
+ this._log('stop-notify error: ' + (err instanceof Error ? err.message : String(err)));
618
+ res.writeHead(500, { 'Content-Type': 'application/json' });
619
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
620
+ }
621
+ });
622
+ }
623
+ // ---- /feishu-mode ----
624
+ _handleFeishuMode(req, res) {
625
+ let body = '';
626
+ req.on('data', (chunk) => { body += chunk; });
627
+ req.on('end', () => {
628
+ this._log('POST /feishu-mode body=' + body);
629
+ try {
630
+ const { mode, project } = JSON.parse(body);
631
+ if (mode !== 'remote' && mode !== 'close') {
632
+ res.writeHead(400, { 'Content-Type': 'application/json' });
633
+ res.end(JSON.stringify({ ok: false, error: 'mode must be remote or close' }));
634
+ return;
635
+ }
636
+ const contextPath = (0, core_1.dataPathFor)(project || this.targetDir, 'cc_remote_context.json');
637
+ let ctx = {};
638
+ if (fs.existsSync(contextPath)) {
639
+ try {
640
+ ctx = JSON.parse(fs.readFileSync(contextPath, 'utf-8'));
641
+ }
642
+ catch { /* ignore */ }
643
+ }
644
+ ctx.mode = mode;
645
+ fs.writeFileSync(contextPath, JSON.stringify(ctx));
646
+ const channelLabel = ctx.channel === 'weixin' ? '微信' : '飞书';
647
+ const label = mode === 'remote' ? `🔗 ${channelLabel}远程模式已启用` : `🔌 ${channelLabel}远程模式已关闭`;
648
+ this._log('feishu-mode: ' + label);
649
+ res.writeHead(200, { 'Content-Type': 'application/json' });
650
+ res.end(JSON.stringify({ ok: true, mode, message: label }));
651
+ }
652
+ catch (err) {
653
+ this._log('feishu-mode error: ' + (err instanceof Error ? err.message : String(err)));
654
+ res.writeHead(500, { 'Content-Type': 'application/json' });
655
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
656
+ }
657
+ });
658
+ }
659
+ // ---- /doctor ----
660
+ async _handleDoctor(_req, res) {
661
+ const contextPath = (0, core_1.dataPath)(this.targetDir, 'cc_remote_context.json');
662
+ const settingsLocalPath = path.join(this.targetDir, '.claude', 'settings.local.json');
663
+ let settings = {};
664
+ try {
665
+ settings = JSON.parse(fs.readFileSync(settingsLocalPath, 'utf-8'));
666
+ }
667
+ catch { /* ignore */ }
668
+ let configOk = false;
669
+ let configFields = [];
670
+ try {
671
+ const c = JSON.parse(fs.readFileSync(this.configPath, 'utf-8'));
672
+ configOk = !!(c.appId && c.appSecret);
673
+ configFields = Object.keys(c).filter(k => c[k]);
674
+ }
675
+ catch { /* ignore */ }
676
+ const pt = settings?.hooks?.PreToolUse;
677
+ const st = settings?.hooks?.Stop;
678
+ const hooksPreToolUse = Array.isArray(pt) && pt.some((e) => (e.matcher || '') === '' && Array.isArray(e.hooks) && e.hooks.length > 0);
679
+ const hooksStop = Array.isArray(st) && st.some((e) => (e.matcher || '') === '' && Array.isArray(e.hooks) && e.hooks.length > 0);
680
+ const hookCommands = {
681
+ preToolUse: Array.isArray(pt) ? pt.flatMap((e) => (e.hooks || []).map((h) => h.command || '').filter(Boolean)) : [],
682
+ stop: Array.isArray(st) ? st.flatMap((e) => (e.hooks || []).map((h) => h.command || '').filter(Boolean)) : [],
683
+ };
684
+ const permissionsAllow = settings?.permissions?.allow || [];
685
+ const hasCcRemote = permissionsAllow.some((r) => r.includes('cc-remote'));
686
+ let contextMode = 'unknown';
687
+ let contextChatId = '';
688
+ try {
689
+ const ctx = JSON.parse(fs.readFileSync(contextPath, 'utf-8'));
690
+ contextMode = ctx.mode || 'close';
691
+ contextChatId = ctx.chatId || '';
692
+ }
693
+ catch { /* ignore */ }
694
+ // Feishu API test
695
+ let apiOk = false;
696
+ let apiError = '';
697
+ try {
698
+ const { createFeishuClient } = require('./feishu/client');
699
+ const testClient = createFeishuClient({ appId: this.config.appId, appSecret: this.config.appSecret });
700
+ await testClient.im.v1.chat.list({ params: { page_size: 1 } });
701
+ apiOk = true;
702
+ }
703
+ catch (e) {
704
+ const msg = String(e.message || e);
705
+ if (msg.includes('auth') || msg.includes('AppNotFound') || msg.includes('app_id')) {
706
+ apiError = '认证失败: appId/appSecret 无效';
707
+ }
708
+ else {
709
+ apiError = msg.slice(0, 200);
710
+ apiOk = true;
711
+ }
712
+ }
713
+ const d = this.diag;
714
+ const relayPort = this.config.relayPort || 19200;
715
+ const report = {
716
+ time: new Date().toISOString(),
717
+ config: { ok: configOk, path: this.configPath, fields: configFields, hint: configOk ? 'OK' : 'config.json 缺少 appId 或 appSecret' },
718
+ hooks: { preToolUse: hooksPreToolUse, stop: hooksStop, hookCommands, hint: (hooksPreToolUse && hooksStop) ? 'OK' : 'Hook 未配置,飞书 remote 模式下审批和通知不会工作' },
719
+ permissions: { allow: permissionsAllow, hasCcRemote, hint: hasCcRemote ? 'OK' : '缺少 cc-remote 权限规则,hook 可能无法执行' },
720
+ connection: {
721
+ wsConnected: d.wsConnected,
722
+ wsConnectTime: d.wsConnectTime ? new Date(d.wsConnectTime).toISOString() : null,
723
+ wsUptimeSec: d.wsConnectTime ? Math.floor((Date.now() - d.wsConnectTime) / 1000) : 0,
724
+ relayRunning: this.running,
725
+ hint: d.wsConnected ? 'OK' : 'WebSocket 未连接,检查网络和飞书后台配置',
726
+ },
727
+ relay: { port: relayPort, running: this.running, hint: this.running ? `OK (127.0.0.1:${relayPort})` : '未运行' },
728
+ pty: { pid: d.ptyPid || null, alive: d.ptyAlive || false, hint: d.ptyPid ? `运行中 PID=${d.ptyPid}` : '未启动' },
729
+ weixin: {
730
+ enabled: d.weixinEnabled || false,
731
+ clientStarted: d.weixinClientStarted || false,
732
+ accountId: d.weixinAccountId || null,
733
+ hint: d.weixinClientStarted ? 'OK' : (d.weixinEnabled ? '客户端未启动' : '未启用'),
734
+ },
735
+ messages: {
736
+ lastReceiveTime: d.lastMessageTime ? new Date(d.lastMessageTime).toISOString() : null,
737
+ lastReceiveText: (d.lastMessageText || '').slice(0, 200),
738
+ lastSendError: d.lastSendError || null,
739
+ pendingApprovals: this.pendingApprovals.size,
740
+ hint: d.lastMessageTime ? 'OK' : '从未收到飞书消息,检查机器人是否上线、事件是否订阅',
741
+ },
742
+ api: { ok: apiOk, error: apiError || null, hint: apiOk ? 'OK' : `飞书 API 不通: ${apiError}` },
743
+ context: { mode: contextMode, chatId: contextChatId || '(空)', hint: contextMode === 'remote' ? '当前为 remote 模式' : '当前为 close 模式(飞书消息仅转发到终端,不触发 hook 审批)' },
744
+ };
745
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
746
+ res.end(JSON.stringify(report, null, 2));
747
+ }
748
+ // ---- handleCardAction (consolidated from main.ts handleCardAction + dead card-action.ts) ----
749
+ async handleCardAction(raw) {
750
+ const log = (msg) => {
751
+ const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
752
+ try {
753
+ fs.appendFileSync((0, core_1.dataPath)(this.targetDir, `card-action-${date}.log`), `[${new Date().toISOString()}] ${msg}\n`);
754
+ }
755
+ catch { /* ignore */ }
756
+ };
757
+ try {
758
+ log(`raw: ${JSON.stringify(raw)}`);
759
+ const data = raw;
760
+ const event = (data['event'] ?? data);
761
+ log(`event keys: ${Object.keys(event).join(', ')}`);
762
+ const messageId = (event['open_message_id'] ?? event['context']?.['open_message_id'] ?? '');
763
+ log(`messageId: ${messageId}`);
764
+ const actionObj = (event['action'] ?? {});
765
+ log(`actionObj keys: ${Object.keys(actionObj).join(', ')}, value: ${String(actionObj['value']).slice(0, 200)}`);
766
+ const rawValue = actionObj['value'];
767
+ if (!rawValue) {
768
+ log('BAIL: no value');
769
+ return {};
770
+ }
771
+ let value;
772
+ if (typeof rawValue === 'string') {
773
+ value = JSON.parse(rawValue);
774
+ if (typeof value === 'string') {
775
+ log(`double-encoded value, parsing again`);
776
+ value = JSON.parse(value);
777
+ }
778
+ }
779
+ else if (typeof rawValue === 'object') {
780
+ value = rawValue;
781
+ }
782
+ else {
783
+ log(`BAIL: unexpected value type: ${typeof rawValue}`);
784
+ return {};
785
+ }
786
+ log(`parsed value: cmd=${value.cmd}, requestId=${value.requestId}, decision=${value.decision}, toolName=${value.toolName}`);
787
+ if (value.cmd !== '__approve') {
788
+ log(`BAIL: cmd=${value.cmd} != __approve`);
789
+ return {};
790
+ }
791
+ const requestId = value.requestId;
792
+ const rawDecision = value.decision;
793
+ const toolName = value.toolName || '';
794
+ const resDecision = (rawDecision === 'deny') ? 'deny' : 'allow';
795
+ const pending = this.pendingApprovals.get(requestId);
796
+ if (pending) {
797
+ clearTimeout(pending.timer);
798
+ this.pendingApprovals.delete(requestId);
799
+ log(`resolving pending approval: ${requestId} -> ${resDecision}`);
800
+ pending.resolve(resDecision);
801
+ }
802
+ const projDir = pending?.projectDir || this.targetDir;
803
+ const resPath = (0, core_1.dataPathFor)(projDir, `approval_res_${requestId}.json`);
804
+ if (rawDecision === 'always_allow') {
805
+ const localSettingsPath = path.join(projDir, '.claude', 'settings.local.json');
806
+ try {
807
+ let settings = { permissions: { allow: [] } };
808
+ if (fs.existsSync(localSettingsPath)) {
809
+ settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
810
+ }
811
+ if (!settings.permissions)
812
+ settings.permissions = {};
813
+ if (!Array.isArray(settings.permissions.allow))
814
+ settings.permissions.allow = [];
815
+ if (!settings.permissions.allow.includes(toolName)) {
816
+ settings.permissions.allow.push(toolName);
817
+ fs.writeFileSync(localSettingsPath, JSON.stringify(settings, null, 2));
818
+ log(`added "${toolName}" to permissions.allow in settings.local.json`);
819
+ }
820
+ }
821
+ catch (e) {
822
+ log(`failed to update settings: ${e}`);
823
+ }
824
+ }
825
+ log(`writing res file: ${resPath} -> decision=${resDecision}`);
826
+ fs.writeFileSync(resPath, JSON.stringify({ decision: resDecision }));
827
+ log(`res file written, exists: ${fs.existsSync(resPath)}`);
828
+ if (messageId && toolName) {
829
+ log(`patching card: messageId=${messageId}, toolName=${toolName}`);
830
+ this.feishuReplier.patchCard(messageId, (0, core_1.buildResolvedApprovalCard)(toolName, rawDecision))
831
+ .then(() => log('patchCard OK'))
832
+ .catch((e) => log(`patchCard FAIL: ${e}`));
833
+ }
834
+ else {
835
+ log(`skip patchCard: messageId=${messageId}, toolName=${toolName}`);
836
+ }
837
+ const toast = rawDecision === 'deny'
838
+ ? { type: 'warning', content: '❌ 已拒绝' }
839
+ : rawDecision === 'always_allow'
840
+ ? { type: 'success', content: '🟢 已始终允许并记住' }
841
+ : { type: 'success', content: '✅ 已允许' };
842
+ log(`returning toast: ${toast.content}`);
843
+ return { toast };
844
+ }
845
+ catch (err) {
846
+ log(`EXCEPTION: ${err}`);
847
+ return { toast: { type: 'error', content: '审批处理失败' } };
848
+ }
849
+ }
850
+ }
851
+ exports.CoreRelay = CoreRelay;