mocode-ai 1.4.7 → 1.5.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.
@@ -76,3 +76,90 @@ export async function loadImageAttachment(input, opts) {
76
76
  },
77
77
  };
78
78
  }
79
+ /**
80
+ * 从 dataUrl 里读出图片宽高(只解头部,不做完整解码)。
81
+ *
82
+ * 用途:视觉 token 估算 —— 图片 token 是按**像素面积**计费的(Claude 口径 ≈ w*h/750,
83
+ * OpenAI 系按 512 tile 分块,同量级),不是每图 85。按 85 估会把一张 1568×878 的截图
84
+ * (≈1835 token)低估 20 倍,后果是上下文压力线长期不触发、prompt 实际早就超了才 compact。
85
+ */
86
+ export function imageSizeFromDataUrl(dataUrl) {
87
+ const comma = dataUrl.indexOf(',');
88
+ if (comma < 0)
89
+ return null;
90
+ const meta = dataUrl.slice(5, comma);
91
+ const isPng = meta.startsWith('image/png');
92
+ const isJpeg = meta.startsWith('image/jpeg') || meta.startsWith('image/jpg');
93
+ const isGif = meta.startsWith('image/gif');
94
+ const isWebp = meta.startsWith('image/webp');
95
+ if (!isPng && !isJpeg && !isGif && !isWebp)
96
+ return null;
97
+ // PNG IHDR 在头 33 字节内;JPEG 的 SOF 位置不定,gif/webp 头很小 —— 取 64KB 上界足够。
98
+ const b64 = dataUrl.slice(comma + 1, comma + 1 + 65536);
99
+ let buf;
100
+ try {
101
+ buf = Buffer.from(b64, 'base64');
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ if (isPng) {
107
+ if (buf.length < 24)
108
+ return null;
109
+ if (buf.toString('ascii', 12, 16) !== 'IHDR')
110
+ return null;
111
+ const w = buf.readUInt32BE(16);
112
+ const h = buf.readUInt32BE(20);
113
+ return w && h ? { width: w, height: h } : null;
114
+ }
115
+ if (isGif) {
116
+ if (buf.length < 10 || buf.toString('ascii', 0, 3) !== 'GIF')
117
+ return null;
118
+ const w = buf.readUInt16LE(6);
119
+ const h = buf.readUInt16LE(8);
120
+ return w && h ? { width: w, height: h } : null;
121
+ }
122
+ if (isWebp) {
123
+ // VP8X(扩展)/VP8(无损外)/VP8L 三种容器,只解析最常见且便宜的两种,其余兜底。
124
+ if (buf.length < 30 || buf.toString('ascii', 0, 4) !== 'RIFF')
125
+ return null;
126
+ const fourcc = buf.toString('ascii', 12, 16);
127
+ if (fourcc === 'VP8X') {
128
+ const w = 1 + buf.readUIntLE(24, 3);
129
+ const h = 1 + buf.readUIntLE(27, 3);
130
+ return w > 1 && h > 1 ? { width: w, height: h } : null;
131
+ }
132
+ if (fourcc === 'VP8 ') {
133
+ const w = buf.readUInt16LE(26) & 0x3fff;
134
+ const h = buf.readUInt16LE(28) & 0x3fff;
135
+ return w && h ? { width: w, height: h } : null;
136
+ }
137
+ return null;
138
+ }
139
+ // JPEG:扫 SOF0-SOF3/SOF5-SOF7/SOF9-SOF11/SOF13-SOF15
140
+ let i = 2;
141
+ while (i + 9 < buf.length) {
142
+ if (buf[i] !== 0xff) {
143
+ i++;
144
+ continue;
145
+ }
146
+ const marker = buf[i + 1];
147
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
148
+ i += 2;
149
+ continue;
150
+ }
151
+ const len = buf.readUInt16BE(i + 2);
152
+ if ((marker >= 0xc0 && marker <= 0xc3) ||
153
+ (marker >= 0xc5 && marker <= 0xc7) ||
154
+ (marker >= 0xc9 && marker <= 0xcb) ||
155
+ (marker >= 0xcd && marker <= 0xcf)) {
156
+ const h = buf.readUInt16BE(i + 5);
157
+ const w = buf.readUInt16BE(i + 7);
158
+ return w && h ? { width: w, height: h } : null;
159
+ }
160
+ if (len < 2)
161
+ return null;
162
+ i += 2 + len;
163
+ }
164
+ return null;
165
+ }
package/dist/llm/index.js CHANGED
@@ -2,6 +2,7 @@ import OpenAI from 'openai';
2
2
  import { config, getActiveModel } from '../config/index.js';
3
3
  import { tools } from '../tools/registry.js';
4
4
  import { getPlanDisabledTools, getProfileDisabledTools } from '../tools/constants.js';
5
+ import { imageSizeFromDataUrl } from '../attachments/image.js';
5
6
  import { ThinkTagFilter } from './think-filter.js';
6
7
  import { sanitizeToolSchemas } from './tool-schema.js';
7
8
  import { defaultAnthropicFetch, anthropicChatOnce } from './providers/anthropic.js';
@@ -612,17 +613,38 @@ function contentToText(content) {
612
613
  return String(content);
613
614
  }
614
615
  }
615
- /** OpenAI 视觉模型单图固定 token(低细节 / auto 模式);高细节更大但属罕见路径,保守按 85 计。 */
616
- const IMAGE_TOKEN_COST = 85;
617
- /** 单个 content part 的 token:text part 走 estimateTokens,image_url part 固定 85。+2 结构开销。 */
616
+ /**
617
+ * 解析不出尺寸时的兜底(OpenAI 低细节单图)。
618
+ * 真实的视觉 token 是按**像素面积**计费的:Claude 口径 ≈ w*h/750,OpenAI 系按 512 tile 分块、
619
+ * 同量级。按 85 估会把一张 1568×878 的截图(≈1835 token)低估 20 倍 —— 后果不是数字不好看,
620
+ * 而是 80% 上下文压力线长期不触发,prompt 实际早已超出才 compact,表现为「模型突然抽风」。
621
+ */
622
+ const IMAGE_TOKEN_FALLBACK = 85;
623
+ const TOKENS_PER_PIXEL = 1 / 750;
624
+ function imageTokens(dataUrl) {
625
+ if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:'))
626
+ return IMAGE_TOKEN_FALLBACK;
627
+ const size = imageSizeFromDataUrl(dataUrl);
628
+ if (!size)
629
+ return IMAGE_TOKEN_FALLBACK;
630
+ return Math.max(IMAGE_TOKEN_FALLBACK, Math.round(size.width * size.height * TOKENS_PER_PIXEL));
631
+ }
632
+ /** 单个 content part 的 token:text part 走 estimateTokens,image_url part 按像素面积估。+2 结构开销。 */
618
633
  function partTokens(part) {
619
634
  if (!part || typeof part !== 'object')
620
635
  return 0;
621
636
  const p = part;
622
637
  if (p.type === 'text')
623
638
  return 2 + estimateTokens(p.text ?? '');
624
- if (p.type === 'image_url')
625
- return 2 + IMAGE_TOKEN_COST;
639
+ if (p.type === 'image_url') {
640
+ const iu = p.image_url;
641
+ const url = typeof iu === 'string'
642
+ ? iu
643
+ : typeof iu?.url === 'string'
644
+ ? iu.url
645
+ : '';
646
+ return 2 + imageTokens(url);
647
+ }
626
648
  return 2;
627
649
  }
628
650
  /** 估算多模态 content 的 token(text parts + 固定每图 85);不把 base64 走 estimateTokens,避免 1MB 图算成 25 万 token。 */
@@ -85,7 +85,7 @@ export const toolGroupCommands = [
85
85
  },
86
86
  // /cu|/computer [status|on|off] — computer-control 的高危 capability gate。
87
87
  // on 仅允许 router 在用户明确要求 GUI 操作时选择;每个动作仍经过权限门,plan 永远过滤。
88
- (ctx) => {
88
+ async (ctx) => {
89
89
  const { line } = ctx;
90
90
  const isCu = line === '/cu' || line.startsWith('/cu ') || line === '/computer' || line.startsWith('/computer ');
91
91
  if (!isCu)
@@ -103,6 +103,20 @@ export const toolGroupCommands = [
103
103
  const state = t(enabled ? 'cu.stateOn' : 'cu.stateOff');
104
104
  layout.contentWrite(`${ui.accent}${t('cu.status', { state })}${ui.reset}\n` +
105
105
  `${ui.dim}MOCODE_COMPUTER_USE_ENABLED=${enabled ? 'true' : 'false'} · ${CONFIG_PATH}${ui.reset}\n`);
106
+ // 埋点基线:优化前后都要能报出 p50/p95,否则「快了」只是感觉。
107
+ try {
108
+ const { getComputerMetrics } = await import('../../tools/builtins/computer.js');
109
+ const s = getComputerMetrics();
110
+ if (s.count > 0) {
111
+ const kb = (s.avgBytes / 1024).toFixed(0);
112
+ layout.contentWrite(`${ui.dim}${s.count} calls · total p50 ${s.totalP50}ms / p95 ${s.totalP95}ms · ` +
113
+ `capture p50 ${s.captureP50}ms · ${kb}KB/frame · ` +
114
+ `dedup ${(s.skippedRatio * 100).toFixed(0)}%${ui.reset}\n`);
115
+ }
116
+ }
117
+ catch {
118
+ // 埋点不可用时不影响状态输出。
119
+ }
106
120
  return next();
107
121
  }
108
122
  if (arg !== 'on' && arg !== 'off') {
@@ -117,6 +131,23 @@ export const toolGroupCommands = [
117
131
  ctx.history[0] = { role: 'system', content: ctx.buildSystemMessage(getAgentMode() === 'plan') };
118
132
  layout.rewriteBanner(bannerLines(ctx.banner()));
119
133
  }
134
+ if (!enabled) {
135
+ // 常驻 PowerShell 进程(注入 / 抓屏)不会随主进程自动退出(Windows 尤其),关开关时必须
136
+ // 显式回收,否则每次 /cu off 都留一组孤儿进程。差分基准帧与几何缓存也一并清掉。
137
+ try {
138
+ const [{ disposeInputInjector }, { disposeCaptureService }, { resetComputerState }] = await Promise.all([
139
+ import('../../runtime/input-injector.js'),
140
+ import('../../runtime/screen-capture-service.js'),
141
+ import('../../tools/builtins/computer.js'),
142
+ ]);
143
+ await disposeInputInjector();
144
+ await disposeCaptureService();
145
+ resetComputerState();
146
+ }
147
+ catch {
148
+ // 从未启动过常驻进程:dispose 是空操作,忽略。
149
+ }
150
+ }
120
151
  layout.contentWrite(`${enabled ? ui.green : ui.yellow}${t(enabled ? 'cu.changedOn' : 'cu.changedOff')}${ui.reset}\n`);
121
152
  return next();
122
153
  },
@@ -53,7 +53,12 @@ function recallWindowMs(input) {
53
53
  const long = input.length >= 2 || [...joined].length >= LONG_INPUT_CHARS;
54
54
  if (!long)
55
55
  return PENDING_RECALL_MS;
56
- const env = Number(process.env.MOCODE_RECALL_MS);
56
+ // 空串必须显式回落:Number('') === 0 且能通过下面的 >= 0 校验,
57
+ // 结果是撤回窗口变成 0(等于没有撤回窗口),而用户只是想清空这个配置。
58
+ const rawEnv = process.env.MOCODE_RECALL_MS;
59
+ if (rawEnv === undefined || rawEnv.trim() === '')
60
+ return PENDING_RECALL_LONG_MS;
61
+ const env = Number(rawEnv);
57
62
  return Number.isFinite(env) && env >= 0 ? env : PENDING_RECALL_LONG_MS;
58
63
  }
59
64
  let pendingTimer = null;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * 常驻子进程的空闲回收。
3
+ *
4
+ * 为什么需要:computer use 的两个常驻进程(注入器 / 抓屏)都是**惰性**启动的 —— 第一次真正
5
+ * 用到才 spawn。但「惰性启动」只解决了「不用时不起」,没解决「用完了不退」:/cu 开着、用户
6
+ * 去做别的事(写代码、看 diff、甚至只是去吃个饭),那两个 PowerShell 进程会一直挂在后台。
7
+ *
8
+ * 所以生命周期是四段式:惰性启动 → 每次调用 touch 续期 → 空闲 N 分钟自动回收 → 退出时强制回收。
9
+ *
10
+ * 关键细节:`setTimeout(...).unref()`。不 unref 的话这个定时器本身就是一个 active handle,
11
+ * 会让 Node 主进程在 REPL 想退出时退不出去 —— 表现为「输 /exit 卡住几秒才走」。
12
+ */
13
+ export class IdleGuard {
14
+ idleMs;
15
+ onIdle;
16
+ timer = null;
17
+ /**
18
+ * @param idleMs 空闲多久回收。<= 0 表示永不自动回收(只靠显式 dispose)。
19
+ * @param onIdle 回收回调,必须幂等(可能由超时、dispose、进程崩溃多方触发)。
20
+ */
21
+ constructor(idleMs, onIdle) {
22
+ this.idleMs = idleMs;
23
+ this.onIdle = onIdle;
24
+ }
25
+ /** 每次成功调用后续期。 */
26
+ touch() {
27
+ this.stop();
28
+ if (this.idleMs <= 0)
29
+ return;
30
+ this.timer = setTimeout(() => {
31
+ this.timer = null;
32
+ void this.onIdle();
33
+ }, this.idleMs);
34
+ // 见文件头:不 unref 会吊住 Node 退出。
35
+ this.timer.unref?.();
36
+ }
37
+ stop() {
38
+ if (this.timer) {
39
+ clearTimeout(this.timer);
40
+ this.timer = null;
41
+ }
42
+ }
43
+ get armed() {
44
+ return this.timer !== null;
45
+ }
46
+ }
47
+ /**
48
+ * 读一个毫秒数环境变量,非法值回落默认。
49
+ *
50
+ * 空串必须回落:`Number('')` 是 0,而 0 在本模块语义里是「关闭自动回收」—— 用户在 .env 里
51
+ * 写 `MOCODE_CU_CAPTURE_IDLE_MS=`(清空/注释掉的常见写法)会被静默解读成「永不回收」,
52
+ * 于是又回到留孤儿 PowerShell 的老问题。空 = 没配 = 用默认,只有显式写 0 才是 0。
53
+ */
54
+ export function envInt(name, fallback) {
55
+ const raw = process.env[name];
56
+ if (raw === undefined || raw.trim() === '')
57
+ return fallback;
58
+ const n = Number(raw);
59
+ if (!Number.isFinite(n) || n < 0)
60
+ return fallback;
61
+ return Math.round(n);
62
+ }
@@ -12,20 +12,69 @@
12
12
  */
13
13
  import { spawn } from 'node:child_process';
14
14
  import { filterEnv } from '../sandbox/index.js';
15
+ import { IdleGuard, envInt } from './idle-guard.js';
15
16
  // ── Anthropic 键名 → Windows VK 码 ─────────────────────────────────────
16
17
  const VK_NAMED = {
17
- return: 0x0d, enter: 0x0d, escape: 0x1b, esc: 0x1b, tab: 0x09, space: 0x20,
18
- backspace: 0x08, delete: 0x2e, insert: 0x2d, home: 0x24, end: 0x23,
19
- page_up: 0x21, pageup: 0x21, page_down: 0x22, pagedown: 0x22, prior: 0x21, next: 0x22,
20
- left: 0x25, up: 0x26, right: 0x27, down: 0x28,
21
- arrowleft: 0x25, arrowup: 0x26, arrowright: 0x27, arrowdown: 0x28,
22
- ctrl: 0x11, control: 0x11, control_l: 0xa2, ctrl_l: 0xa2, control_r: 0xa3, ctrl_r: 0xa3,
23
- alt: 0x12, alt_l: 0xa4, alt_r: 0xa5, option: 0x12,
24
- shift: 0x10, shift_l: 0xa0, shift_r: 0xa1,
25
- super_l: 0x5b, super: 0x5b, meta: 0x5b, win: 0x5b, cmd: 0x5b, command: 0x5b,
26
- caps_lock: 0x14, num_lock: 0x90, scroll_lock: 0x91, print_screen: 0x2c, snapshot: 0x2c,
27
- minus: 0xbd, equals: 0xbb, comma: 0xbc, period: 0xbe, slash: 0xbf,
28
- semicolon: 0xba, quote: 0xde, bracketleft: 0xdb, bracketright: 0xdd, backslash: 0xdc, backquote: 0xc0,
18
+ return: 0x0d,
19
+ enter: 0x0d,
20
+ escape: 0x1b,
21
+ esc: 0x1b,
22
+ tab: 0x09,
23
+ space: 0x20,
24
+ backspace: 0x08,
25
+ delete: 0x2e,
26
+ insert: 0x2d,
27
+ home: 0x24,
28
+ end: 0x23,
29
+ page_up: 0x21,
30
+ pageup: 0x21,
31
+ page_down: 0x22,
32
+ pagedown: 0x22,
33
+ prior: 0x21,
34
+ next: 0x22,
35
+ left: 0x25,
36
+ up: 0x26,
37
+ right: 0x27,
38
+ down: 0x28,
39
+ arrowleft: 0x25,
40
+ arrowup: 0x26,
41
+ arrowright: 0x27,
42
+ arrowdown: 0x28,
43
+ ctrl: 0x11,
44
+ control: 0x11,
45
+ control_l: 0xa2,
46
+ ctrl_l: 0xa2,
47
+ control_r: 0xa3,
48
+ ctrl_r: 0xa3,
49
+ alt: 0x12,
50
+ alt_l: 0xa4,
51
+ alt_r: 0xa5,
52
+ option: 0x12,
53
+ shift: 0x10,
54
+ shift_l: 0xa0,
55
+ shift_r: 0xa1,
56
+ super_l: 0x5b,
57
+ super: 0x5b,
58
+ meta: 0x5b,
59
+ win: 0x5b,
60
+ cmd: 0x5b,
61
+ command: 0x5b,
62
+ caps_lock: 0x14,
63
+ num_lock: 0x90,
64
+ scroll_lock: 0x91,
65
+ print_screen: 0x2c,
66
+ snapshot: 0x2c,
67
+ minus: 0xbd,
68
+ equals: 0xbb,
69
+ comma: 0xbc,
70
+ period: 0xbe,
71
+ slash: 0xbf,
72
+ semicolon: 0xba,
73
+ quote: 0xde,
74
+ bracketleft: 0xdb,
75
+ bracketright: 0xdd,
76
+ backslash: 0xdc,
77
+ backquote: 0xc0,
29
78
  };
30
79
  for (let i = 1; i <= 12; i++)
31
80
  VK_NAMED[`f${i}`] = 0x6f + i;
@@ -48,7 +97,19 @@ export function mapKeyComboToVk(combo) {
48
97
  return c - 0x20; // a-z → 0x41-0x5A
49
98
  if (c >= 0x30 && c <= 0x39)
50
99
  return c; // 0-9
51
- const punct = VK_NAMED[{ '-': 'minus', '=': 'equals', ',': 'comma', '.': 'period', '/': 'slash', ';': 'semicolon', "'": 'quote', '[': 'bracketleft', ']': 'bracketright', '\\': 'backslash', '`': 'backquote' }[key] ?? ''];
100
+ const punct = VK_NAMED[{
101
+ '-': 'minus',
102
+ '=': 'equals',
103
+ ',': 'comma',
104
+ '.': 'period',
105
+ '/': 'slash',
106
+ ';': 'semicolon',
107
+ "'": 'quote',
108
+ '[': 'bracketleft',
109
+ ']': 'bracketright',
110
+ '\\': 'backslash',
111
+ '`': 'backquote',
112
+ }[key] ?? ''];
52
113
  if (punct !== undefined)
53
114
  return punct;
54
115
  }
@@ -69,6 +130,8 @@ const PS_INJECTOR_SCRIPT = [
69
130
  ' [DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT lpPoint);',
70
131
  ' [DllImport("user32.dll")] public static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);',
71
132
  ' [DllImport("user32.dll")] public static extern void mouse_event(uint dwFlags, uint dx, uint dy, int dwData, UIntPtr dwExtraInfo);',
133
+ ' [DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr ctx);',
134
+ ' [DllImport("user32.dll")] public static extern bool SetProcessDPIAware();',
72
135
  ' public struct POINT { public int X; public int Y; }',
73
136
  ' [StructLayout(LayoutKind.Sequential)] public struct INPUT { public uint type; public INPUTUNION U; }',
74
137
  ' [StructLayout(LayoutKind.Explicit)] public struct INPUTUNION { [FieldOffset(0)] public MOUSEINPUT mi; [FieldOffset(0)] public KEYBDINPUT ki; }',
@@ -76,6 +139,10 @@ const PS_INJECTOR_SCRIPT = [
76
139
  ' [StructLayout(LayoutKind.Sequential)] public struct KEYBDINPUT { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public UIntPtr dwExtraInfo; }',
77
140
  '}',
78
141
  '"@',
142
+ // DPI 感知:SetCursorPos/GetCursorPos 始终按物理像素解释,而进程默认 DPI UNAWARE。
143
+ // 声明 PER_MONITOR_AWARE_V2(失败回落 SYSTEM_AWARE)后 GetCursorPos 与调用方的物理坐标口径一致,
144
+ // 避免 cursor_position 读回的坐标与主屏物理分辨率不同源。
145
+ 'try { if (-not [Inject]::SetProcessDpiAwarenessContext([IntPtr](-4))) { [void][Inject]::SetProcessDPIAware() } } catch { }',
79
146
  'function Send-VkCombo([int[]]$vks) {',
80
147
  ' $size = [Runtime.InteropServices.Marshal]::SizeOf([type]"Inject+INPUT")',
81
148
  ' foreach ($vk in $vks) {',
@@ -163,6 +230,17 @@ class PowerShellInjector {
163
230
  pending = new Map();
164
231
  stdoutBuf = '';
165
232
  lastError = '';
233
+ /**
234
+ * 空闲回收:注入器同样是惰性启动的常驻进程,`/cu off` 之前会一直挂着。
235
+ * 用户开了 /cu 之后去做别的事(写代码、看 diff),不该白留一个 PowerShell。
236
+ * 回收只是 kill 子进程 —— 鼠标按下状态是 OS 级的,不随进程消失,下次调用惰性重建。
237
+ */
238
+ idle;
239
+ constructor(idleMs) {
240
+ this.idle = new IdleGuard(idleMs, () => {
241
+ void this.dispose();
242
+ });
243
+ }
166
244
  ensureProcess() {
167
245
  if (this.child && this.child.exitCode === null && !this.child.killed)
168
246
  return this.child;
@@ -250,6 +328,7 @@ class PowerShellInjector {
250
328
  if (resp.ok !== true) {
251
329
  throw new Error(`injector op "${op}" failed: ${String(resp.detail ?? 'unknown error')}`);
252
330
  }
331
+ this.idle.touch();
253
332
  return resp;
254
333
  });
255
334
  }
@@ -288,10 +367,13 @@ class PowerShellInjector {
288
367
  return { x: Number(resp.x) || 0, y: Number(resp.y) || 0 };
289
368
  }
290
369
  async dispose() {
370
+ this.idle.stop();
291
371
  this.failAll(new Error('injector disposed'));
292
372
  }
293
373
  }
294
374
  let singleton = null;
375
+ /** 常驻注入进程空闲多久自动回收(可配;0 = 只靠显式 dispose)。 */
376
+ const DEFAULT_INJECTOR_IDLE_MS = 3 * 60 * 1000;
295
377
  /**
296
378
  * 取当前平台的 InputInjector。Windows 用 PowerShell 常驻进程(惰性启动);
297
379
  * 其它平台显式抛错——宁可诚实报「不支持」,也不静默失败。
@@ -301,7 +383,7 @@ export function createInputInjector() {
301
383
  throw new Error(`computer use input injection is not yet supported on ${process.platform} ` +
302
384
  '(Windows MVP via PowerShell; Rust enigo injector is the planned cross-platform path).');
303
385
  }
304
- singleton ??= new PowerShellInjector();
386
+ singleton ??= new PowerShellInjector(envInt('MOCODE_CU_INJECTOR_IDLE_MS', DEFAULT_INJECTOR_IDLE_MS));
305
387
  return singleton;
306
388
  }
307
389
  /** 关闭常驻注入进程(REPL 退出 / /cu off 时调用)。 */