coding-tool-x 3.5.11 → 3.5.13

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +11 -1
  3. package/dist/web/assets/{Analytics-Bw-0r4Ys.js → Analytics-B9Shk-uq.js} +1 -1
  4. package/dist/web/assets/{ConfigTemplates-C6QRIeDj.js → ConfigTemplates-Dlkn-_zW.js} +1 -1
  5. package/dist/web/assets/Home-AGf32LvJ.js +1 -0
  6. package/dist/web/assets/Home-BeGkC4bb.css +1 -0
  7. package/dist/web/assets/{PluginManager-Ce-mws6Q.js → PluginManager-BnAjHN4u.js} +1 -1
  8. package/dist/web/assets/{ProjectList-9Z7FFCIT.js → ProjectList-7YV_zk9L.js} +1 -1
  9. package/dist/web/assets/{SessionList-CyETQcs8.js → SessionList-D0un-UKS.js} +1 -1
  10. package/dist/web/assets/{SkillManager-DSUcP1se.js → SkillManager-DhYBmbtd.js} +1 -1
  11. package/dist/web/assets/{WorkspaceManager-Cvbc11bJ.js → WorkspaceManager-JOZIPAGo.js} +1 -1
  12. package/dist/web/assets/icons-QFXzEgqM.js +1 -0
  13. package/dist/web/assets/index-DM3yF1Oo.js +2 -0
  14. package/dist/web/assets/{index-BS7aplYp.css → index-dzKgnRhx.css} +1 -1
  15. package/dist/web/index.html +4 -4
  16. package/package.json +2 -1
  17. package/src/commands/daemon.js +6 -2
  18. package/src/commands/ui.js +8 -3
  19. package/src/index.js +2 -0
  20. package/src/server/api/hooks.js +18 -1
  21. package/src/server/codex-proxy-server.js +22 -6
  22. package/src/server/gemini-proxy-server.js +3 -1
  23. package/src/server/index.js +37 -5
  24. package/src/server/opencode-proxy-server.js +39 -14
  25. package/src/server/proxy-server.js +3 -1
  26. package/src/server/services/base/proxy-utils.js +41 -0
  27. package/src/server/services/base/response-usage-parser.js +137 -59
  28. package/src/server/services/codex-statistics-service.js +1 -0
  29. package/src/server/services/gemini-statistics-service.js +1 -0
  30. package/src/server/services/https-cert.js +202 -0
  31. package/src/server/services/notification-hooks.js +138 -6
  32. package/src/server/services/opencode-gateway-converter.js +3 -0
  33. package/src/server/services/opencode-statistics-service.js +1 -0
  34. package/src/server/services/proxy-log-helper.js +44 -12
  35. package/src/server/services/statistics-service.js +73 -28
  36. package/src/server/services/web-build.js +172 -0
  37. package/src/server/websocket-server.js +18 -1
  38. package/dist/web/assets/Home-BQxQ1LhR.css +0 -1
  39. package/dist/web/assets/Home-B_Z9ythw.js +0 -1
  40. package/dist/web/assets/icons-lKCTySl7.js +0 -1
  41. package/dist/web/assets/index-CTeg1xG-.js +0 -2
@@ -8,6 +8,128 @@
8
8
  * 并确保模型重定向后仍能正确解析不同格式的响应。
9
9
  */
10
10
 
11
+ function readNumericField(source, keys = []) {
12
+ for (const key of keys) {
13
+ if (!source || source[key] === undefined || source[key] === null) {
14
+ continue;
15
+ }
16
+ const value = Number(source[key]);
17
+ if (Number.isFinite(value)) {
18
+ return value;
19
+ }
20
+ }
21
+ return undefined;
22
+ }
23
+
24
+ function readNestedNumericField(source, pathVariants = []) {
25
+ for (const path of pathVariants) {
26
+ let current = source;
27
+ let missing = false;
28
+ for (const segment of path) {
29
+ if (!current || typeof current !== 'object' || !(segment in current)) {
30
+ missing = true;
31
+ break;
32
+ }
33
+ current = current[segment];
34
+ }
35
+ if (missing || current === undefined || current === null) {
36
+ continue;
37
+ }
38
+ const value = Number(current);
39
+ if (Number.isFinite(value)) {
40
+ return value;
41
+ }
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ function parseUsageObject(rawUsage) {
47
+ if (!rawUsage || typeof rawUsage !== 'object') {
48
+ return null;
49
+ }
50
+
51
+ const tokens = {};
52
+ const hasAnthropicCacheFields =
53
+ rawUsage.cache_creation_input_tokens !== undefined ||
54
+ rawUsage.cache_read_input_tokens !== undefined ||
55
+ rawUsage.cacheCreationInputTokens !== undefined ||
56
+ rawUsage.cacheReadInputTokens !== undefined;
57
+ const hasOpenAiPromptFields =
58
+ rawUsage.prompt_tokens !== undefined ||
59
+ rawUsage.promptTokens !== undefined ||
60
+ rawUsage.prompt_tokens_details !== undefined ||
61
+ rawUsage.promptTokensDetails !== undefined;
62
+ const hasOpenAiResponseFields =
63
+ rawUsage.input_tokens_details !== undefined ||
64
+ rawUsage.inputTokensDetails !== undefined ||
65
+ rawUsage.output_tokens_details !== undefined ||
66
+ rawUsage.outputTokensDetails !== undefined;
67
+ const hasOpenAiCompatibilityFields =
68
+ hasOpenAiPromptFields ||
69
+ hasOpenAiResponseFields ||
70
+ rawUsage.cached_tokens !== undefined ||
71
+ rawUsage.cachedTokens !== undefined ||
72
+ rawUsage.reasoning_tokens !== undefined ||
73
+ rawUsage.reasoningTokens !== undefined ||
74
+ rawUsage.completion_tokens_details !== undefined ||
75
+ rawUsage.completionTokensDetails !== undefined;
76
+
77
+ const input = readNumericField(rawUsage, ['input_tokens', 'prompt_tokens', 'inputTokens', 'promptTokens']);
78
+ if (input !== undefined) tokens.input = input;
79
+
80
+ const output = readNumericField(rawUsage, ['output_tokens', 'completion_tokens', 'outputTokens', 'completionTokens']);
81
+ if (output !== undefined) tokens.output = output;
82
+
83
+ const total = readNumericField(rawUsage, ['total_tokens', 'totalTokens']);
84
+ if (total !== undefined) tokens.total = total;
85
+
86
+ const cacheCreation = readNumericField(rawUsage, ['cache_creation_input_tokens', 'cacheCreationInputTokens'])
87
+ ?? readNestedNumericField(rawUsage, [
88
+ ['prompt_tokens_details', 'cache_creation_input_tokens'],
89
+ ['promptTokensDetails', 'cacheCreationInputTokens']
90
+ ]);
91
+ if (cacheCreation !== undefined) tokens.cacheCreation = cacheCreation;
92
+
93
+ const cacheRead = readNumericField(rawUsage, ['cache_read_input_tokens', 'cacheReadInputTokens']);
94
+ if (cacheRead !== undefined) tokens.cacheRead = cacheRead;
95
+
96
+ const cached = readNumericField(rawUsage, ['cached_tokens', 'cachedTokens'])
97
+ ?? readNestedNumericField(rawUsage, [
98
+ ['input_tokens_details', 'cached_tokens'],
99
+ ['prompt_tokens_details', 'cached_tokens'],
100
+ ['inputTokensDetails', 'cachedTokens'],
101
+ ['promptTokensDetails', 'cachedTokens']
102
+ ]);
103
+ if (cached !== undefined) tokens.cached = cached;
104
+
105
+ const reasoning = readNumericField(rawUsage, ['reasoning_tokens', 'reasoningTokens'])
106
+ ?? readNestedNumericField(rawUsage, [
107
+ ['completion_tokens_details', 'reasoning_tokens'],
108
+ ['output_tokens_details', 'reasoning_tokens'],
109
+ ['completionTokensDetails', 'reasoningTokens'],
110
+ ['outputTokensDetails', 'reasoningTokens']
111
+ ]);
112
+ if (reasoning !== undefined) tokens.reasoning = reasoning;
113
+
114
+ if (tokens.total === undefined && tokens.input !== undefined && tokens.output !== undefined && !hasAnthropicCacheFields) {
115
+ tokens.total = tokens.input + tokens.output;
116
+ }
117
+
118
+ // OpenAI/OpenAI-compatible usage commonly reports prompt/input totals that
119
+ // already include cache hits. Align with OpenCode's provider contract by
120
+ // moving cached prompt tokens into a dedicated field and keeping input as the
121
+ // net uncached prompt tokens.
122
+ if (!hasAnthropicCacheFields && hasOpenAiCompatibilityFields && tokens.cached !== undefined && tokens.input !== undefined) {
123
+ tokens.input = Math.max(tokens.input - tokens.cached, 0);
124
+ }
125
+
126
+ if (Object.keys(tokens).length === 0) {
127
+ return null;
128
+ }
129
+
130
+ return tokens;
131
+ }
132
+
11
133
  /**
12
134
  * 从单个 SSE 事件的 parsed JSON 中提取 model 和 token 信息。
13
135
  * 自动检测 Claude / OpenAI / Gemini 格式。
@@ -31,82 +153,35 @@ function parseSSEUsage(parsed, eventType) {
31
153
  if (eventType === 'message_start' && parsed.message && parsed.message.model) {
32
154
  model = parsed.message.model;
33
155
  }
156
+ if (!tokens && parsed.message && parsed.message.usage) {
157
+ tokens = parseUsageObject(parsed.message.usage);
158
+ }
34
159
  if (eventType === 'message_stop') {
35
160
  isDone = true;
36
161
  }
37
162
 
38
163
  // === OpenAI Responses API 格式 ===
39
164
  // data: {"type": "response.completed", "response": {"model", "usage": {...}}}
40
- if (parsed.type === 'response.completed' && parsed.response) {
165
+ if ((parsed.type === 'response.completed' || parsed.type === 'response.incomplete') && parsed.response) {
41
166
  if (parsed.response.model) {
42
167
  model = parsed.response.model;
43
168
  }
44
169
  if (parsed.response.usage) {
45
- tokens = {
46
- input: parsed.response.usage.input_tokens || 0,
47
- output: parsed.response.usage.output_tokens || 0,
48
- total: parsed.response.usage.total_tokens || 0,
49
- };
50
- if (parsed.response.usage.input_tokens_details &&
51
- parsed.response.usage.input_tokens_details.cached_tokens !== undefined) {
52
- tokens.cached = parsed.response.usage.input_tokens_details.cached_tokens;
53
- }
54
- if (parsed.response.usage.output_tokens_details &&
55
- parsed.response.usage.output_tokens_details.reasoning_tokens !== undefined) {
56
- tokens.reasoning = parsed.response.usage.output_tokens_details.reasoning_tokens;
57
- }
170
+ tokens = parseUsageObject(parsed.response.usage);
58
171
  }
59
172
  isDone = true;
60
173
  }
61
174
 
62
175
  // === parsed.usage(Claude 原生 + OpenAI Chat Completions 共用) ===
63
176
  if (!tokens && parsed.usage) {
64
- const t = {};
65
-
66
- // Claude 格式字段
67
- if (parsed.usage.input_tokens !== undefined) {
68
- t.input = parsed.usage.input_tokens;
69
- }
70
- if (parsed.usage.output_tokens !== undefined) {
71
- t.output = parsed.usage.output_tokens;
72
- }
73
- if (parsed.usage.cache_creation_input_tokens !== undefined) {
74
- t.cacheCreation = parsed.usage.cache_creation_input_tokens;
75
- }
76
- if (parsed.usage.cache_read_input_tokens !== undefined) {
77
- t.cacheRead = parsed.usage.cache_read_input_tokens;
78
- }
79
-
80
- // OpenAI Chat Completions 格式字段(fallback)
81
- if (t.input === undefined && parsed.usage.prompt_tokens !== undefined) {
82
- t.input = parsed.usage.prompt_tokens;
83
- }
84
- if (t.output === undefined && parsed.usage.completion_tokens !== undefined) {
85
- t.output = parsed.usage.completion_tokens;
86
- }
87
- if (parsed.usage.total_tokens !== undefined) {
88
- t.total = parsed.usage.total_tokens;
89
- }
90
-
91
- // OpenAI detailed breakdowns
92
- if (parsed.usage.input_tokens_details &&
93
- parsed.usage.input_tokens_details.cached_tokens !== undefined) {
94
- t.cached = parsed.usage.input_tokens_details.cached_tokens;
95
- }
96
- if (parsed.usage.output_tokens_details &&
97
- parsed.usage.output_tokens_details.reasoning_tokens !== undefined) {
98
- t.reasoning = parsed.usage.output_tokens_details.reasoning_tokens;
99
- }
100
-
101
- // Gemini cache in OpenAI compat mode
102
- if (parsed.usage.prompt_tokens_details &&
103
- parsed.usage.prompt_tokens_details.cached_tokens !== undefined) {
104
- t.cached = parsed.usage.prompt_tokens_details.cached_tokens;
105
- }
177
+ tokens = parseUsageObject(parsed.usage);
178
+ }
106
179
 
107
- if (Object.keys(t).length > 0) {
108
- tokens = t;
109
- }
180
+ if (!tokens && parsed.tokenUsage) {
181
+ tokens = parseUsageObject(parsed.tokenUsage);
182
+ }
183
+ if (!tokens && parsed.token_usage) {
184
+ tokens = parseUsageObject(parsed.token_usage);
110
185
  }
111
186
 
112
187
  // === Gemini Native 格式 ===
@@ -120,6 +195,9 @@ function parseSSEUsage(parsed, eventType) {
120
195
  if (parsed.usageMetadata.cachedContentTokenCount) {
121
196
  tokens.cached = parsed.usageMetadata.cachedContentTokenCount;
122
197
  }
198
+ if (parsed.usageMetadata.thoughtsTokenCount) {
199
+ tokens.reasoning = parsed.usageMetadata.thoughtsTokenCount;
200
+ }
123
201
  }
124
202
 
125
203
  // === 通用 model fallback ===
@@ -110,6 +110,7 @@ function recordRequest(requestData = {}) {
110
110
  input: normalizedTokens.input,
111
111
  output: normalizedTokens.output,
112
112
  reasoning: normalizedTokens.reasoning,
113
+ cached: normalizedTokens.cached,
113
114
  cacheCreation: normalizedTokens.cacheCreation,
114
115
  cacheRead: normalizedTokens.cacheRead,
115
116
  total: normalizedTokens.total
@@ -108,6 +108,7 @@ function recordRequest(requestData = {}) {
108
108
  tokens: {
109
109
  input: normalizedTokens.input,
110
110
  output: normalizedTokens.output,
111
+ cached: normalizedTokens.cached,
111
112
  cacheCreation: normalizedTokens.cacheCreation,
112
113
  cacheRead: normalizedTokens.cacheRead,
113
114
  total: normalizedTokens.total
@@ -0,0 +1,202 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { execFileSync } = require('child_process');
6
+ const { randomBytes } = require('crypto');
7
+ const { PATHS } = require('../../config/paths');
8
+
9
+ const HTTPS_DIR = path.join(PATHS.config, 'https');
10
+ const HTTPS_KEY_FILE = path.join(HTTPS_DIR, 'localhost-key.pem');
11
+ const HTTPS_CERT_FILE = path.join(HTTPS_DIR, 'localhost-cert.pem');
12
+ const HTTPS_PFX_FILE = path.join(HTTPS_DIR, 'localhost.pfx');
13
+ const HTTPS_META_FILE = path.join(HTTPS_DIR, 'meta.json');
14
+
15
+ function ensureHttpsDir() {
16
+ if (!fs.existsSync(HTTPS_DIR)) {
17
+ fs.mkdirSync(HTTPS_DIR, { recursive: true });
18
+ }
19
+ }
20
+
21
+ function readMeta() {
22
+ if (!fs.existsSync(HTTPS_META_FILE)) {
23
+ return {};
24
+ }
25
+
26
+ try {
27
+ return JSON.parse(fs.readFileSync(HTTPS_META_FILE, 'utf8'));
28
+ } catch {
29
+ return {};
30
+ }
31
+ }
32
+
33
+ function writeMeta(meta) {
34
+ ensureHttpsDir();
35
+ fs.writeFileSync(HTTPS_META_FILE, JSON.stringify(meta, null, 2), 'utf8');
36
+ }
37
+
38
+ function resolveOpenSslCommand() {
39
+ const commands = process.platform === 'win32'
40
+ ? ['openssl.exe', 'openssl']
41
+ : ['openssl'];
42
+
43
+ for (const command of commands) {
44
+ try {
45
+ execFileSync(command, ['version'], { stdio: 'ignore' });
46
+ return command;
47
+ } catch {
48
+ // Try next candidate.
49
+ }
50
+ }
51
+
52
+ return '';
53
+ }
54
+
55
+ function buildOpenSslConfig() {
56
+ return [
57
+ '[req]',
58
+ 'default_bits = 2048',
59
+ 'distinguished_name = req_distinguished_name',
60
+ 'prompt = no',
61
+ 'x509_extensions = v3_req',
62
+ '',
63
+ '[req_distinguished_name]',
64
+ 'CN = localhost',
65
+ '',
66
+ '[v3_req]',
67
+ 'subjectAltName = @alt_names',
68
+ 'keyUsage = digitalSignature, keyEncipherment',
69
+ 'extendedKeyUsage = serverAuth',
70
+ '',
71
+ '[alt_names]',
72
+ 'DNS.1 = localhost',
73
+ 'IP.1 = 127.0.0.1',
74
+ 'IP.2 = ::1'
75
+ ].join('\n');
76
+ }
77
+
78
+ function generatePemCertificateWithOpenSsl(command) {
79
+ ensureHttpsDir();
80
+ const configPath = path.join(HTTPS_DIR, 'openssl.cnf');
81
+ fs.writeFileSync(configPath, buildOpenSslConfig(), 'utf8');
82
+
83
+ execFileSync(command, [
84
+ 'req',
85
+ '-x509',
86
+ '-nodes',
87
+ '-newkey',
88
+ 'rsa:2048',
89
+ '-sha256',
90
+ '-days',
91
+ '825',
92
+ '-keyout',
93
+ HTTPS_KEY_FILE,
94
+ '-out',
95
+ HTTPS_CERT_FILE,
96
+ '-config',
97
+ configPath
98
+ ], {
99
+ stdio: 'ignore'
100
+ });
101
+
102
+ writeMeta({
103
+ generator: 'openssl',
104
+ createdAt: new Date().toISOString()
105
+ });
106
+ }
107
+
108
+ function buildWindowsPfxGenerationScript(outputPath, passphrase) {
109
+ return [
110
+ '$ErrorActionPreference = "Stop"',
111
+ '$rsa = [System.Security.Cryptography.RSA]::Create(2048)',
112
+ '$req = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new(',
113
+ ' "CN=localhost",',
114
+ ' $rsa,',
115
+ ' [System.Security.Cryptography.HashAlgorithmName]::SHA256,',
116
+ ' [System.Security.Cryptography.RSASignaturePadding]::Pkcs1',
117
+ ')',
118
+ '$san = [System.Security.Cryptography.X509Certificates.SubjectAlternativeNameBuilder]::new()',
119
+ '$san.AddDnsName("localhost")',
120
+ '$san.AddIpAddress([System.Net.IPAddress]::Parse("127.0.0.1"))',
121
+ '$req.CertificateExtensions.Add($san.Build($false))',
122
+ '$req.CertificateExtensions.Add([System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($false, $false, 0, $true))',
123
+ '$req.CertificateExtensions.Add([System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new([System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature -bor [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyEncipherment, $true))',
124
+ '$req.CertificateExtensions.Add([System.Security.Cryptography.X509Certificates.X509SubjectKeyIdentifierExtension]::new($req.PublicKey, $false))',
125
+ '$notBefore = [DateTimeOffset]::UtcNow.AddDays(-1)',
126
+ '$notAfter = $notBefore.AddYears(2)',
127
+ '$cert = $req.CreateSelfSigned($notBefore, $notAfter)',
128
+ `$bytes = $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, ${JSON.stringify(passphrase)})`,
129
+ `[System.IO.Directory]::CreateDirectory(${JSON.stringify(HTTPS_DIR)}) | Out-Null`,
130
+ `[System.IO.File]::WriteAllBytes(${JSON.stringify(outputPath)}, $bytes)`,
131
+ '$cert.Dispose()',
132
+ '$rsa.Dispose()'
133
+ ].join('; ');
134
+ }
135
+
136
+ function generatePfxCertificateOnWindows() {
137
+ ensureHttpsDir();
138
+ const passphrase = randomBytes(18).toString('base64url');
139
+
140
+ execFileSync('powershell', [
141
+ '-NoProfile',
142
+ '-Command',
143
+ buildWindowsPfxGenerationScript(HTTPS_PFX_FILE, passphrase)
144
+ ], {
145
+ stdio: 'ignore',
146
+ windowsHide: true
147
+ });
148
+
149
+ writeMeta({
150
+ generator: 'powershell-pfx',
151
+ passphrase,
152
+ createdAt: new Date().toISOString()
153
+ });
154
+ }
155
+
156
+ function ensureHttpsCredentials() {
157
+ ensureHttpsDir();
158
+
159
+ if (fs.existsSync(HTTPS_KEY_FILE) && fs.existsSync(HTTPS_CERT_FILE)) {
160
+ return {
161
+ key: fs.readFileSync(HTTPS_KEY_FILE),
162
+ cert: fs.readFileSync(HTTPS_CERT_FILE)
163
+ };
164
+ }
165
+
166
+ const meta = readMeta();
167
+ if (fs.existsSync(HTTPS_PFX_FILE) && meta.passphrase) {
168
+ return {
169
+ pfx: fs.readFileSync(HTTPS_PFX_FILE),
170
+ passphrase: meta.passphrase
171
+ };
172
+ }
173
+
174
+ const openSslCommand = resolveOpenSslCommand();
175
+ if (openSslCommand) {
176
+ generatePemCertificateWithOpenSsl(openSslCommand);
177
+ return {
178
+ key: fs.readFileSync(HTTPS_KEY_FILE),
179
+ cert: fs.readFileSync(HTTPS_CERT_FILE)
180
+ };
181
+ }
182
+
183
+ if (process.platform === 'win32') {
184
+ generatePfxCertificateOnWindows();
185
+ const nextMeta = readMeta();
186
+ return {
187
+ pfx: fs.readFileSync(HTTPS_PFX_FILE),
188
+ passphrase: nextMeta.passphrase
189
+ };
190
+ }
191
+
192
+ throw new Error('无法生成 HTTPS 本地证书,请先安装 openssl 后重试');
193
+ }
194
+
195
+ module.exports = {
196
+ ensureHttpsCredentials,
197
+ _test: {
198
+ buildOpenSslConfig,
199
+ buildWindowsPfxGenerationScript,
200
+ resolveOpenSslCommand
201
+ }
202
+ };
@@ -13,7 +13,7 @@ const MANAGED_HOOK_NAME = 'coding-tool-notify';
13
13
  const MANAGED_OPENCODE_PLUGIN_FILE = 'coding-tool-notify.js';
14
14
 
15
15
  function normalizeType(type) {
16
- return type === 'dialog' ? 'dialog' : 'notification';
16
+ return type === 'dialog' || type === 'browser' ? type : 'notification';
17
17
  }
18
18
 
19
19
  function ensureParentDir(filePath) {
@@ -118,9 +118,9 @@ function removeNotifyScript() {
118
118
  function parseManagedType(input) {
119
119
  const value = String(input || '');
120
120
  const matches = [
121
- value.match(/--cc-notify-type=(dialog|notification)/i),
122
- value.match(/--mode=(dialog|notification)/i),
123
- value.match(/MODE\s*=\s*["'](dialog|notification)["']/i)
121
+ value.match(/--cc-notify-type=(dialog|notification|browser)/i),
122
+ value.match(/--mode=(dialog|notification|browser)/i),
123
+ value.match(/MODE\s*=\s*["'](dialog|notification|browser)["']/i)
124
124
  ];
125
125
 
126
126
  for (const match of matches) {
@@ -446,6 +446,7 @@ const { execSync, execFileSync } = require('child_process')
446
446
 
447
447
  const FEISHU_ENABLED = ${feishuEnabled ? 'true' : 'false'}
448
448
  const FEISHU_WEBHOOK_URL = ${JSON.stringify(feishuEnabled ? feishu.webhookUrl : '')}
449
+ const CONFIG_FILE = ${JSON.stringify(PATHS.configFile)}
449
450
 
450
451
  function readArg(name) {
451
452
  const prefix = \`\${name}=\`
@@ -507,10 +508,77 @@ function resolveMessage(source, eventType, payload) {
507
508
  return 'Claude Code 任务已完成 | 等待交互'
508
509
  }
509
510
 
511
+ function readWebUiPort() {
512
+ try {
513
+ const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'))
514
+ const port = parseInt(config?.ports?.webUI, 10)
515
+ return Number.isFinite(port) ? port : 19999
516
+ } catch (error) {
517
+ return 19999
518
+ }
519
+ }
520
+
521
+ function postBrowserNotification(source, eventType, message) {
522
+ const payload = JSON.stringify({
523
+ source,
524
+ eventType,
525
+ message
526
+ })
527
+ const port = readWebUiPort()
528
+ const attempts = [
529
+ { module: https, options: { rejectUnauthorized: false } },
530
+ { module: http, options: {} }
531
+ ]
532
+
533
+ return new Promise((resolve) => {
534
+ const runAttempt = (index) => {
535
+ const current = attempts[index]
536
+ if (!current) {
537
+ resolve()
538
+ return
539
+ }
540
+
541
+ const request = current.module.request({
542
+ hostname: '127.0.0.1',
543
+ port,
544
+ path: '/api/hooks/browser-event',
545
+ method: 'POST',
546
+ timeout: 1500,
547
+ headers: {
548
+ 'Content-Type': 'application/json',
549
+ 'Content-Length': Buffer.byteLength(payload)
550
+ },
551
+ ...current.options
552
+ }, (response) => {
553
+ response.resume()
554
+ if (response.statusCode >= 200 && response.statusCode < 300) {
555
+ resolve()
556
+ return
557
+ }
558
+ runAttempt(index + 1)
559
+ })
560
+
561
+ request.on('error', () => runAttempt(index + 1))
562
+ request.on('timeout', () => {
563
+ request.destroy()
564
+ runAttempt(index + 1)
565
+ })
566
+ request.write(payload)
567
+ request.end()
568
+ }
569
+
570
+ runAttempt(0)
571
+ })
572
+ }
573
+
510
574
  function notify(mode, message) {
511
575
  const title = 'Coding Tool'
512
576
  const platform = os.platform()
513
577
 
578
+ if (mode === 'browser') {
579
+ return
580
+ }
581
+
514
582
  try {
515
583
  if (platform === 'darwin') {
516
584
  if (mode === 'dialog') {
@@ -635,7 +703,11 @@ function sendFeishu(message, source) {
635
703
  const payload = readOptionalPayload()
636
704
  const message = resolveMessage(source, eventType, payload)
637
705
 
638
- notify(mode === 'dialog' ? 'dialog' : 'notification', message)
706
+ if (mode === 'browser') {
707
+ await postBrowserNotification(source, eventType, message)
708
+ } else {
709
+ notify(mode, message)
710
+ }
639
711
  await sendFeishu(message, source)
640
712
  })().catch(() => {
641
713
  process.exit(0)
@@ -1183,6 +1255,55 @@ function normalizePlatformInput(platform = {}) {
1183
1255
  };
1184
1256
  }
1185
1257
 
1258
+ function resolveBrowserNotificationTitle(source = 'claude') {
1259
+ switch (String(source || '').toLowerCase()) {
1260
+ case 'codex':
1261
+ return 'Codex CLI';
1262
+ case 'gemini':
1263
+ return 'Gemini CLI';
1264
+ case 'opencode':
1265
+ return 'OpenCode';
1266
+ default:
1267
+ return 'Claude Code';
1268
+ }
1269
+ }
1270
+
1271
+ function resolveBrowserNotificationUrl(source = 'claude') {
1272
+ switch (String(source || '').toLowerCase()) {
1273
+ case 'codex':
1274
+ return '/codex';
1275
+ case 'gemini':
1276
+ return '/gemini';
1277
+ case 'opencode':
1278
+ return '/opencode';
1279
+ default:
1280
+ return '/claude';
1281
+ }
1282
+ }
1283
+
1284
+ function emitBrowserNotification(input = {}) {
1285
+ const source = String(input.source || 'claude').toLowerCase();
1286
+ const message = String(input.message || '').trim();
1287
+ if (!message) {
1288
+ throw createValidationError('缺少浏览器通知内容');
1289
+ }
1290
+
1291
+ const payload = {
1292
+ type: 'browser-notification',
1293
+ id: input.id || `${source}-${Date.now()}`,
1294
+ source,
1295
+ eventType: String(input.eventType || '').trim(),
1296
+ title: String(input.title || resolveBrowserNotificationTitle(source)).trim(),
1297
+ message,
1298
+ url: String(input.url || resolveBrowserNotificationUrl(source)).trim(),
1299
+ timestamp: Date.now()
1300
+ };
1301
+
1302
+ const { broadcastBrowserNotification } = require('../websocket-server');
1303
+ broadcastBrowserNotification(payload);
1304
+ return payload;
1305
+ }
1306
+
1186
1307
  function saveNotificationSettings(input = {}) {
1187
1308
  const existingFeishu = getFeishuConfig();
1188
1309
  const requestedWebhookUrl = String(input?.feishu?.webhookUrl || '').trim();
@@ -1220,7 +1341,7 @@ function saveNotificationSettings(input = {}) {
1220
1341
  }
1221
1342
 
1222
1343
  function parseNotifyTypeMarker(command) {
1223
- const marker = String(command || '').match(/--cc-notify-type=(['"])?(dialog|notification)\1/i);
1344
+ const marker = String(command || '').match(/--cc-notify-type=(['"])?(dialog|notification|browser)\1/i);
1224
1345
  return marker?.[2] ? normalizeType(marker[2].toLowerCase()) : null;
1225
1346
  }
1226
1347
 
@@ -1566,6 +1687,15 @@ function testNotification({ type, testFeishu, webhookUrl } = {}) {
1566
1687
  return sendFeishuTest(webhookUrl);
1567
1688
  }
1568
1689
 
1690
+ if (normalizeType(type) === 'browser') {
1691
+ emitBrowserNotification({
1692
+ source: 'claude',
1693
+ title: 'coding-tool-x',
1694
+ message: '这是一条浏览器测试通知'
1695
+ });
1696
+ return;
1697
+ }
1698
+
1569
1699
  runSystemNotification(type || 'notification', '这是一条测试通知');
1570
1700
  }
1571
1701
 
@@ -1576,6 +1706,7 @@ module.exports = {
1576
1706
  saveNotificationSettings,
1577
1707
  saveLegacyClaudeHookSettings,
1578
1708
  testNotification,
1709
+ emitBrowserNotification,
1579
1710
  initDefaultHooks,
1580
1711
  syncManagedNotificationAssets,
1581
1712
  getOpenCodeManagedPluginPath,
@@ -1604,6 +1735,7 @@ module.exports = {
1604
1735
  getOpenCodeManagedPluginPath,
1605
1736
  generateNotifyScript,
1606
1737
  generateSystemNotificationCommand,
1738
+ emitBrowserNotification,
1607
1739
  parseStopHookStatus,
1608
1740
  shouldRepairStopHook
1609
1741
  }
@@ -11,6 +11,8 @@
11
11
  * - OpenAI Chat Completions API (/v1/chat/completions)
12
12
  */
13
13
 
14
+ const { ensureOpenAiStreamUsage } = require('./base/proxy-utils');
15
+
14
16
  const SUPPORTED_SOURCE_TYPES = ['claude', 'codex', 'gemini'];
15
17
  const SUPPORTED_TARGET_APIS = ['responses', 'chat.completions'];
16
18
 
@@ -554,6 +556,7 @@ function buildChatCompletionsRequest(normalized, options, warnings) {
554
556
  if (chatTools.length > 0) request.tools = chatTools;
555
557
  }
556
558
 
559
+ ensureOpenAiStreamUsage(request);
557
560
  return request;
558
561
  }
559
562
 
@@ -110,6 +110,7 @@ function recordRequest(requestData = {}) {
110
110
  input: normalizedTokens.input,
111
111
  output: normalizedTokens.output,
112
112
  reasoning: normalizedTokens.reasoning,
113
+ cached: normalizedTokens.cached,
113
114
  cacheCreation: normalizedTokens.cacheCreation,
114
115
  cacheRead: normalizedTokens.cacheRead,
115
116
  total: normalizedTokens.total