mocode-ai 1.3.9 → 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 -2577
  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
@@ -28,12 +28,23 @@ class StdioTransport extends McpTransport {
28
28
  });
29
29
  this.child = child;
30
30
  child.stdout?.on('data', (chunk) => this.onData(chunk));
31
- child.stderr?.on('data', () => { });
32
- child.on('exit', (code, signal) => { this.child = null; if (code !== 0 && signal == null)
33
- this.receive({ error: { message: `stdio server 已退出 (${code})` } }); });
31
+ child.stderr?.on('data', () => {
32
+ /* MCP stderr 是诊断输出,不混入协议流。 */
33
+ });
34
+ child.on('exit', (code, signal) => {
35
+ this.child = null;
36
+ if (code !== 0 && signal == null)
37
+ this.receive({ error: { message: `stdio server 已退出 (${code})` } });
38
+ });
34
39
  await new Promise((resolve, reject) => {
35
- const onSpawn = () => { child.off('error', onError); resolve(); };
36
- const onError = (error) => { child.off('spawn', onSpawn); reject(error); };
40
+ const onSpawn = () => {
41
+ child.off('error', onError);
42
+ resolve();
43
+ };
44
+ const onError = (error) => {
45
+ child.off('spawn', onSpawn);
46
+ reject(error);
47
+ };
37
48
  child.once('spawn', onSpawn);
38
49
  child.once('error', onError);
39
50
  });
@@ -43,7 +54,7 @@ class StdioTransport extends McpTransport {
43
54
  throw new Error('MCP stdio server 未连接');
44
55
  const body = `${JSON.stringify(message)}\n`;
45
56
  // MCP stdio 传输使用 NDJSON;接收端仍兼容 Content-Length,方便接入历史 LSP 风格服务。
46
- await new Promise((resolve, reject) => this.child.stdin.write(body, (error) => error ? reject(error) : resolve()));
57
+ await new Promise((resolve, reject) => this.child.stdin.write(body, (error) => (error ? reject(error) : resolve())));
47
58
  }
48
59
  async close() {
49
60
  const child = this.child;
@@ -83,7 +94,9 @@ class StdioTransport extends McpTransport {
83
94
  try {
84
95
  this.receive(JSON.parse(raw));
85
96
  }
86
- catch { /* 忽略 server 的非协议 stdout,避免打断会话。 */ }
97
+ catch {
98
+ /* 忽略 server 的非协议 stdout,避免打断会话。 */
99
+ }
87
100
  }
88
101
  }
89
102
  class StreamableHttpTransport extends McpTransport {
@@ -93,7 +106,9 @@ class StreamableHttpTransport extends McpTransport {
93
106
  super(receive);
94
107
  this.spec = spec;
95
108
  }
96
- async start() { }
109
+ async start() {
110
+ /* 每个 JSON-RPC 请求自行 POST,无长连接需预热。 */
111
+ }
97
112
  async send(message) {
98
113
  const controller = new AbortController();
99
114
  const timer = setTimeout(() => controller.abort(), timeoutFor(this.spec));
@@ -106,7 +121,11 @@ class StreamableHttpTransport extends McpTransport {
106
121
  if (this.sessionId)
107
122
  headers['mcp-session-id'] = this.sessionId;
108
123
  const response = await fetch(this.spec.url, {
109
- method: 'POST', headers, body: JSON.stringify(message), signal: controller.signal, redirect: 'error',
124
+ method: 'POST',
125
+ headers,
126
+ body: JSON.stringify(message),
127
+ signal: controller.signal,
128
+ redirect: 'error',
110
129
  });
111
130
  this.captureSession(response);
112
131
  if (!response.ok)
@@ -125,12 +144,20 @@ class StreamableHttpTransport extends McpTransport {
125
144
  clearTimeout(timer);
126
145
  }
127
146
  }
128
- async close() { this.sessionId = null; }
129
- captureSession(response) { this.sessionId = response.headers.get('mcp-session-id') ?? this.sessionId; }
130
- receiveEvent(event) { try {
131
- this.receive(JSON.parse(event.data));
147
+ async close() {
148
+ this.sessionId = null;
149
+ }
150
+ captureSession(response) {
151
+ this.sessionId = response.headers.get('mcp-session-id') ?? this.sessionId;
152
+ }
153
+ receiveEvent(event) {
154
+ try {
155
+ this.receive(JSON.parse(event.data));
156
+ }
157
+ catch {
158
+ /* 非 JSON SSE event 无法作为 MCP 响应。 */
159
+ }
132
160
  }
133
- catch { /* 非 JSON SSE event 无法作为 MCP 响应。 */ } }
134
161
  }
135
162
  class SseTransport extends McpTransport {
136
163
  spec;
@@ -147,7 +174,10 @@ class SseTransport extends McpTransport {
147
174
  if (this.connecting)
148
175
  return this.connecting;
149
176
  this.controller = new AbortController();
150
- this.connecting = new Promise((resolve, reject) => { this.resolveEndpoint = resolve; this.rejectEndpoint = reject; });
177
+ this.connecting = new Promise((resolve, reject) => {
178
+ this.resolveEndpoint = resolve;
179
+ this.rejectEndpoint = reject;
180
+ });
151
181
  void this.open();
152
182
  return this.connecting;
153
183
  }
@@ -161,7 +191,9 @@ class SseTransport extends McpTransport {
161
191
  const response = await fetch(this.endpoint, {
162
192
  method: 'POST',
163
193
  headers: { 'content-type': 'application/json', ...this.spec.headers },
164
- body: JSON.stringify(message), signal: controller.signal, redirect: 'error',
194
+ body: JSON.stringify(message),
195
+ signal: controller.signal,
196
+ redirect: 'error',
165
197
  });
166
198
  if (!response.ok)
167
199
  throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 500)}`);
@@ -179,7 +211,9 @@ class SseTransport extends McpTransport {
179
211
  async open() {
180
212
  try {
181
213
  const response = await fetch(this.spec.url, {
182
- headers: { accept: 'text/event-stream', ...this.spec.headers }, signal: this.controller?.signal, redirect: 'error',
214
+ headers: { accept: 'text/event-stream', ...this.spec.headers },
215
+ signal: this.controller?.signal,
216
+ redirect: 'error',
183
217
  });
184
218
  if (!response.ok)
185
219
  throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 500)}`);
@@ -192,7 +226,9 @@ class SseTransport extends McpTransport {
192
226
  try {
193
227
  this.receive(JSON.parse(event.data));
194
228
  }
195
- catch { /* 忽略非 MCP SSE event。 */ }
229
+ catch {
230
+ /* 忽略非 MCP SSE event。 */
231
+ }
196
232
  }, this.controller?.signal);
197
233
  if (!this.endpoint)
198
234
  throw new Error('SSE MCP server 在连接关闭前未提供 endpoint');
@@ -218,13 +254,16 @@ export class McpClient {
218
254
  this.spec = spec;
219
255
  const receive = (message) => this.onMessage(message);
220
256
  // 向后兼容早期仅提供 command/args 的 stdio 配置;新配置显式写 transport 更清晰。
221
- this.transport = spec.transport === 'stdio' || ('command' in spec && typeof spec.command === 'string')
222
- ? new StdioTransport(receive, spec)
223
- : spec.transport === 'sse'
224
- ? new SseTransport(receive, spec)
225
- : new StreamableHttpTransport(receive, spec);
257
+ this.transport =
258
+ spec.transport === 'stdio' || ('command' in spec && typeof spec.command === 'string')
259
+ ? new StdioTransport(receive, spec)
260
+ : spec.transport === 'sse'
261
+ ? new SseTransport(receive, spec)
262
+ : new StreamableHttpTransport(receive, spec);
263
+ }
264
+ isReady() {
265
+ return this.initialized && !this.closed;
226
266
  }
227
- isReady() { return this.initialized && !this.closed; }
228
267
  async initialize() {
229
268
  if (this.isReady())
230
269
  return;
@@ -353,7 +392,13 @@ async function consumeSse(body, onEvent, signal) {
353
392
  let boundary;
354
393
  while ((boundary = buffered.search(/\r?\n\r?\n/)) !== -1) {
355
394
  const block = buffered.slice(0, boundary);
356
- const separatorLength = buffered[boundary] === '\r' ? (buffered[boundary + 2] === '\r' ? 4 : 3) : (buffered[boundary + 1] === '\r' ? 3 : 2);
395
+ const separatorLength = buffered[boundary] === '\r'
396
+ ? buffered[boundary + 2] === '\r'
397
+ ? 4
398
+ : 3
399
+ : buffered[boundary + 1] === '\r'
400
+ ? 3
401
+ : 2;
357
402
  buffered = buffered.slice(boundary + separatorLength);
358
403
  const event = parseSseBlock(block);
359
404
  if (event)
@@ -114,9 +114,7 @@ function positiveNumber(value) {
114
114
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
115
115
  }
116
116
  function stringArray(value) {
117
- return Array.isArray(value) && value.every((item) => typeof item === 'string')
118
- ? value.map(expandEnv)
119
- : undefined;
117
+ return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value.map(expandEnv) : undefined;
120
118
  }
121
119
  function stringRecord(value) {
122
120
  if (!isRecord(value))
package/dist/mcp/index.js CHANGED
@@ -68,7 +68,9 @@ export async function closeAllMcp() {
68
68
  clients.clear();
69
69
  await Promise.allSettled(active.map((client) => client.close()));
70
70
  }
71
- export function getMcpWarnings() { return [...startupWarnings]; }
71
+ export function getMcpWarnings() {
72
+ return [...startupWarnings];
73
+ }
72
74
  function shouldRegisterTool(spec, remoteName) {
73
75
  if (spec.excludeTools?.includes(remoteName))
74
76
  return false;
@@ -12,7 +12,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import { chat } from '../llm/index.js';
14
14
  import { config, isMemoryEnabled } from '../config/index.js';
15
- import { saveEntry, updateEntry, forgetEntry, loadAll, gcMemories, } from './store.js';
15
+ import { saveEntry, updateEntry, forgetEntry, loadAll, gcMemories } from './store.js';
16
16
  import { addTriple } from './graph.js';
17
17
  // ── 日志(静默容错,裁尾保最近)─────────────────────────────────────────────
18
18
  function logPath() {
@@ -41,9 +41,7 @@ function textOf(c) {
41
41
  if (c == null)
42
42
  return '';
43
43
  if (Array.isArray(c)) {
44
- return c
45
- .map((p) => (typeof p === 'string' ? p : p?.text ?? ''))
46
- .join('');
44
+ return c.map((p) => (typeof p === 'string' ? p : (p?.text ?? ''))).join('');
47
45
  }
48
46
  return String(c);
49
47
  }
@@ -56,8 +54,7 @@ export function snapshotTranscript(history, K) {
56
54
  const lines = convo.map((m) => {
57
55
  const role = m.role ?? '?';
58
56
  let line = `${role}: ${textOf(m.content)}`;
59
- const tcs = m
60
- .tool_calls;
57
+ const tcs = m.tool_calls;
61
58
  if (Array.isArray(tcs)) {
62
59
  for (const tc of tcs) {
63
60
  line += `\n [tool_call ${tc?.function?.name ?? ''}] ${tc?.function?.arguments ?? ''}`;
@@ -11,7 +11,7 @@
11
11
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
12
12
  import os from 'node:os';
13
13
  import path from 'node:path';
14
- import { MAX_ACTIVE, MAX_INDEX_ENTRIES, MAX_MEMORY_ENTRY, DECAY_DAYS, GC_DAYS, } from '../tools/constants.js';
14
+ import { MAX_ACTIVE, MAX_INDEX_ENTRIES, MAX_MEMORY_ENTRY, DECAY_DAYS, GC_DAYS } from '../tools/constants.js';
15
15
  // ── 路径 ──────────────────────────────────────────────────────────────────
16
16
  function globalPath() {
17
17
  return path.join(os.homedir(), '.mocode', 'memory.jsonl');
@@ -163,12 +163,11 @@ function scoreEntry(e, terms) {
163
163
  export function searchEntries(query, opts = {}) {
164
164
  const all = loadAll();
165
165
  const status = opts.status ?? 'active';
166
- const pool = all.filter((e) => (status === 'any' ? true : e.status === status))
166
+ const pool = all
167
+ .filter((e) => (status === 'any' ? true : e.status === status))
167
168
  .filter((e) => (opts.type ? e.type === opts.type : true));
168
169
  const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
169
- const scored = pool
170
- .map((e) => ({ e, s: scoreEntry(e, terms) }))
171
- .filter((x) => x.s > 0);
170
+ const scored = pool.map((e) => ({ e, s: scoreEntry(e, terms) })).filter((x) => x.s > 0);
172
171
  scored.sort((a, b) => b.s - a.s);
173
172
  const limit = Math.max(1, Math.min(opts.limit ?? 5, 20));
174
173
  const top = scored.slice(0, limit);
@@ -324,10 +323,9 @@ export function gcMemories() {
324
323
  * 封顶 MAX_INDEX_ENTRIES;尾部标 hidden 数量,引导用 memory_list/memory_search 兜底。
325
324
  * 真正「陈旧」被滤掉时也明示(让 LLM 知道有内容存在但被策略隐藏,而不是误以为空)。
326
325
  *
327
- * memoryEnabled=false 时(记忆子系统总开关关闭)直接返空串:Memory Index 段
328
- * 不进系统提示,LLM 看不到工具使用提示;配合 tools/builtins 屏蔽 memory_* 工具,
329
- * 实现「关闭时零侵入」(默认行为)。传参由 repl 的 buildSystemMessage 在拼装前调
330
- * isMemoryEnabled() 注入(本文件是叶子,避免直接引 config 起环)。
326
+ * memoryEnabled=false 时直接返空串,使 Memory Index 零注入;memory 工具仍在 registry 中,
327
+ * 但官方主 Agent 是否看见它们由独立的 per-turn ToolPolicy route gate 决定。传参由 repl
328
+ * 在拼装 system message 前调用 isMemoryEnabled() 得到(本文件保持叶子依赖)。
331
329
  */
332
330
  export function buildMemoryIndexSection(memoryEnabled = true) {
333
331
  if (!memoryEnabled)
@@ -372,9 +370,9 @@ export function buildMemoryIndexSection(memoryEnabled = true) {
372
370
  '',
373
371
  '',
374
372
  '## Memory Index (retrieve full body via memory_search)',
375
- 'The following are saved memory entries (title/summary only). Retrieve full body via memory_search (pass id or keyword); use memory_list to see all,'
376
- + ' memory_update to modify, memory_forget to archive. This list is a startup snapshot; entries added during the session are not listed here — use memory_list/memory_search to find them.'
377
- + ' Index policy: pinned + recently-recalled always shown; long-untouched active entries are hidden to keep this section lean.',
373
+ 'The following are saved memory entries (title/summary only). Retrieve full body via memory_search (pass id or keyword); use memory_list to see all,' +
374
+ ' memory_update to modify, memory_forget to archive. This list is a startup snapshot; entries added during the session are not listed here — use memory_list/memory_search to find them.' +
375
+ ' Index policy: pinned + recently-recalled always shown; long-untouched active entries are hidden to keep this section lean.',
378
376
  ...lines,
379
377
  tail,
380
378
  ].join('\n');
@@ -27,12 +27,36 @@ function canonicalProjectRoot(root) {
27
27
  const resolved = path.resolve(root);
28
28
  return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
29
29
  }
30
+ /**
31
+ * computer 工具的 type/key 文本内容审查(docs/computer-use-design.md §1.3):
32
+ * 命中 URL / 密码形态 / 支付关键词时,无论是否已授权都强制 once 级确认——
33
+ * 这类文本是「把内容敲进任意应用」的载体,不能因 session/项目授权而放行后续所有输入。
34
+ * 纯函数,独立可单测。
35
+ */
36
+ export function computerTextNeedsReview(text) {
37
+ const s = text.toLowerCase();
38
+ if (/https?:\/\/|www\.[a-z0-9-]+\.[a-z]{2,}/i.test(s))
39
+ return true;
40
+ if (/\b(pass(word)?|passwd|pwd|secret|token|api[_-]?key|credential)\b\s*[:=]/i.test(s))
41
+ return true;
42
+ if (/\b(card\s*number|cvv|cvc|expiry)\b/i.test(s))
43
+ return true;
44
+ // CJK 关键词没有 \b 词边界概念(\b 只在 \w 与非 \w 之间成立,中文不是 \w),用普通子串匹配。
45
+ if (/信用卡|卡号|密码|支付|付款|验证码/.test(s))
46
+ return true;
47
+ return false;
48
+ }
30
49
  /** A grant is bound to the actual command or logical resources, never merely a tool name. */
31
50
  export function permissionFingerprint(tool, args) {
32
51
  let subject;
33
52
  if (tool.name === 'run_command' && typeof args.command === 'string') {
34
53
  subject = { command: args.command.trim() };
35
54
  }
55
+ else if (tool.name === 'computer') {
56
+ // computer:坐标/文本每次不同,进指纹会让每次点击都弹窗(功能上等同不可用)。
57
+ // 按动作粒度授权:允许一次 left_click = 本会话允许任意坐标的左键(docs/computer-use-design.md §3.6)。
58
+ subject = { computer: typeof args.action === 'string' ? args.action : 'unknown' };
59
+ }
36
60
  else {
37
61
  let resources;
38
62
  try {
@@ -43,9 +67,7 @@ export function permissionFingerprint(tool, args) {
43
67
  }
44
68
  // File mutations may be granted by their concrete resource. Coarse resources such as
45
69
  // "workspace" must retain arguments so task/process-like calls cannot become tool-wide.
46
- subject = resources?.length && typeof args.path === 'string'
47
- ? { resources }
48
- : stable(args);
70
+ subject = resources?.length && typeof args.path === 'string' ? { resources } : stable(args);
49
71
  }
50
72
  return crypto.createHash('sha256').update(JSON.stringify(subject)).digest('hex');
51
73
  }
@@ -53,10 +75,10 @@ function validGrant(value) {
53
75
  if (!value || typeof value !== 'object')
54
76
  return false;
55
77
  const grant = value;
56
- return typeof grant.tool === 'string'
57
- && typeof grant.fingerprint === 'string'
58
- && grant.fingerprint.length > 0
59
- && (grant.scope === 'project' || grant.scope === 'session' || grant.scope === 'once');
78
+ return (typeof grant.tool === 'string' &&
79
+ typeof grant.fingerprint === 'string' &&
80
+ grant.fingerprint.length > 0 &&
81
+ (grant.scope === 'project' || grant.scope === 'session' || grant.scope === 'once'));
60
82
  }
61
83
  function loadPermanent() {
62
84
  if (permanentLoaded)
@@ -69,9 +91,10 @@ function loadPermanent() {
69
91
  : [];
70
92
  // Only the explicit v3 field enables broad grants. The retired legacy allowForever
71
93
  // field remains ignored so upgrades cannot silently restore old authorization.
72
- permanentToolAllows = parsed.version === PERMISSIONS_VERSION && Array.isArray(parsed.alwaysAllowTools)
73
- ? new Set(parsed.alwaysAllowTools.filter((tool) => typeof tool === 'string' && tool.length > 0))
74
- : new Set();
94
+ permanentToolAllows =
95
+ parsed.version === PERMISSIONS_VERSION && Array.isArray(parsed.alwaysAllowTools)
96
+ ? new Set(parsed.alwaysAllowTools.filter((tool) => typeof tool === 'string' && tool.length > 0))
97
+ : new Set();
75
98
  }
76
99
  catch {
77
100
  permanentGrants = [];
@@ -102,6 +125,17 @@ function summarizeArgs(args) {
102
125
  lines.push(t('permission.path', { value: args.path }));
103
126
  if (typeof args.command === 'string')
104
127
  lines.push(t('permission.command', { value: args.command }));
128
+ if (typeof args.action === 'string') {
129
+ // computer 工具:动作名 + 坐标/文本预览(文本截断防长串刷屏)。
130
+ const parts = [args.action];
131
+ if (Array.isArray(args.coordinate))
132
+ parts.push(`@(${args.coordinate.join(', ')})`);
133
+ if (typeof args.text === 'string' && args.text) {
134
+ const preview = args.text.slice(0, 80);
135
+ parts.push(`"${preview}${args.text.length > 80 ? '…' : ''}"`);
136
+ }
137
+ lines.push(t('permission.computerAction', { value: parts.join(' ') }));
138
+ }
105
139
  if (typeof args.prompt === 'string') {
106
140
  const preview = args.prompt.slice(0, 100);
107
141
  lines.push(t('permission.task', { value: `${preview}${args.prompt.length > 100 ? '…' : ''}` }));
@@ -109,9 +143,9 @@ function summarizeArgs(args) {
109
143
  return lines.length ? lines.join('\n') : t('permission.noArgs');
110
144
  }
111
145
  function matches(grant, tool, fingerprint, projectRoot) {
112
- return grant.tool === tool
113
- && grant.fingerprint === fingerprint
114
- && (grant.scope !== 'project' || grant.projectRoot === projectRoot);
146
+ return (grant.tool === tool &&
147
+ grant.fingerprint === fingerprint &&
148
+ (grant.scope !== 'project' || grant.projectRoot === projectRoot));
115
149
  }
116
150
  export async function checkPermission(tool, args, signal, options = {}) {
117
151
  if (!config.permissionEnabled || getToolRisk(tool) === 'safe')
@@ -121,12 +155,21 @@ export async function checkPermission(tool, args, signal, options = {}) {
121
155
  loadPermanent();
122
156
  if (permanentToolAllows.has(tool.name))
123
157
  return 'allow';
158
+ // computer 的 type/key 命中敏感内容(URL/密码/支付)时强制 once 级确认:
159
+ // 不看任何授权缓存,也不提供 session/项目/永久授权选项——这类文本是「敲进任意应用」的载体,
160
+ // 一旦放行不该沿用到下一次输入(docs/computer-use-design.md §1.3/§3.6)。
161
+ const forceOnce = tool.name === 'computer' &&
162
+ (args.action === 'type' || args.action === 'key') &&
163
+ typeof args.text === 'string' &&
164
+ computerTextNeedsReview(args.text);
124
165
  const fingerprint = permissionFingerprint(tool, args);
125
166
  const projectRoot = canonicalProjectRoot(options.projectRoot ?? getSandboxRoot() ?? process.cwd());
126
- if (sessionGrants.some((grant) => matches(grant, tool.name, fingerprint, projectRoot)))
127
- return 'allow';
128
- if (permanentGrants.some((grant) => matches(grant, tool.name, fingerprint, projectRoot)))
129
- return 'allow';
167
+ if (!forceOnce) {
168
+ if (sessionGrants.some((grant) => matches(grant, tool.name, fingerprint, projectRoot)))
169
+ return 'allow';
170
+ if (permanentGrants.some((grant) => matches(grant, tool.name, fingerprint, projectRoot)))
171
+ return 'allow';
172
+ }
130
173
  // CI/pipes must fail closed. Operators can deliberately restore unattended behavior.
131
174
  if (!process.stdin.isTTY && !config.permissionNonInteractiveAllow && !options.prompt)
132
175
  return 'deny';
@@ -138,10 +181,14 @@ export async function checkPermission(tool, args, signal, options = {}) {
138
181
  const alwaysOption = t('permission.allowForever');
139
182
  const denyOption = t('permission.deny');
140
183
  const dangerous = getToolRisk(tool) === 'dangerous';
141
- const choices = [onceOption, sessionOption, projectOption, alwaysOption, denyOption];
184
+ const choices = forceOnce
185
+ ? [onceOption, denyOption]
186
+ : [onceOption, sessionOption, projectOption, alwaysOption, denyOption];
142
187
  // 新手首次审批引导:第一次弹出确认面板时附一段说明(每个用户只出现一次,
143
188
  // ~/.mocode/config 的 onboarding_approval_hint 标记;读写失败静默,不阻断审批)。
144
189
  let detail = summarizeArgs(args) + (dangerous ? `\n\n${t('permission.dangerWarning')}` : '');
190
+ if (forceOnce)
191
+ detail += `\n\n${t('permission.computerSensitiveReview')}`;
145
192
  const ONBOARDING_KEY = 'onboarding_approval_hint';
146
193
  try {
147
194
  if (!readConfigFile()[ONBOARDING_KEY]) {
@@ -205,10 +252,7 @@ export function listPermanentToolAllows() {
205
252
  /** Compatibility API: returns tools having any persistent resource or tool-wide grant. */
206
253
  export function listPermanentAllow() {
207
254
  loadPermanent();
208
- return [...new Set([
209
- ...permanentGrants.map((grant) => grant.tool),
210
- ...permanentToolAllows,
211
- ])].sort();
255
+ return [...new Set([...permanentGrants.map((grant) => grant.tool), ...permanentToolAllows])].sort();
212
256
  }
213
257
  export function clearSessionPermissionGrants() {
214
258
  sessionGrants.length = 0;
@@ -27,7 +27,7 @@ let lastSentState = null;
27
27
  let heartbeatTimer = null;
28
28
  let heartbeatTimeoutTimer = null;
29
29
  /** list_skins 请求的等待队列(先进先出;桌宠单连接场景下不会有并发歧义)。 */
30
- let pendingSkinListResolvers = [];
30
+ const pendingSkinListResolvers = [];
31
31
  function clearHeartbeat() {
32
32
  if (heartbeatTimer)
33
33
  clearInterval(heartbeatTimer);
@@ -117,7 +117,7 @@ export function spawnPetProcess() {
117
117
  const require = createRequire(import.meta.url);
118
118
  binPath = require.resolve('mocode-pet-app/bin/pet-app.js');
119
119
  }
120
- catch (e) {
120
+ catch {
121
121
  reject(new Error('mocode-pet-app 未安装,请运行 npm install mocode-pet-app'));
122
122
  return;
123
123
  }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * 外观命令组:/theme [name|list]
3
+ *
4
+ * 无参开菜单(↑↓ 选,Enter 切换,Esc 取消);/theme <name> 直切;/theme list 或未知名 → 列出。
5
+ *
6
+ * 切换路径:setTheme → 重算状态行(新色)→ 清内容重绘(历史 / 横幅,镜像启动 + /resume)→
7
+ * 确认 → 持久化。markdown MEMO 按 themeVersion 自动失效,故 renderHistory 取新色;
8
+ * 状态栏 / 输入框由 next 回 INPUT 态时读 getter 刷。
9
+ */
10
+ import * as layout from '../../ui/layout.js';
11
+ import { ui, setTheme, getTheme, listThemes, themeExists } from '../../ui/theme.js';
12
+ import { bannerLines } from '../../ui/render.js';
13
+ import { t } from '../../i18n/index.js';
14
+ import { config } from '../../config/index.js';
15
+ import { updateConfigKey } from '../../config/file.js';
16
+ import { promptThemePicker } from '../../ui/prompt.js';
17
+ import { themeDescription } from '../commands.js';
18
+ import { renderHistory } from '../message-format.js';
19
+ import { unhandled, next } from './types.js';
20
+ export const appearanceCommands = [
21
+ async (ctx) => {
22
+ const { line } = ctx;
23
+ if (line !== '/theme' && !line.startsWith('/theme '))
24
+ return unhandled();
25
+ const arg = line.startsWith('/theme ') ? line.slice('/theme '.length).trim() : '';
26
+ let name;
27
+ if (arg === '') {
28
+ // 无参:菜单选(仿 /resume /rollback 的 picker)
29
+ const items = listThemes().map((th) => ({
30
+ id: th,
31
+ title: th,
32
+ subtitle: themeDescription(th),
33
+ }));
34
+ let pick;
35
+ try {
36
+ pick = await promptThemePicker(items);
37
+ }
38
+ catch {
39
+ return next(); // Ctrl+C(SIGINT)→ 取消
40
+ }
41
+ name = pick?.id ?? null;
42
+ }
43
+ else if (arg === 'list' || !themeExists(arg)) {
44
+ layout.contentWrite(`${ui.dim}${t('repl.themeList')}${ui.reset}\n`);
45
+ for (const theme of listThemes()) {
46
+ layout.contentWrite(` ${ui.accent}${theme}${ui.reset} ${ui.dim}${themeDescription(theme)}${ui.reset}\n`);
47
+ }
48
+ layout.contentWrite(`${ui.dim}${t('repl.themeCurrent', { theme: getTheme() })}${ui.reset}\n`);
49
+ return next();
50
+ }
51
+ else {
52
+ name = arg;
53
+ }
54
+ if (name === null)
55
+ return next(); // Esc / Ctrl+D 取消
56
+ setTheme(name);
57
+ ctx.refreshStatusBase(ctx.history);
58
+ layout.clearContent();
59
+ if (ctx.history.some((m) => m.role === 'user')) {
60
+ renderHistory(ctx.history);
61
+ }
62
+ else {
63
+ layout.writeBanner(bannerLines(ctx.banner()));
64
+ }
65
+ layout.contentWrite(`${ui.dim}${t('repl.themeChanged', { theme: name })}${ui.reset}\n`);
66
+ updateConfigKey('MOCODE_THEME', name);
67
+ if (config.themeFromShell) {
68
+ layout.contentWrite(`${ui.dim}(shell 环境变量 MOCODE_THEME 已设,文件写入下次启动被其覆盖;取消该 shell 设置后生效)${ui.reset}\n`);
69
+ }
70
+ return next();
71
+ },
72
+ ];
@@ -0,0 +1,114 @@
1
+ /**
2
+ * 压缩命令组:/compact [focus] · /compact --no-force [focus]
3
+ *
4
+ * 摘要是几十秒的 LLM 调用:包一层 startRunningListener 让 Ctrl+C 能掐断
5
+ * (信号透传 manualCompact → maybeCompact → compactHistory → chat)。
6
+ * 中断时 history 未被改动(重建发生在摘要成功之后),直接提示回输入态。
7
+ *
8
+ * 会话状态(plan + 笔记段)不在此处回写 history[0]:agent/core 每步都在
9
+ * requestHistory 末尾注入最新副本(buildSessionStateReminder),压缩后下一步
10
+ * 自然恢复,且系统提示保持逐字节稳定以命中 prompt 缓存。
11
+ */
12
+ import * as layout from '../../ui/layout.js';
13
+ import { ui } from '../../ui/theme.js';
14
+ import { t } from '../../i18n/index.js';
15
+ import { manualCompact, appendCurrentSessionRuntimeEvent, hashTraceValue } from '../../session/index.js';
16
+ import { startRunningListener, stopRunningListener } from '../running-input.js';
17
+ import { unhandled, next } from './types.js';
18
+ export const compactCommands = [
19
+ async (ctx) => {
20
+ const { line } = ctx;
21
+ if (line !== '/compact' && !line.startsWith('/compact '))
22
+ return unhandled();
23
+ // /compact 默认强制压缩(force=true),不受阈值/保护区限制
24
+ const rest = line.slice('/compact'.length).trim();
25
+ let force = true; // 默认强制
26
+ let focus;
27
+ if (rest === '--no-force')
28
+ force = false;
29
+ else if (rest.startsWith('--no-force ')) {
30
+ force = false;
31
+ focus = rest.slice('--no-force '.length).trim() || undefined;
32
+ }
33
+ else if (rest)
34
+ focus = rest;
35
+ // 走调度器路径:与自动每步压缩完全一致——五区按 ROI 压(cold tools 优先 → history 摘要最后)。
36
+ // focus 透传到 compact_history action 的 LLM 摘要 prompt。
37
+ // 返回 SchedulerRunLog 给 UI 显示决策;退化路径(开关关时)在 manualCompact 内部走 compactHistory。
38
+ const log = await (async () => {
39
+ const signal = startRunningListener(t('running.compacting'));
40
+ try {
41
+ return await manualCompact(ctx.history, focus, { force, signal });
42
+ }
43
+ finally {
44
+ stopRunningListener();
45
+ }
46
+ })().catch((e) => {
47
+ // 中断:history 未被改动(重建在摘要成功之后),直接提示并回到输入态。
48
+ if (e instanceof Error && (e.name === 'AbortError' || e.name === 'APIUserAbortError')) {
49
+ layout.contentWrite(`${ui.dim}(已取消压缩)${ui.reset}\n`);
50
+ return null;
51
+ }
52
+ throw e;
53
+ });
54
+ if (!log)
55
+ return next();
56
+ const d = log.compactDetail;
57
+ appendCurrentSessionRuntimeEvent('compact', {
58
+ source: 'manual',
59
+ force,
60
+ called: log.compactHistoryCalled,
61
+ reason: d?.reason ?? 'unknown',
62
+ estimateBefore: d?.estimateBefore,
63
+ estimateAfter: d?.estimateAfter,
64
+ focusHash: focus ? hashTraceValue(focus) : undefined,
65
+ });
66
+ if (!d) {
67
+ // 兜底(旧调用):只显示 old 文案
68
+ if (!log.compactHistoryCalled) {
69
+ layout.contentWrite(`${ui.dim}(无需压缩:没有可压缩的旧消息)${ui.reset}\n`);
70
+ }
71
+ else if (focus) {
72
+ layout.contentWrite(`${ui.dim}(带焦点压缩:${focus})${ui.reset}\n`);
73
+ }
74
+ return next();
75
+ }
76
+ // 详细文案:按 reason 分类
77
+ const reason = d.reason;
78
+ const before = d.estimateBefore;
79
+ const after = d.estimateAfter;
80
+ const proto = d.protectedRatio !== undefined ? `保护区占比 ${(d.protectedRatio * 100).toFixed(0)}%` : '';
81
+ const oldCt = d.oldGroupCount !== undefined ? `旧区组数 ${d.oldGroupCount}` : '';
82
+ const focusNote = focus ? `焦点:${focus}` : '';
83
+ const stats = [proto, oldCt].filter(Boolean).join(' · ');
84
+ if (reason === 'microcompact') {
85
+ layout.contentWrite(`${ui.cyan}✓ 微压缩:${ui.reset} ${before} → ${after} tokens${stats ? ` (${ui.dim}${stats}${ui.reset})` : ''}\n`);
86
+ }
87
+ else if (reason === 'summarize') {
88
+ layout.contentWrite(`${ui.cyan}✓ LLM 摘要:${ui.reset} ${before} → ${after} tokens${focusNote ? ` (${ui.dim}${focusNote}${ui.reset})` : ''}\n`);
89
+ }
90
+ else if (reason === 'noop-empty') {
91
+ layout.contentWrite(`${ui.dim}(history 太短,只有 system 提示,无可压旧区)${ui.reset}\n`);
92
+ }
93
+ else if (reason === 'noop-protected') {
94
+ layout.contentWrite(`${ui.dim}(无可压旧区:全部在保护区 system + 当前轮)${ui.reset}${stats ? ` ${ui.dim}(${stats})${ui.reset}` : ''}\n`);
95
+ layout.contentWrite(`${ui.dim}提示:/compact --force 强行把早期对话压成摘要${ui.reset}\n`);
96
+ }
97
+ else if (reason === 'noop-ml-only') {
98
+ layout.contentWrite(`${ui.dim}(LLM 摘要失败,且无超大单条可微压;可能是后端不可用)${ui.reset}\n`);
99
+ layout.contentWrite(`${ui.dim}回退:只跑了 keep-current 结构,history 未变${ui.reset}\n`);
100
+ }
101
+ else if (reason === 'noop-shrunk-too-large') {
102
+ layout.contentWrite(`${ui.yellow}● 上下文已超阈但无可压缩项(全在保护区),建议 /clear 或缩短输入。${ui.reset}\n`);
103
+ if (stats)
104
+ layout.contentWrite(`${ui.dim}(${stats})${ui.reset}\n`);
105
+ }
106
+ else if (reason === 'noop-noold-noop') {
107
+ layout.contentWrite(`${ui.dim}(无需压缩:没有可压缩的旧消息,且不在手动触发)${ui.reset}\n`);
108
+ }
109
+ else {
110
+ layout.contentWrite(`${ui.dim}(reason=${reason},${before} → ${after} tokens)${ui.reset}\n`);
111
+ }
112
+ return next();
113
+ },
114
+ ];