chatcc-agent 0.4.0 → 0.5.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatcc-agent",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "CCLink Agent - bridges Claude Code CLI with instant messaging",
5
5
  "bin": {
6
6
  "chatcc": "src/cli.js"
@@ -64,6 +64,7 @@ class ClaudeBridge {
64
64
  this._pendingPermissionRequests = new Map();
65
65
  this._toolUseBlockQueue = new Map();
66
66
  this._askedQuestionIds = new Map(); // msgID → Set<tool_use_id>
67
+ this._pendingQuestionRequests = new Map(); // tool_use_id → { requestId, msgID, replyTo, sessionID, questions }
67
68
  this._toolMeta = new Map(); // tool_use_id → { toolName, input }
68
69
  this._queue = new ProcessQueue({
69
70
  maxConcurrency: options.maxConcurrency || 5,
@@ -155,6 +156,45 @@ class ClaudeBridge {
155
156
  return true;
156
157
  }
157
158
 
159
+ resolveQuestionAnswer(toolUseID, answers, from) {
160
+ const req = this._pendingQuestionRequests.get(toolUseID);
161
+ if (!req) {
162
+ console.warn(`[Bridge] resolveQuestionAnswer: no pending question for tool_use_id=${toolUseID}`);
163
+ return false;
164
+ }
165
+ // Only the client who owns this session may answer
166
+ if (from && req.replyTo && req.replyTo !== from) {
167
+ console.warn(`[Security] question_answer rejected: ${from} is not the session owner (${req.replyTo})`);
168
+ return false;
169
+ }
170
+ this._pendingQuestionRequests.delete(toolUseID);
171
+
172
+ const entry = this._activeProcesses.get(req.msgID);
173
+ if (!entry) {
174
+ console.warn(`[Bridge] resolveQuestionAnswer: process gone for tool_use_id=${toolUseID}`);
175
+ return false;
176
+ }
177
+
178
+ // Write the answer back to the waiting Claude process as a control_response.
179
+ // The SDK maps updatedInput.answers (keyed by question text) into the tool_result.
180
+ this._writeToStdin(entry.proc, {
181
+ type: 'control_response',
182
+ response: {
183
+ subtype: 'success',
184
+ request_id: req.requestId,
185
+ response: {
186
+ behavior: 'allow',
187
+ updatedInput: {
188
+ questions: req.questions,
189
+ answers: answers || {},
190
+ annotations: {},
191
+ },
192
+ },
193
+ },
194
+ });
195
+ return true;
196
+ }
197
+
158
198
  _handlePermissionRequest(event, replyTo, msgID, sessionID, cwd, workspaceRestricted, permissions) {
159
199
  const req = event.request;
160
200
  const requestId = req.request_id;
@@ -599,6 +639,19 @@ class ClaudeBridge {
599
639
  const req = event.request;
600
640
  if (req?.subtype === 'permission') {
601
641
  this._handlePermissionRequest(event, replyTo, msgID, sessionID, cwd, workspaceRestricted, permissions);
642
+ } else if (req?.subtype === 'can_use_tool' && req.tool_name === 'AskUserQuestion') {
643
+ // AskUserQuestion asks for the user's selection via the permission-prompt-tool
644
+ // stdio protocol. The assistant tool_use already pushed a user_question to the
645
+ // client (see _handleAssistantEvent); here we only record the control_request's
646
+ // request_id so the client's answer can be written back as a control_response.
647
+ const toolUseID = req.tool_use_id;
648
+ if (toolUseID) {
649
+ this._pendingQuestionRequests.set(toolUseID, {
650
+ requestId: event.request_id,
651
+ msgID, replyTo, sessionID,
652
+ questions: req.input?.questions || [],
653
+ });
654
+ }
602
655
  }
603
656
  break;
604
657
  }
package/src/config.js CHANGED
@@ -48,15 +48,20 @@ function callCloudFunction(action, params, env) {
48
48
  const postData = JSON.stringify({ type: action, ...params });
49
49
  const url = new URL(httpTrigger + '/index');
50
50
 
51
+ // traceId 贯穿到云函数:有当前请求上下文就带 X-Trace-Id 头,便于跨层关联
52
+ const traceId = require('./trace').getTraceId();
53
+ const headers = {
54
+ 'Content-Type': 'application/json',
55
+ 'Content-Length': Buffer.byteLength(postData),
56
+ };
57
+ if (traceId) headers['X-Trace-Id'] = traceId;
58
+
51
59
  const options = {
52
60
  hostname: url.hostname,
53
61
  port: 443,
54
62
  path: url.pathname,
55
63
  method: 'POST',
56
- headers: {
57
- 'Content-Type': 'application/json',
58
- 'Content-Length': Buffer.byteLength(postData),
59
- },
64
+ headers,
60
65
  };
61
66
 
62
67
  const req = require('https').request(options, (res) => {
@@ -399,5 +404,5 @@ module.exports = {
399
404
  CREDENTIALS_FILE, startRenewalTimer, renewAgentNow,
400
405
  readPairedClients, addPairedClient, removePairedClient,
401
406
  tryRenewFromRaw, startClientValidationTimer,
402
- validateAgentExists, clearLocalState,
407
+ validateAgentExists, clearLocalState, readCachedCredentials,
403
408
  };
package/src/im-client.js CHANGED
@@ -149,7 +149,10 @@ class IMClient {
149
149
  // Stamp a monotonic seq onto every outgoing message (including chunks).
150
150
  // The iOS client uses it as the authoritative sort key. Placed here so it
151
151
  // is impossible to send without one — this is the sole send path.
152
+ // traceId:当前请求上下文有就带上,跨 hop 续链。
153
+ const traceId = require('./trace').getTraceId();
152
154
  payload = { ...payload, seq: this._seqStore.next() };
155
+ if (traceId) payload.trace_id = traceId;
153
156
  const message = this.tim.createCustomMessage({
154
157
  to,
155
158
  conversationType: this.TIM.TYPES.CONV_C2C,
@@ -70,10 +70,13 @@ function downloadImage(url, tempFiles) {
70
70
  // we later declare to Claude, so it must match the actual bytes.
71
71
  const realExt = CONTENT_TYPE_EXT[contentType] || urlExt;
72
72
  if (realExt !== urlExt) {
73
+ console.log(`[Image] Realign ext: url=${urlExt} -> ${realExt} (content-type=${contentType})`);
73
74
  tempFiles.delete(writePath);
74
75
  fs.unlink(writePath, () => {});
75
76
  writePath = makePath(realExt);
76
77
  tempFiles.add(writePath);
78
+ } else {
79
+ console.log(`[Image] ext=${realExt} (content-type=${contentType})`);
77
80
  }
78
81
 
79
82
  const contentLength = parseInt(res.headers['content-length'], 10);
@@ -90,7 +93,11 @@ function downloadImage(url, tempFiles) {
90
93
  }
91
94
  });
92
95
  res.pipe(file);
93
- file.on('finish', () => { file.close(); resolve(writePath); });
96
+ file.on('finish', () => {
97
+ file.close();
98
+ console.log(`[Image] Downloaded ${downloaded} bytes -> ${path.basename(writePath)}`);
99
+ resolve(writePath);
100
+ });
94
101
  }).on('error', fail);
95
102
  }).catch(fail);
96
103
  }
package/src/index.js CHANGED
@@ -15,6 +15,7 @@ const fs = require('fs');
15
15
  const path = require('path');
16
16
  const os = require('os');
17
17
  const crypto = require('crypto');
18
+ const logUploader = require('./log-uploader');
18
19
 
19
20
  const isDaemon = process.argv.includes('--daemon');
20
21
  const CHATCC_DIR = path.join(os.homedir(), '.chatcc');
@@ -32,8 +33,14 @@ if (isDaemon) {
32
33
  const ts = () => new Date().toISOString().slice(0, 19);
33
34
  const bindStream = (stream) => {
34
35
  console.log = (...args) => { origLog(...args); stream.write(`[${ts()}] ` + args.join(' ') + '\n'); };
35
- console.warn = (...args) => { origWarn(...args); stream.write(`[${ts()}] WARN ` + args.join(' ') + '\n'); };
36
- console.error = (...args) => { origErr(...args); stream.write(`[${ts()}] ERROR ` + args.join(' ') + '\n'); };
36
+ console.warn = (...args) => {
37
+ origWarn(...args); stream.write(`[${ts()}] WARN ` + args.join(' ') + '\n');
38
+ logUploader.collect('WARN', args.join(' ')); // 采样上报到 CLS(未 start 时为空操作)
39
+ };
40
+ console.error = (...args) => {
41
+ origErr(...args); stream.write(`[${ts()}] ERROR ` + args.join(' ') + '\n');
42
+ logUploader.collect('ERROR', args.join(' '));
43
+ };
37
44
  };
38
45
  bindStream(logStream);
39
46
  cleanupOldLogs(CHATCC_DIR);
@@ -127,6 +134,7 @@ async function shutdown(signal) {
127
134
  if (termBridge) await termBridge.killAll();
128
135
  cleanupTempFiles();
129
136
  sessions.flush();
137
+ await logUploader.stop(); // 退出前把缓冲日志 flush 到云函数
130
138
  if (imClient) {
131
139
  try { await imClient.logout(); } catch {}
132
140
  }
@@ -171,6 +179,9 @@ async function main() {
171
179
 
172
180
  const config = await loadConfig();
173
181
 
182
+ // 启动日志上报(采样 error/warn → 云函数 → CLS);仅 daemon 模式上报,前台调试只落本地
183
+ if (isDaemon) logUploader.start({ agentUserID: config.agentUserID, tcbEnv: config.tcbEnv });
184
+
174
185
  console.log('╔══════════════════════════════════════╗');
175
186
  console.log('║ CCLink Agent v' + getVersion() + ' ║');
176
187
  console.log('╚══════════════════════════════════════╝');
@@ -282,6 +293,9 @@ async function main() {
282
293
 
283
294
  // Route incoming messages
284
295
  imClient.onMessage(async (from, data) => {
296
+ // 建立请求级 traceId 上下文:沿用入站 trace_id(跨 hop 续链),没有则生成。
297
+ // 后续所有出站 IM/HTTP 调用、日志都会自动带上。
298
+ require('./trace').enterWithTrace(data && data.trace_id);
285
299
  const qs = bridge.getQueueStatus();
286
300
  console.log(`[Message] from=${from} cc_type=${data.cc_type} queue_active=${qs.activeCount} queue_waiting=${qs.queuedCount}`);
287
301
 
@@ -377,6 +391,7 @@ async function main() {
377
391
  // If every image failed, there's nothing to analyze — tell the client instead
378
392
  // of silently feeding an empty (or text-only) prompt to Claude.
379
393
  if (ok.length === 0) {
394
+ console.warn(`[Image] All ${paths.length} image(s) failed to download in session ${sid}; notifying client`);
380
395
  imClient.sendCustomMessage(from, 'error', {
381
396
  type: 'image_download',
382
397
  session_id: sid,
@@ -424,6 +439,16 @@ async function main() {
424
439
  break;
425
440
  }
426
441
 
442
+ case 'question_answer': {
443
+ // Structured answer to an AskUserQuestion. data.answers is keyed by question text;
444
+ // value is the chosen label (single-select) or array of labels (multi-select).
445
+ console.log(`[Question] tool_use_id=${data.tool_use_id} from=${from} answers=${JSON.stringify(data.answers)}`);
446
+ if (bridge) {
447
+ bridge.resolveQuestionAnswer(data.tool_use_id, data.answers, from);
448
+ }
449
+ break;
450
+ }
451
+
427
452
  case 'file_tree_request': {
428
453
  if (fileSessionDenied(from, data)) {
429
454
  imClient.sendCustomMessage(from, 'file_tree_response', { request_id: data.request_id, error: 'Access denied' }).catch(() => {});
@@ -0,0 +1,91 @@
1
+ // Agent 日志上报:采集本进程 error/warn 日志 → 采样 + 脱敏 → 批量转发到云函数 agentLog handler。
2
+ // 安全:CLS 写密钥留在服务端,本模块只把日志交给云函数,不直接碰 CLS(密钥不下发到用户机器)。
3
+ // 防风暴:每分钟采集上限,单批上限;flush 失败静默且绝不再打日志(防递归)。
4
+ const config = require('./config');
5
+ const { getTraceId } = require('./trace');
6
+
7
+ let AGENT_VERSION = 'unknown';
8
+ try { AGENT_VERSION = require('../package.json').version; } catch (e) {}
9
+
10
+ const MAX_BATCH = 50;
11
+ const FLUSH_INTERVAL_MS = 60 * 1000; // 每 60s 定时 flush 一次
12
+ const PER_MINUTE_CAP = 30; // 每分钟最多采集 30 条(采样上限,控成本)
13
+
14
+ let _buffer = [];
15
+ let _sentThisMinute = 0;
16
+ let _minuteStart = 0;
17
+ let _timer = null;
18
+ let _cfg = null; // { agentUserID, tcbEnv }
19
+
20
+ // 脱敏:抹掉用户名路径、token/密钥片段,避免隐私/凭证进 CLS
21
+ function sanitize(input) {
22
+ let s = (typeof input === 'string') ? input : String(input);
23
+ s = s.replace(/\/Users\/[^/\s"']+/g, '/Users/***');
24
+ s = s.replace(/\/home\/[^/\s"']+/g, '/home/***');
25
+ s = s.replace(/C:\\Users\\[^\\\s"']+/g, 'C:\\Users\\***');
26
+ s = s.replace(/(token|secret|password|passwd|apikey|api_key|authorization)["']?\s*[:=]\s*"?[^\s"',}]+/gi, '$1=***');
27
+ if (s.length > 4000) s = s.slice(0, 4000) + '…[truncated]';
28
+ return s;
29
+ }
30
+
31
+ // 采集一条 error/warn 日志。绝不调用 console.*(否则与 console patch 互相递归)。
32
+ function collect(level, msg, extra) {
33
+ if (!_cfg) return; // 未 start,不采集
34
+ const now = Date.now();
35
+ if (_minuteStart === 0) _minuteStart = now;
36
+ if (now - _minuteStart > 60000) { _minuteStart = now; _sentThisMinute = 0; }
37
+ if (_sentThisMinute >= PER_MINUTE_CAP) return; // 触顶采样,丢弃
38
+ _sentThisMinute++;
39
+
40
+ const entry = {
41
+ ts: new Date(now).toISOString(),
42
+ level,
43
+ msg: sanitize(msg),
44
+ traceId: getTraceId() || '',
45
+ };
46
+ if (extra && typeof extra === 'object') {
47
+ for (const k of Object.keys(extra)) {
48
+ if (['msg', 'level', 'traceId'].includes(k)) continue;
49
+ const v = extra[k];
50
+ if (v != null) entry[k] = sanitize(v);
51
+ }
52
+ }
53
+ _buffer.push(entry);
54
+ if (_buffer.length >= MAX_BATCH) {
55
+ flush().catch(() => {});
56
+ }
57
+ }
58
+
59
+ // 把当前缓冲区推送到云函数。失败静默(不打日志,防递归/防风暴)。
60
+ async function flush() {
61
+ if (!_cfg || _buffer.length === 0) return;
62
+ const batch = _buffer.splice(0, Math.min(_buffer.length, MAX_BATCH));
63
+ try {
64
+ const cached = config.readCachedCredentials();
65
+ const renew_token = cached && cached.renew_token;
66
+ if (!renew_token) return; // 没凭证就不报,等配对后自然有
67
+ await config.callCloudFunction('agentLog', {
68
+ agent_im_user_id: _cfg.agentUserID,
69
+ renew_token,
70
+ agent_version: AGENT_VERSION,
71
+ logs: batch,
72
+ }, _cfg.tcbEnv);
73
+ } catch (e) {
74
+ // 上报失败:静默丢弃,避免日志上报自身的错误再被采集(递归)
75
+ }
76
+ }
77
+
78
+ function start(cfg) {
79
+ _cfg = cfg;
80
+ _minuteStart = Date.now();
81
+ if (_timer) clearInterval(_timer);
82
+ _timer = setInterval(() => { flush().catch(() => {}); }, FLUSH_INTERVAL_MS);
83
+ _timer.unref(); // 不阻止进程退出
84
+ }
85
+
86
+ async function stop() {
87
+ if (_timer) { clearInterval(_timer); _timer = null; }
88
+ await flush().catch(() => {});
89
+ }
90
+
91
+ module.exports = { start, stop, collect, flush };
package/src/trace.js ADDED
@@ -0,0 +1,30 @@
1
+ // 请求级 traceId:贯穿一次入站请求的所有出站 IM/HTTP 调用 + 日志。
2
+ // 用 AsyncLocalStorage,业务代码不用层层传参;跨 hop 时 sendCustomMessage /
3
+ // callCloudFunction 会把当前 traceId 带上,云函数侧也生成/回传,形成跨层链路。
4
+ const { AsyncLocalStorage } = require('async_hooks');
5
+ const crypto = require('crypto');
6
+
7
+ const als = new AsyncLocalStorage();
8
+
9
+ // 在入站请求边界调用:data 里有 trace_id 就沿用(跨 hop 续链),没有则生成。
10
+ // 两种用法:
11
+ // - startTrace(id, fn):把整段逻辑包进闭包,上下文随 fn 自动传播
12
+ // - enterWithTrace(id):在 handler 顶部同步设上下文,后续 await/出站都带上
13
+ // (适合含多个 early return 的 handler,无需重排主体)
14
+ function startTrace(traceId, fn) {
15
+ const id = (traceId && typeof traceId === 'string') ? traceId : crypto.randomUUID();
16
+ return als.run({ traceId: id }, fn);
17
+ }
18
+
19
+ function enterWithTrace(traceId) {
20
+ const id = (traceId && typeof traceId === 'string') ? traceId : crypto.randomUUID();
21
+ als.enterWith({ traceId: id });
22
+ }
23
+
24
+ // 业务代码/出站调用读取当前 traceId;无上下文时返回 undefined(不强制)
25
+ function getTraceId() {
26
+ const store = als.getStore();
27
+ return store ? store.traceId : undefined;
28
+ }
29
+
30
+ module.exports = { startTrace, enterWithTrace, getTraceId };