mocode-ai 1.3.10 → 1.4.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.
Files changed (134) hide show
  1. package/README.md +78 -70
  2. package/README.zh-CN.md +81 -73
  3. package/bin/mocode-agent-host.js +1 -1
  4. package/dist/agent/core.js +226 -293
  5. package/dist/agent/index.js +14 -10
  6. package/dist/agent/mode.js +3 -3
  7. package/dist/agent/runtime-context.js +45 -0
  8. package/dist/agent/spawn.js +101 -86
  9. package/dist/agent/tool-helpers.js +134 -0
  10. package/dist/agent/trace-state.js +118 -0
  11. package/dist/agents/coordinator.js +6 -1
  12. package/dist/changeset/index.js +9 -8
  13. package/dist/config/index.js +109 -77
  14. package/dist/config/presets.js +3 -1
  15. package/dist/config/profiles.js +125 -0
  16. package/dist/context/age-aware.js +2 -4
  17. package/dist/context/artifacts.js +6 -13
  18. package/dist/context/budget.js +6 -8
  19. package/dist/context/classifier.js +1 -2
  20. package/dist/context/encoders/code.js +2 -6
  21. package/dist/context/encoders/command.js +1 -3
  22. package/dist/context/encoders/search.js +1 -3
  23. package/dist/context/index.js +1 -1
  24. package/dist/context/lifecycle.js +5 -3
  25. package/dist/context/pipeline.js +1 -1
  26. package/dist/context/relevance.js +2 -4
  27. package/dist/context/token-calibration.js +13 -18
  28. package/dist/host/stdio.js +73 -12
  29. package/dist/i18n/index.js +96 -44
  30. package/dist/index.js +1 -1
  31. package/dist/llm/index.js +39 -23
  32. package/dist/llm/provider.js +23 -0
  33. package/dist/llm/providers/anthropic.js +12 -18
  34. package/dist/llm/tool-schema.js +48 -0
  35. package/dist/mcp/client.js +70 -25
  36. package/dist/mcp/config.js +1 -3
  37. package/dist/mcp/index.js +3 -1
  38. package/dist/memory/reflect.js +3 -6
  39. package/dist/memory/store.js +10 -12
  40. package/dist/permissions/index.js +66 -22
  41. package/dist/pet/bridge.js +2 -2
  42. package/dist/repl/commands/appearance.js +72 -0
  43. package/dist/repl/commands/compact.js +114 -0
  44. package/dist/repl/commands/context.js +17 -0
  45. package/dist/repl/commands/image.js +71 -0
  46. package/dist/repl/commands/memory.js +51 -0
  47. package/dist/repl/commands/mode.js +47 -0
  48. package/dist/repl/commands/model.js +435 -0
  49. package/dist/repl/commands/pet.js +70 -0
  50. package/dist/repl/commands/registry.js +46 -0
  51. package/dist/repl/commands/session.js +102 -0
  52. package/dist/repl/commands/skill.js +95 -0
  53. package/dist/repl/commands/system.js +202 -0
  54. package/dist/repl/commands/tool-group.js +233 -0
  55. package/dist/repl/commands/types.js +4 -0
  56. package/dist/repl/commands.js +324 -0
  57. package/dist/repl/index.js +5 -2581
  58. package/dist/repl/message-format.js +151 -0
  59. package/dist/repl/running-input.js +147 -0
  60. package/dist/repl/runtime.js +816 -0
  61. package/dist/repl/status-bar.js +161 -0
  62. package/dist/rollback/index.js +44 -14
  63. package/dist/runtime/browser-manager.js +1 -1
  64. package/dist/runtime/dev-server-manager.js +1 -1
  65. package/dist/runtime/input-injector.js +313 -0
  66. package/dist/runtime/screen-capture.js +89 -0
  67. package/dist/runtime/screen-pipeline.js +223 -0
  68. package/dist/sandbox/index.js +1 -1
  69. package/dist/sandbox/policy.js +8 -2
  70. package/dist/session/compact.js +22 -34
  71. package/dist/session/index.js +2 -2
  72. package/dist/session/notes.js +39 -5
  73. package/dist/session/persist.js +14 -8
  74. package/dist/session/scheduler.js +3 -4
  75. package/dist/session/trace-metrics.js +6 -3
  76. package/dist/skills/activation.js +10 -5
  77. package/dist/skills/discover.js +5 -17
  78. package/dist/skills/index.js +1 -1
  79. package/dist/skills/runner.js +28 -44
  80. package/dist/skills/toolmap.js +5 -2
  81. package/dist/tools/builtins/browser.js +17 -3
  82. package/dist/tools/builtins/computer.js +322 -0
  83. package/dist/tools/builtins/dev-server.js +1 -3
  84. package/dist/tools/builtins/edit-file.js +21 -7
  85. package/dist/tools/builtins/grep.js +1 -1
  86. package/dist/tools/builtins/index.js +28 -25
  87. package/dist/tools/builtins/memory-forget.js +1 -1
  88. package/dist/tools/builtins/memory-graph.js +4 -7
  89. package/dist/tools/builtins/memory-list.js +2 -6
  90. package/dist/tools/builtins/memory-save.js +8 -2
  91. package/dist/tools/builtins/memory-search.js +4 -4
  92. package/dist/tools/builtins/note-append.js +14 -3
  93. package/dist/tools/builtins/plan-update.js +40 -9
  94. package/dist/tools/builtins/run-command.js +8 -8
  95. package/dist/tools/builtins/screenshot.js +11 -87
  96. package/dist/tools/builtins/task.js +29 -40
  97. package/dist/tools/builtins/use-skill.js +1 -1
  98. package/dist/tools/builtins/view-image.js +5 -3
  99. package/dist/tools/builtins/web-fetch.js +1 -3
  100. package/dist/tools/builtins/web-search.js +3 -7
  101. package/dist/tools/builtins/write-file.js +4 -2
  102. package/dist/tools/constants.js +50 -37
  103. package/dist/tools/policy.js +221 -0
  104. package/dist/tools/registry.js +40 -19
  105. package/dist/tools/resource-lock.js +4 -2
  106. package/dist/tools/router.js +143 -0
  107. package/dist/tools/validation.js +6 -5
  108. package/dist/ui/batch.js +63 -23
  109. package/dist/ui/clipboard.js +17 -5
  110. package/dist/ui/content.js +166 -13
  111. package/dist/ui/diff.js +61 -19
  112. package/dist/ui/fuzzy.js +35 -2
  113. package/dist/ui/intervention.js +2 -4
  114. package/dist/ui/layout-internal/content-write.js +587 -0
  115. package/dist/ui/layout-internal/core.js +590 -0
  116. package/dist/ui/layout-internal/input-paint.js +365 -0
  117. package/dist/ui/layout-internal/screen.js +128 -0
  118. package/dist/ui/layout-internal/scroll.js +152 -0
  119. package/dist/ui/layout-internal/selection.js +229 -0
  120. package/dist/ui/layout-internal/state.js +82 -0
  121. package/dist/ui/layout-internal/statusbar.js +351 -0
  122. package/dist/ui/layout-types.js +1 -0
  123. package/dist/ui/layout.js +3 -2288
  124. package/dist/ui/markdown.js +10 -2
  125. package/dist/ui/prompt-internal/editor.js +945 -0
  126. package/dist/ui/prompt-internal/paste.js +52 -0
  127. package/dist/ui/prompt-internal/pickers.js +519 -0
  128. package/dist/ui/prompt-internal/types.js +1 -0
  129. package/dist/ui/prompt.js +4 -1506
  130. package/dist/ui/render.js +87 -2
  131. package/dist/verification/affected.js +11 -5
  132. package/dist/verification/discovery.js +1 -3
  133. package/dist/verification/profile.js +11 -3
  134. package/package.json +11 -2
@@ -0,0 +1,161 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { ui } from '../ui/theme.js';
4
+ import * as layout from '../ui/layout.js';
5
+ import { t } from '../i18n/index.js';
6
+ import { config } from '../config/index.js';
7
+ import { contextState } from '../session/index.js';
8
+ import { DEFAULT_BUDGET_POLICY } from '../context/budget.js';
9
+ import { estimateMessagesTokens, estimatePromptTokens, estimateTokens, chatTools } from '../llm/index.js';
10
+ import { computePruneStats } from '../context/relevance.js';
11
+ import { formatArtifactTokenSources } from '../context/artifacts.js';
12
+ import { getAgentMode } from '../agent/mode.js';
13
+ import { getCurrentSessionId } from '../session/state.js';
14
+ import { getSandboxRoot } from '../sandbox/root.js';
15
+ import { planStepLabel } from '../session/notes.js';
16
+ /** /context 的用量条(详情版,进内容区):只算对话内容(不含 system prompt),方便用户感知自己发了多少、agent 回复了多少。 */
17
+ export function renderContextBar(history) {
18
+ // 过滤掉 system 消息,只算对话内容
19
+ const dialog = history.filter((m) => m.role !== 'system');
20
+ const est = estimateMessagesTokens(dialog);
21
+ const win = config.contextWindowTokens;
22
+ const pct = Math.min(1, est / win);
23
+ const W = 10;
24
+ const filled = Math.round(pct * W);
25
+ const bar = '█'.repeat(filled) + '░'.repeat(W - filled);
26
+ const src = contextState.lastUsage ? t('status.measured') : t('status.estimated');
27
+ const k = (n) => `${Math.round(n / 1000)}k`;
28
+ const pctCol = pct >= DEFAULT_BUDGET_POLICY.pressureTriggerRatio ? ui.yellow : ui.accent;
29
+ const lifecycle = contextState.lifecycleStats;
30
+ const archived = computePruneStats(history);
31
+ const artifactStats = contextState.artifactStats;
32
+ const artifactLine = artifactStats
33
+ ? `\n artifacts · fresh ${artifactStats.fresh} · stale ${artifactStats.stale} · stubbed ${archived.stubbed} · tokens ${formatArtifactTokenSources(artifactStats)}`
34
+ : '\n artifacts · no file-backed facts recorded';
35
+ const lifecycleLine = lifecycle
36
+ ? `\n lifecycle · live ${lifecycle.live} · referenced ${lifecycle.referenced} · digested ${lifecycle.digested} · stubbed ${lifecycle.stubbed}`
37
+ : '\n lifecycle · no active snapshot (run a tool-enabled turn first)';
38
+ const archiveLine = `\n archived tool results · ${archived.stubbed}`;
39
+ return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${Math.round(pct * 100)}% ${k(est)}/${k(win)} tokens · ${t('status.messages', { count: history.length })} (${src})${ui.reset}${artifactLine}${lifecycleLine}${archiveLine}`;
40
+ }
41
+ /** 状态行用量条(精简版,进底栏):[bar] pct% k/k。
42
+ * 必须**用全 prompt 估算**(消息 + 工具 schema + 尾部 ephemeral 注入),与压缩触发器
43
+ * evaluateBudget 的 system+history+toolOld+toolRecent 总账对齐——任何一段漏算都会让
44
+ * bar 与触发器口径不一致、看着没到 80% 实际已经在压。
45
+ * 触发器用 `Math.max(rawTotal, total) >= 0.8 * window`,bar 也照搬:校正后和校正前
46
+ * 哪个大取哪个,确保不会因 correction<1 而低估。ephemeral 文本由 agent/core 每步写入
47
+ * contextState.ephemeralText(避免在 bar 里再读一次 notes.md)。
48
+ *
49
+ * /context 命令仍是 dialog-only(见 renderContextBar):它的设计意图是"我说了多少"而非
50
+ * "还剩多少空间",两条职责分开。 */
51
+ export function renderContextBarInline(history) {
52
+ const baseRaw = estimatePromptTokens(history, chatTools, 1);
53
+ const baseAdj = estimatePromptTokens(history, chatTools, contextState.correction);
54
+ const ephemeral = contextState.ephemeralText ? estimateTokens(contextState.ephemeralText) : 0;
55
+ // 与触发器同样的「取大」语义:correction<1 时 raw 更大,bar 不会假装很安全。
56
+ const est = Math.max(baseRaw, baseAdj) + ephemeral;
57
+ const win = config.contextWindowTokens;
58
+ const pct = Math.min(1, est / win);
59
+ const W = 10;
60
+ const filled = Math.round(pct * W);
61
+ const bar = '█'.repeat(filled) + '░'.repeat(W - filled);
62
+ const k = (n) => `${Math.round(n / 1000)}k`;
63
+ const pctCol = pct >= DEFAULT_BUDGET_POLICY.pressureTriggerRatio ? ui.yellow : ui.accent;
64
+ return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${pctCol}${Math.round(pct * 100)}%${ui.reset} ${ui.dim}${k(est)}/${k(win)}${ui.reset}`;
65
+ }
66
+ // 宿主侧记录已结束轮次最后看到的 plan。notes.md 仍完整保留,只抑制未变化的旧 plan 状态栏,
67
+ // 避免 agent 忘记把 `## Plan:` 改成 `## Done:` 时输入框上方永久悬挂。
68
+ let settledPlanFingerprint;
69
+ /** 读取 notes.md 中唯一活跃的 `## Plan:` 段。进度只统计该段,避免其他笔记 checkbox 污染计数。 */
70
+ export function readPlanStatusFromNotes() {
71
+ const sessionId = getCurrentSessionId();
72
+ if (!sessionId)
73
+ return null;
74
+ const root = getSandboxRoot() ?? process.cwd();
75
+ const p = path.join(root, '.mocode', 'sessions', sessionId, 'notes.md');
76
+ try {
77
+ const normalized = fs.readFileSync(p, 'utf8').replace(/\r\n?/g, '\n');
78
+ const lines = normalized.split('\n');
79
+ const start = lines.findIndex((line) => /^## Plan:\s*.+$/.test(line));
80
+ if (start < 0)
81
+ return null;
82
+ const endOffset = lines.slice(start + 1).findIndex((line) => /^##\s/.test(line));
83
+ const end = endOffset < 0 ? lines.length : start + 1 + endOffset;
84
+ const section = lines.slice(start, end).join('\n').trimEnd();
85
+ const title = lines[start].match(/^## Plan:\s*(.+)$/)?.[1].trim();
86
+ if (!title)
87
+ return null;
88
+ const total = (section.match(/^\s*-\s*\[[ xX]\]\s*\d+\./gm) || []).length;
89
+ const currentMatch = section.match(/^\s*-\s*\[ \]\s*(\d+)\.\s*(.+)$/m);
90
+ const current = currentMatch ? planStepLabel(currentMatch[2]) : undefined;
91
+ // 括号进度 = 「当前执行到的步骤序号/总数」(执行第 1 步显示 1/3),与 `▸ 当前步` 后缀自洽;
92
+ // 旧语义 done/total 会永远慢一拍(执行第 2 步显示 1/3)。全勾选时显示 total/total(通常已自动结算为 Done,chip 消失)。
93
+ const activeNo = currentMatch ? Number(currentMatch[1]) : total;
94
+ const summary = `plan: ${title} (${activeNo}/${total})`;
95
+ // mtime 让“相同内容被重写为一项新计划”也能重新出现,而不被旧轮次误抑制。
96
+ const fingerprint = `${sessionId}\0${fs.statSync(p).mtimeMs}\0${section}`;
97
+ return { fingerprint, summary: current ? `${summary} ▸ ${current}` : summary };
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
103
+ /** 将当前 plan 标记为已结算。只影响状态栏,不修改 agent 的工作笔记。 */
104
+ export function settlePlanStatus() {
105
+ settledPlanFingerprint = readPlanStatusFromNotes()?.fingerprint;
106
+ }
107
+ /** 从 notes.md 读取活跃 plan 摘要;已结算且未变化的旧 plan 不再显示。 */
108
+ export function readPlanFromNotes() {
109
+ const plan = readPlanStatusFromNotes();
110
+ if (!plan || plan.fingerprint === settledPlanFingerprint)
111
+ return '';
112
+ return plan.summary;
113
+ }
114
+ /** 状态行基线:模型 / context / cwd / 模式标识 / 活跃 plan chip / 本轮 token。repl 在轮次边界、切模式、plan 变更时调。 */
115
+ export function refreshStatusBase(history, lastTurnUsage) {
116
+ layout.setStatusBase({
117
+ model: config.model,
118
+ contextBar: renderContextBarInline(history),
119
+ cwd: process.cwd(),
120
+ modeTag: getAgentMode() === 'plan' ? 'Plan' : 'Auto',
121
+ planSummary: readPlanFromNotes(),
122
+ lastTurnUsage,
123
+ });
124
+ }
125
+ /** 命令 → 运行态状态文字 + 底栏 dim 占位。 */
126
+ export function runningStateFor(cmd) {
127
+ switch (cmd) {
128
+ case '/compact':
129
+ return { status: t('running.compact'), placeholder: t('running.compacting') };
130
+ case '/resume':
131
+ return { status: t('running.resume'), placeholder: t('running.chooseSession') };
132
+ case '/rollback':
133
+ return { status: t('running.rollback'), placeholder: t('running.chooseTurn') };
134
+ case '/init':
135
+ return { status: t('running.init'), placeholder: t('running.generateMemory') };
136
+ case '/plan':
137
+ return { status: t('running.plan'), placeholder: '…' };
138
+ case '/auto':
139
+ return { status: t('running.auto'), placeholder: '…' };
140
+ case '/clear':
141
+ return { status: t('running.clear'), placeholder: '…' };
142
+ case '/theme':
143
+ return { status: t('running.theme'), placeholder: t('running.chooseTheme') };
144
+ case '/model':
145
+ return { status: t('running.model'), placeholder: t('running.configuring') };
146
+ case '/pet':
147
+ return { status: t('running.pet'), placeholder: t('running.processing') };
148
+ case '/memory_switch':
149
+ return { status: t('running.memory'), placeholder: t('running.switching') };
150
+ case '/memory_status':
151
+ return { status: t('running.memoryStatus'), placeholder: '…' };
152
+ case '/subagent':
153
+ return { status: t('running.subagent'), placeholder: t('running.switching') };
154
+ case '/language':
155
+ return { status: t('running.language'), placeholder: t('running.chooseLanguage') };
156
+ case '/upgrade':
157
+ return { status: t('running.upgrade'), placeholder: t('running.upgrading') };
158
+ default:
159
+ return { status: '', placeholder: '' };
160
+ }
161
+ }
@@ -111,9 +111,7 @@ function addSnapshot(next) {
111
111
  const existingSequence = existing.sequence ?? Number.MAX_SAFE_INTEGER;
112
112
  const nextSequence = next.sequence ?? Number.MAX_SAFE_INTEGER;
113
113
  const ops = new Set([...(existing.ops ?? []), ...(next.ops ?? [])]);
114
- const latestAfterFingerprint = nextSequence >= existingSequence
115
- ? next.afterFingerprint
116
- : existing.afterFingerprint;
114
+ const latestAfterFingerprint = nextSequence >= existingSequence ? next.afterFingerprint : existing.afterFingerprint;
117
115
  if (nextSequence < existingSequence) {
118
116
  snapshots[existingIndex] = { ...next, ops: [...ops], afterFingerprint: latestAfterFingerprint };
119
117
  }
@@ -183,15 +181,48 @@ export function endPathMutation(capture, op) {
183
181
  const EXCLUDED_WORKSPACE_DIRS = new Set([
184
182
  // VCS / 索引 / mocode 自身运行时状态(会话、trace、dev-server 日志、记忆、截图每轮都在写,
185
183
  // 既无回滚意义,又会被误判成模型改动)
186
- '.git', '.hg', '.svn', '.codegraph', '.mocode',
184
+ '.git',
185
+ '.hg',
186
+ '.svn',
187
+ '.codegraph',
188
+ '.mocode',
187
189
  // 依赖树与包管理器缓存
188
- 'node_modules', 'vendor', 'bower_components', '.yarn', '.pnpm-store', '.venv', 'venv', 'pods',
190
+ 'node_modules',
191
+ 'vendor',
192
+ 'bower_components',
193
+ '.yarn',
194
+ '.pnpm-store',
195
+ '.venv',
196
+ 'venv',
197
+ 'pods',
189
198
  // 构建产物
190
- 'dist', 'build', 'out', 'target', 'coverage', '.output', '.next', '.nuxt', '.vite',
191
- '.turbo', '.svelte-kit', '.angular', '.astro', '.docusaurus', '.dart_tool', '.terraform',
199
+ 'dist',
200
+ 'build',
201
+ 'out',
202
+ 'target',
203
+ 'coverage',
204
+ '.output',
205
+ '.next',
206
+ '.nuxt',
207
+ '.vite',
208
+ '.turbo',
209
+ '.svelte-kit',
210
+ '.angular',
211
+ '.astro',
212
+ '.docusaurus',
213
+ '.dart_tool',
214
+ '.terraform',
192
215
  // 临时与缓存
193
- '.tmp', 'tmp', '.cache', '.parcel-cache', '.nyc_output', '__pycache__',
194
- '.pytest_cache', '.mypy_cache', '.ruff_cache', '.gradle',
216
+ '.tmp',
217
+ 'tmp',
218
+ '.cache',
219
+ '.parcel-cache',
220
+ '.nyc_output',
221
+ '__pycache__',
222
+ '.pytest_cache',
223
+ '.mypy_cache',
224
+ '.ruff_cache',
225
+ '.gradle',
195
226
  ]);
196
227
  /** 单文件内容捕获上限:更大的文件只留 stamp(可检测变化,不可恢复),避免把巨型二进制读进内存。 */
197
228
  const CAPTURE_FILE_LIMIT = 1024 * 1024;
@@ -498,17 +529,16 @@ export function applyRollback(plan, history, revertPaths) {
498
529
  if (snapshot.turnId <= plan.cutoffTurnId || !revertPaths.has(snapshot.path))
499
530
  continue;
500
531
  const latestSnapshot = latest.get(snapshot.path);
501
- if (!latestSnapshot || snapshot.turnId > latestSnapshot.turnId ||
502
- (snapshot.turnId === latestSnapshot.turnId &&
503
- (snapshot.sequence ?? -1) > (latestSnapshot.sequence ?? -1))) {
532
+ if (!latestSnapshot ||
533
+ snapshot.turnId > latestSnapshot.turnId ||
534
+ (snapshot.turnId === latestSnapshot.turnId && (snapshot.sequence ?? -1) > (latestSnapshot.sequence ?? -1))) {
504
535
  latest.set(snapshot.path, snapshot);
505
536
  }
506
537
  const existing = picks.get(snapshot.path);
507
538
  if (!existing ||
508
539
  snapshot.turnId < existing.turnId ||
509
540
  (snapshot.turnId === existing.turnId &&
510
- (snapshot.sequence ?? Number.MAX_SAFE_INTEGER) <
511
- (existing.sequence ?? Number.MAX_SAFE_INTEGER))) {
541
+ (snapshot.sequence ?? Number.MAX_SAFE_INTEGER) < (existing.sequence ?? Number.MAX_SAFE_INTEGER))) {
512
542
  picks.set(snapshot.path, snapshot);
513
543
  }
514
544
  }
@@ -244,7 +244,7 @@ export async function readText(opts) {
244
244
  let text;
245
245
  try {
246
246
  text = opts.selector
247
- ? (await session.page.textContent(opts.selector, { timeout: DEFAULT_TIMEOUT_MS })) ?? ''
247
+ ? ((await session.page.textContent(opts.selector, { timeout: DEFAULT_TIMEOUT_MS })) ?? '')
248
248
  : await session.page.innerText('body');
249
249
  }
250
250
  catch (error) {
@@ -254,7 +254,7 @@ export async function startDevServer(opts) {
254
254
  ready = true;
255
255
  break;
256
256
  }
257
- if (readyUrl && await probeReady(readyUrl)) {
257
+ if (readyUrl && (await probeReady(readyUrl))) {
258
258
  ready = true;
259
259
  break;
260
260
  }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * OS 级输入注入(computer 工具的「手」)。
3
+ *
4
+ * 架构:InputInjector 接口与实现解耦——
5
+ * - 阶段一(MVP,本文件):PowerShell 常驻子进程,stdin 收 NDJSON 动作、stdout 回 NDJSON 结果,
6
+ * P/Invoke user32.dll(SendInput / SetCursorPos / GetCursorPos / mouse_event)。零新依赖。
7
+ * - 阶段二(终态,docs/computer-use-design.md P4,本轮不做):rust/ enigo 二进制,同协议替换。
8
+ *
9
+ * 非 Windows 平台 MVP 显式抛错(诚实降级),不静默假装支持。
10
+ * 键名映射接受 Anthropic 风格键名(Return / Alt_L / ctrl+s),在 TS 侧映射成 VK 码,
11
+ * PowerShell 只负责按 VK 码发键——映射逻辑留在可单测的 TS 里。
12
+ */
13
+ import { spawn } from 'node:child_process';
14
+ import { filterEnv } from '../sandbox/index.js';
15
+ // ── Anthropic 键名 → Windows VK 码 ─────────────────────────────────────
16
+ 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,
29
+ };
30
+ for (let i = 1; i <= 12; i++)
31
+ VK_NAMED[`f${i}`] = 0x6f + i;
32
+ /** 把 'ctrl+shift+t' / 'Return' 这类组合键解析为 VK 码数组(修饰键在前)。未知键名抛错。 */
33
+ export function mapKeyComboToVk(combo) {
34
+ const parts = combo
35
+ .split('+')
36
+ .map((s) => s.trim())
37
+ .filter(Boolean);
38
+ if (parts.length === 0)
39
+ throw new Error(`empty key combo: "${combo}"`);
40
+ return parts.map((part) => {
41
+ const key = part.toLowerCase();
42
+ const named = VK_NAMED[key];
43
+ if (named !== undefined)
44
+ return named;
45
+ if (key.length === 1) {
46
+ const c = key.charCodeAt(0);
47
+ if (c >= 0x61 && c <= 0x7a)
48
+ return c - 0x20; // a-z → 0x41-0x5A
49
+ if (c >= 0x30 && c <= 0x39)
50
+ return c; // 0-9
51
+ const punct = VK_NAMED[{ '-': 'minus', '=': 'equals', ',': 'comma', '.': 'period', '/': 'slash', ';': 'semicolon', "'": 'quote', '[': 'bracketleft', ']': 'bracketright', '\\': 'backslash', '`': 'backquote' }[key] ?? ''];
52
+ if (punct !== undefined)
53
+ return punct;
54
+ }
55
+ throw new Error(`unknown key name: "${part}" in combo "${combo}"`);
56
+ });
57
+ }
58
+ // ── PowerShell 常驻进程协议 ─────────────────────────────────────────────
59
+ /** PowerShell 侧脚本:加载 P/Invoke 声明一次,然后 NDJSON 循环收动作。 */
60
+ const PS_INJECTOR_SCRIPT = [
61
+ '$ErrorActionPreference = "Stop"',
62
+ '[Console]::InputEncoding = [System.Text.Encoding]::UTF8',
63
+ '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
64
+ 'Add-Type -TypeDefinition @"',
65
+ 'using System;',
66
+ 'using System.Runtime.InteropServices;',
67
+ 'public class Inject {',
68
+ ' [DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y);',
69
+ ' [DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT lpPoint);',
70
+ ' [DllImport("user32.dll")] public static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);',
71
+ ' [DllImport("user32.dll")] public static extern void mouse_event(uint dwFlags, uint dx, uint dy, int dwData, UIntPtr dwExtraInfo);',
72
+ ' public struct POINT { public int X; public int Y; }',
73
+ ' [StructLayout(LayoutKind.Sequential)] public struct INPUT { public uint type; public INPUTUNION U; }',
74
+ ' [StructLayout(LayoutKind.Explicit)] public struct INPUTUNION { [FieldOffset(0)] public MOUSEINPUT mi; [FieldOffset(0)] public KEYBDINPUT ki; }',
75
+ ' [StructLayout(LayoutKind.Sequential)] public struct MOUSEINPUT { public int dx; public int dy; public uint mouseData; public uint dwFlags; public uint time; public UIntPtr dwExtraInfo; }',
76
+ ' [StructLayout(LayoutKind.Sequential)] public struct KEYBDINPUT { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public UIntPtr dwExtraInfo; }',
77
+ '}',
78
+ '"@',
79
+ 'function Send-VkCombo([int[]]$vks) {',
80
+ ' $size = [Runtime.InteropServices.Marshal]::SizeOf([type]"Inject+INPUT")',
81
+ ' foreach ($vk in $vks) {',
82
+ ' $inp = New-Object "Inject+INPUT"',
83
+ ' $inp.type = 1',
84
+ ' $inp.U.ki.wVk = $vk',
85
+ ' $inp.U.ki.dwFlags = 0',
86
+ ' [Inject]::SendInput(1, [Inject+INPUT[]]@($inp), $size) | Out-Null',
87
+ ' }',
88
+ ' [array]::Reverse($vks)',
89
+ ' foreach ($vk in $vks) {',
90
+ ' $inp = New-Object "Inject+INPUT"',
91
+ ' $inp.type = 1',
92
+ ' $inp.U.ki.wVk = $vk',
93
+ ' $inp.U.ki.dwFlags = 2',
94
+ ' [Inject]::SendInput(1, [Inject+INPUT[]]@($inp), $size) | Out-Null',
95
+ ' }',
96
+ '}',
97
+ 'function Send-UnicodeText([string]$text) {',
98
+ ' $size = [Runtime.InteropServices.Marshal]::SizeOf([type]"Inject+INPUT")',
99
+ ' for ($i = 0; $i -lt $text.Length; $i++) {',
100
+ ' $code = [int][char]$text[$i]',
101
+ ' foreach ($flag in @(4, 6)) {',
102
+ ' $inp = New-Object "Inject+INPUT"',
103
+ ' $inp.type = 1',
104
+ ' $inp.U.ki.wVk = 0',
105
+ ' $inp.U.ki.wScan = $code',
106
+ ' $inp.U.ki.dwFlags = $flag',
107
+ ' [Inject]::SendInput(1, [Inject+INPUT[]]@($inp), $size) | Out-Null',
108
+ ' }',
109
+ ' }',
110
+ '}',
111
+ 'while ($null -ne ($line = [Console]::In.ReadLine())) {',
112
+ ' $resp = @{ id = 0; ok = $false; detail = "" }',
113
+ ' try {',
114
+ ' $req = $line | ConvertFrom-Json',
115
+ ' $resp.id = $req.id',
116
+ ' switch ($req.op) {',
117
+ ' "ping" { $resp.ok = $true }',
118
+ ' "move" { [Inject]::SetCursorPos([int]$req.x, [int]$req.y) | Out-Null; $resp.ok = $true }',
119
+ ' "click" {',
120
+ ' $flags = switch ($req.button) { "right" { @(8, 16) } "middle" { @(32, 64) } default { @(2, 4) } }',
121
+ ' for ($n = 0; $n -lt [int]$req.count; $n++) {',
122
+ ' [Inject]::mouse_event($flags[0], 0, 0, 0, [UIntPtr]::Zero)',
123
+ ' [Inject]::mouse_event($flags[1], 0, 0, 0, [UIntPtr]::Zero)',
124
+ ' if ($n -lt [int]$req.count - 1) { Start-Sleep -Milliseconds 40 }',
125
+ ' }',
126
+ ' $resp.ok = $true',
127
+ ' }',
128
+ ' "mousedown" {',
129
+ ' $flag = switch ($req.button) { "right" { 8 } "middle" { 32 } default { 2 } }',
130
+ ' [Inject]::mouse_event($flag, 0, 0, 0, [UIntPtr]::Zero); $resp.ok = $true',
131
+ ' }',
132
+ ' "mouseup" {',
133
+ ' $flag = switch ($req.button) { "right" { 16 } "middle" { 64 } default { 4 } }',
134
+ ' [Inject]::mouse_event($flag, 0, 0, 0, [UIntPtr]::Zero); $resp.ok = $true',
135
+ ' }',
136
+ ' "type" { Send-UnicodeText([string]$req.text); $resp.ok = $true }',
137
+ ' "key" { Send-VkCombo([int[]]$req.vks); $resp.ok = $true }',
138
+ ' "scroll" {',
139
+ ' $amt = [int]$req.amount * 120',
140
+ ' if ($req.direction -eq "down" -or $req.direction -eq "left") { $amt = -$amt }',
141
+ ' $flag = if ($req.direction -eq "left" -or $req.direction -eq "right") { 4096 } else { 2048 }',
142
+ ' [Inject]::mouse_event($flag, 0, 0, $amt, [UIntPtr]::Zero); $resp.ok = $true',
143
+ ' }',
144
+ ' "cursor" {',
145
+ ' $p = New-Object "Inject+POINT"',
146
+ ' [Inject]::GetCursorPos([ref]$p) | Out-Null',
147
+ ' $resp.x = $p.X; $resp.y = $p.Y; $resp.ok = $true',
148
+ ' }',
149
+ ' default { $resp.detail = "unknown op: $($req.op)" }',
150
+ ' }',
151
+ ' } catch {',
152
+ ' $resp.ok = $false',
153
+ ' $resp.detail = $_.Exception.Message',
154
+ ' }',
155
+ ' [Console]::Out.WriteLine(($resp | ConvertTo-Json -Compress))',
156
+ ' [Console]::Out.Flush()',
157
+ '}',
158
+ ].join('\n');
159
+ const OP_TIMEOUT_MS = 10000;
160
+ class PowerShellInjector {
161
+ child = null;
162
+ nextId = 1;
163
+ pending = new Map();
164
+ stdoutBuf = '';
165
+ lastError = '';
166
+ ensureProcess() {
167
+ if (this.child && this.child.exitCode === null && !this.child.killed)
168
+ return this.child;
169
+ this.child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', PS_INJECTOR_SCRIPT], { env: filterEnv(process.env), windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
170
+ this.stdoutBuf = '';
171
+ this.lastError = '';
172
+ this.child.stdout.on('data', (chunk) => this.onStdout(chunk.toString('utf8')));
173
+ this.child.stderr.on('data', (chunk) => {
174
+ this.lastError = (this.lastError + chunk.toString('utf8')).slice(-2000);
175
+ });
176
+ this.child.on('exit', () => this.failAll(new Error(`injector process exited${this.lastError ? `: ${this.lastError.trim()}` : ''}`)));
177
+ this.child.on('error', (err) => this.failAll(err));
178
+ return this.child;
179
+ }
180
+ onStdout(text) {
181
+ this.stdoutBuf += text;
182
+ let idx;
183
+ while ((idx = this.stdoutBuf.indexOf('\n')) >= 0) {
184
+ const line = this.stdoutBuf.slice(0, idx).trim();
185
+ this.stdoutBuf = this.stdoutBuf.slice(idx + 1);
186
+ if (!line)
187
+ continue;
188
+ let msg;
189
+ try {
190
+ msg = JSON.parse(line);
191
+ }
192
+ catch {
193
+ continue; // 非协议行(Add-Type 警告等),忽略
194
+ }
195
+ const id = typeof msg.id === 'number' ? msg.id : -1;
196
+ const op = this.pending.get(id);
197
+ if (!op)
198
+ continue;
199
+ this.pending.delete(id);
200
+ clearTimeout(op.timer);
201
+ op.resolve(msg);
202
+ }
203
+ }
204
+ failAll(error) {
205
+ for (const [, op] of this.pending) {
206
+ clearTimeout(op.timer);
207
+ op.reject(error);
208
+ }
209
+ this.pending.clear();
210
+ if (this.child) {
211
+ try {
212
+ this.child.kill();
213
+ }
214
+ catch {
215
+ /* 进程已退出 */
216
+ }
217
+ this.child = null;
218
+ }
219
+ }
220
+ call(op, params, signal) {
221
+ const child = this.ensureProcess();
222
+ const id = this.nextId++;
223
+ return new Promise((resolve, reject) => {
224
+ const timer = setTimeout(() => {
225
+ this.pending.delete(id);
226
+ this.failAll(new Error(`injector op "${op}" timed out after ${OP_TIMEOUT_MS}ms`));
227
+ reject(new Error(`injector op "${op}" timed out`));
228
+ }, OP_TIMEOUT_MS);
229
+ this.pending.set(id, { resolve, reject, timer });
230
+ const onAbort = () => {
231
+ this.pending.delete(id);
232
+ clearTimeout(timer);
233
+ this.failAll(new Error('aborted'));
234
+ reject(new Error('Input injection aborted.'));
235
+ };
236
+ if (signal) {
237
+ if (signal.aborted)
238
+ return onAbort();
239
+ signal.addEventListener('abort', onAbort, { once: true });
240
+ }
241
+ try {
242
+ child.stdin.write(`${JSON.stringify({ id, op, ...params })}\n`, 'utf8');
243
+ }
244
+ catch (err) {
245
+ this.pending.delete(id);
246
+ clearTimeout(timer);
247
+ reject(err instanceof Error ? err : new Error(String(err)));
248
+ }
249
+ }).then(async (resp) => {
250
+ if (resp.ok !== true) {
251
+ throw new Error(`injector op "${op}" failed: ${String(resp.detail ?? 'unknown error')}`);
252
+ }
253
+ return resp;
254
+ });
255
+ }
256
+ signal;
257
+ /** 每次动作批次绑定一次 signal(computer 工具 execute 时设置)。 */
258
+ bindSignal(signal) {
259
+ this.signal = signal;
260
+ }
261
+ async moveTo(x, y) {
262
+ await this.call('move', { x, y }, this.signal);
263
+ }
264
+ async click(button, count) {
265
+ await this.call('click', { button, count }, this.signal);
266
+ }
267
+ async mouseDown(button) {
268
+ await this.call('mousedown', { button }, this.signal);
269
+ }
270
+ async mouseUp(button) {
271
+ await this.call('mouseup', { button }, this.signal);
272
+ }
273
+ async dragTo(x, y) {
274
+ await this.call('move', { x, y }, this.signal);
275
+ }
276
+ async typeText(text) {
277
+ await this.call('type', { text }, this.signal);
278
+ }
279
+ async pressKey(combo) {
280
+ const vks = mapKeyComboToVk(combo);
281
+ await this.call('key', { vks }, this.signal);
282
+ }
283
+ async scroll(direction, amount) {
284
+ await this.call('scroll', { direction, amount }, this.signal);
285
+ }
286
+ async cursorPosition() {
287
+ const resp = await this.call('cursor', {}, this.signal);
288
+ return { x: Number(resp.x) || 0, y: Number(resp.y) || 0 };
289
+ }
290
+ async dispose() {
291
+ this.failAll(new Error('injector disposed'));
292
+ }
293
+ }
294
+ let singleton = null;
295
+ /**
296
+ * 取当前平台的 InputInjector。Windows 用 PowerShell 常驻进程(惰性启动);
297
+ * 其它平台显式抛错——宁可诚实报「不支持」,也不静默失败。
298
+ */
299
+ export function createInputInjector() {
300
+ if (process.platform !== 'win32') {
301
+ throw new Error(`computer use input injection is not yet supported on ${process.platform} ` +
302
+ '(Windows MVP via PowerShell; Rust enigo injector is the planned cross-platform path).');
303
+ }
304
+ singleton ??= new PowerShellInjector();
305
+ return singleton;
306
+ }
307
+ /** 关闭常驻注入进程(REPL 退出 / /cu off 时调用)。 */
308
+ export async function disposeInputInjector() {
309
+ if (singleton) {
310
+ await singleton.dispose();
311
+ singleton = null;
312
+ }
313
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * 跨平台桌面截图捕获:从 tools/builtins/screenshot.ts 抽出的可复用底层。
3
+ * screenshot 工具与 computer 工具的闭环重截屏共用同一份平台逻辑,零重复。
4
+ * 只做「抓屏 → 写 PNG 文件」,不做缩放/坐标/回灌(那是 screen-pipeline 的职责)。
5
+ */
6
+ import { spawn } from 'node:child_process';
7
+ import { filterEnv } from '../sandbox/index.js';
8
+ function runCaptureProcess(program, args, env, signal) {
9
+ return new Promise((resolve) => {
10
+ const child = spawn(program, args, {
11
+ env,
12
+ windowsHide: true,
13
+ stdio: ['ignore', 'ignore', 'pipe'],
14
+ });
15
+ let stderr = '';
16
+ let finished = false;
17
+ const finish = (result) => {
18
+ if (finished)
19
+ return;
20
+ finished = true;
21
+ signal?.removeEventListener('abort', onAbort);
22
+ resolve(result);
23
+ };
24
+ const onAbort = () => {
25
+ child.kill();
26
+ finish({ status: 'aborted', detail: 'Screenshot capture was aborted.' });
27
+ };
28
+ child.stderr.on('data', (chunk) => {
29
+ stderr = (stderr + chunk.toString('utf8')).slice(-4000);
30
+ });
31
+ child.on('error', (error) => finish({ status: 'spawn_error', detail: error.message }));
32
+ child.on('close', (code) => finish({
33
+ status: code === 0 ? 'passed' : 'failed',
34
+ detail: stderr.trim() || `Capture process exited with code ${code ?? 'null'}.`,
35
+ }));
36
+ if (signal) {
37
+ if (signal.aborted)
38
+ onAbort();
39
+ else
40
+ signal.addEventListener('abort', onAbort, { once: true });
41
+ }
42
+ });
43
+ }
44
+ const WINDOWS_CAPTURE_SCRIPT = [
45
+ 'Add-Type -AssemblyName System.Windows.Forms',
46
+ 'Add-Type -AssemblyName System.Drawing',
47
+ "$bounds = if ($env:MOCODE_SCREENSHOT_TARGET -eq 'all') { [System.Windows.Forms.SystemInformation]::VirtualScreen } else { [System.Windows.Forms.Screen]::PrimaryScreen.Bounds }",
48
+ '$bitmap = [System.Drawing.Bitmap]::new($bounds.Width, $bounds.Height)',
49
+ '$graphics = [System.Drawing.Graphics]::FromImage($bitmap)',
50
+ 'try {',
51
+ ' $graphics.CopyFromScreen($bounds.X, $bounds.Y, 0, 0, $bounds.Size)',
52
+ ' $bitmap.Save($env:MOCODE_SCREENSHOT_PATH, [System.Drawing.Imaging.ImageFormat]::Png)',
53
+ '} finally {',
54
+ ' $graphics.Dispose()',
55
+ ' $bitmap.Dispose()',
56
+ '}',
57
+ ].join('\n');
58
+ /** 抓屏并写入 outputPath。target=primary 主屏,all 整个虚拟桌面。 */
59
+ export async function captureDesktop(outputPath, target, signal) {
60
+ const env = {
61
+ ...filterEnv(process.env),
62
+ MOCODE_SCREENSHOT_PATH: outputPath,
63
+ MOCODE_SCREENSHOT_TARGET: target,
64
+ };
65
+ if (process.platform === 'win32') {
66
+ return runCaptureProcess('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', WINDOWS_CAPTURE_SCRIPT], env, signal);
67
+ }
68
+ if (process.platform === 'darwin') {
69
+ const args = ['-x', ...(target === 'primary' ? ['-m'] : []), outputPath];
70
+ return runCaptureProcess('/usr/sbin/screencapture', args, env, signal);
71
+ }
72
+ const candidates = [
73
+ ['gnome-screenshot', ['-f', outputPath]],
74
+ ['grim', [outputPath]],
75
+ ['scrot', [outputPath]],
76
+ ['import', ['-window', 'root', outputPath]],
77
+ ];
78
+ const failures = [];
79
+ for (const [program, args] of candidates) {
80
+ const result = await runCaptureProcess(program, args, env, signal);
81
+ if (result.status === 'passed' || result.status === 'aborted')
82
+ return result;
83
+ failures.push(`${program}: ${result.detail}`);
84
+ }
85
+ return {
86
+ status: 'failed',
87
+ detail: `No supported Linux screenshot command succeeded. ${failures.join(' | ')}`,
88
+ };
89
+ }