mocode-ai 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,10 @@ const zhCN = {
19
19
  'commands.memoryStatus': '查看当前开关与原理',
20
20
  'commands.memoryReflect': '手动触发后台记忆反思',
21
21
  'commands.memoryInit': '扫描项目生成 MOCODE.md 项目记忆',
22
+ 'commands.subagent': '子 Agent 开关(默认关闭)',
23
+ 'commands.subagentOn': '开启子 Agent',
24
+ 'commands.subagentOff': '关闭子 Agent',
25
+ 'commands.subagentStatus': '查看子 Agent 状态',
22
26
  'commands.skill': '项目专属 Skill 管理',
23
27
  'commands.toggle': '切换开关',
24
28
  'commands.skillOn': '开启项目 Skill',
@@ -84,6 +88,7 @@ const zhCN = {
84
88
  'running.memory': '切记忆开关',
85
89
  'running.switching': '切换中…',
86
90
  'running.memoryStatus': '查记忆状态',
91
+ 'running.subagent': '子 Agent',
87
92
  'running.skill': '项目 Skill',
88
93
  'running.language': '切语言',
89
94
  'running.chooseLanguage': '选择语言…',
@@ -160,6 +165,9 @@ const zhCN = {
160
165
  'agent.noReply': '(无回复)',
161
166
  'agent.maxSteps': '达到最大步数({count}),本轮停止。',
162
167
  'agent.aborted': '(已中断)',
168
+ 'agent.validating': '自动验证 {command}',
169
+ 'agent.validationNoCommand': '未发现验证命令',
170
+ 'agent.validationResult': '自动验证 {command} → {status}',
163
171
  'agent.workedFor': '耗时 {elapsed}',
164
172
  'toolSummary.lines': '{count} 行',
165
173
  'toolSummary.files': '{count} 个文件',
@@ -191,13 +199,22 @@ const zhCN = {
191
199
  'permission.deny': '拒绝',
192
200
  'permission.allow': '允许',
193
201
  'permission.allowSession': '本次会话始终允许此工具',
202
+ 'permission.allowSessionResource': '本次会话允许相同命令/资源',
203
+ 'permission.allowProjectResource': '对此项目永久允许相同命令/资源',
194
204
  'askHuman.cancelled': '用户取消了选择。请考虑是否有不依赖用户输入的替代方案,或换个角度重新提问。',
195
205
  'askHuman.submitted': '用户回答:{value}',
196
206
  'askHuman.selected': '用户选择:{value}',
197
207
  'task.missingPrompt': '错误:缺少 prompt(子任务指令)。',
208
+ 'task.disabled': '错误:子 Agent 当前已关闭。需要时请先运行 /subagent on。',
198
209
  'task.interrupted': '子 agent 被中断,未完成。',
199
210
  'task.noSummary': '子 agent 完成但未返回文本摘要(可能只调了工具或达到步数上限)。',
200
211
  'task.summaryTruncated': '…(子 agent 摘要已截断 {count} 字符)',
212
+ 'subagent.status': '子 Agent:{state}',
213
+ 'subagent.stateOn': '开启',
214
+ 'subagent.stateOff': '关闭',
215
+ 'subagent.changedOn': '已开启子 Agent;task 将从下一次模型请求起可用。',
216
+ 'subagent.changedOff': '已关闭子 Agent;task 已从模型工具表移除。',
217
+ 'subagent.usage': '用法:/subagent on|off|status',
201
218
  'plan.ready': '计划已就绪',
202
219
  'plan.approvalDetail': '切换到 auto 模式按上述计划执行?(plan 模式只读探查,执行需切 auto)',
203
220
  'plan.execute': '切 auto 执行',
@@ -228,6 +245,10 @@ const en = {
228
245
  'commands.memoryStatus': 'Show current state and behavior',
229
246
  'commands.memoryReflect': 'Run background memory reflection',
230
247
  'commands.memoryInit': 'Scan project and generate MOCODE.md',
248
+ 'commands.subagent': 'Sub-agent controls (disabled by default)',
249
+ 'commands.subagentOn': 'Enable sub-agents',
250
+ 'commands.subagentOff': 'Disable sub-agents',
251
+ 'commands.subagentStatus': 'Show sub-agent status',
231
252
  'commands.skill': 'Manage the project-specific Skill',
232
253
  'commands.toggle': 'Toggle the feature',
233
254
  'commands.skillOn': 'Enable the project Skill',
@@ -293,6 +314,7 @@ const en = {
293
314
  'running.memory': 'Memory',
294
315
  'running.switching': 'Switching…',
295
316
  'running.memoryStatus': 'Memory status',
317
+ 'running.subagent': 'Sub-agent',
296
318
  'running.skill': 'Project Skill',
297
319
  'running.language': 'Language',
298
320
  'running.chooseLanguage': 'Choose a language…',
@@ -369,6 +391,9 @@ const en = {
369
391
  'agent.noReply': '(no reply)',
370
392
  'agent.maxSteps': 'Maximum steps reached ({count}); this turn has stopped.',
371
393
  'agent.aborted': '(aborted)',
394
+ 'agent.validating': 'Validating {command}',
395
+ 'agent.validationNoCommand': 'no validation command',
396
+ 'agent.validationResult': 'Automatic validation {command} → {status}',
372
397
  'agent.workedFor': 'Worked for {elapsed}',
373
398
  'toolSummary.lines': '{count} lines',
374
399
  'toolSummary.files': '{count} files',
@@ -400,13 +425,22 @@ const en = {
400
425
  'permission.deny': 'Deny',
401
426
  'permission.allow': 'Allow',
402
427
  'permission.allowSession': 'Always allow this tool for this session',
428
+ 'permission.allowSessionResource': 'Allow this command/resource for this session',
429
+ 'permission.allowProjectResource': 'Always allow this command/resource for this project',
403
430
  'askHuman.cancelled': 'The user cancelled the choice. Consider an alternative that does not require user input, or ask from a different angle.',
404
431
  'askHuman.submitted': 'User response: {value}',
405
432
  'askHuman.selected': 'User selected: {value}',
406
433
  'task.missingPrompt': 'Error: missing prompt (sub-task instruction).',
434
+ 'task.disabled': 'Error: sub-agents are disabled. Run /subagent on first if needed.',
407
435
  'task.interrupted': 'The sub-agent was interrupted before completion.',
408
436
  'task.noSummary': 'The sub-agent completed without a text summary (it may only have used tools or reached its step limit).',
409
437
  'task.summaryTruncated': '…(sub-agent summary truncated by {count} characters)',
438
+ 'subagent.status': 'Sub-agent: {state}',
439
+ 'subagent.stateOn': 'enabled',
440
+ 'subagent.stateOff': 'disabled',
441
+ 'subagent.changedOn': 'Sub-agents enabled; task will be available from the next model request.',
442
+ 'subagent.changedOff': 'Sub-agents disabled; task has been removed from the model tool list.',
443
+ 'subagent.usage': 'Usage: /subagent on|off|status',
410
444
  'plan.ready': 'Plan ready',
411
445
  'plan.approvalDetail': 'Switch to auto mode and execute the plan above? (Plan mode is read-only; execution requires auto mode.)',
412
446
  'plan.execute': 'Switch to auto and execute',
package/dist/llm/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import OpenAI from 'openai';
2
- import { config } from '../config/index.js';
2
+ import { config, isSubAgentEnabled } from '../config/index.js';
3
3
  import { tools } from '../tools/registry.js';
4
4
  import { getPlanDisabledTools } from '../tools/constants.js';
5
5
  import { ThinkTagFilter } from './think-filter.js';
@@ -149,7 +149,11 @@ export function __setChatCreateImpl(impl) {
149
149
  export const chatTools = [];
150
150
  export const planChatTools = [];
151
151
  export function refreshChatTools() {
152
- const next = tools.map((t) => ({
152
+ // task 常驻内部 registry,运行时开关只控制模型可见 schema,因而 on/off 可即时生效。
153
+ const visibleTools = isSubAgentEnabled()
154
+ ? tools
155
+ : tools.filter((tool) => tool.name !== 'task');
156
+ const next = visibleTools.map((t) => ({
153
157
  type: 'function',
154
158
  function: {
155
159
  name: t.name,
package/dist/mcp/index.js CHANGED
@@ -41,9 +41,22 @@ export function getMcpTools() {
41
41
  parameters: remote.inputSchema && typeof remote.inputSchema === 'object'
42
42
  ? remote.inputSchema
43
43
  : { type: 'object', properties: {} },
44
- // 远程/本地 server 均可能读写系统或网络;协议没有副作用声明,必须保守处理。
44
+ // MCP 协议没有可靠副作用声明:未知能力、workspace 串行、每次确认。
45
45
  risk: 'dangerous',
46
- execute: async (args, ctx) => formatToolResult(await client.callTool(remote.name, args, ctx?.signal)),
46
+ capabilities: {
47
+ effect: 'unknown',
48
+ concurrency: 'serial',
49
+ retry: 'never',
50
+ resources: () => ['workspace'],
51
+ supportsAbort: true,
52
+ },
53
+ execute: async (args, ctx) => {
54
+ const result = await client.callTool(remote.name, args, ctx?.signal);
55
+ const output = formatToolResult(result);
56
+ return result.isError
57
+ ? { status: 'error', code: 'MCP_ERROR', retryable: false, output }
58
+ : { status: 'success', code: 'OK', retryable: false, output };
59
+ },
47
60
  });
48
61
  }
49
62
  }
@@ -1,142 +1,159 @@
1
+ import crypto from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { promptIntervention } from '../ui/intervention.js';
5
6
  import { config } from '../config/index.js';
7
+ import { getSandboxRoot } from '../sandbox/index.js';
6
8
  import { t } from '../i18n/index.js';
7
- /**
8
- * 工具权限系统:基于 risk 字段在执行前拦截确认。
9
- *
10
- * 设计:
11
- * - safe → 直接放行(只读工具,零交互)
12
- * - confirm → 弹面板确认,同工具同会话缓存(避免重复打断)
13
- * - dangerous → 每次都弹(高风险,命令内容不可预测)
14
- *
15
- * 三层允许(优先级从高到低):
16
- * 1. 永久允许(permanentAllow,跨会话持久化到 ~/.mocode/permissions.json)
17
- * 2. 会话允许(approvedTools,本次进程内缓存)
18
- * 3. 面板询问(promptIntervention,复用 ask_human UI)
19
- *
20
- * 复用 promptIntervention:统一 UI 面板,非 TTY 自动降级(第一项 = 允许)。
21
- */
22
- /** 跨会话持久化允许列表路径 */
23
9
  const PERMISSIONS_PATH = path.join(os.homedir(), '.mocode', 'permissions.json');
24
- /** 永久允许:跨会话持久化(用户选了"以后不再询问此工具") */
25
- let permanentAllow = new Set();
10
+ let permanentGrants = [];
26
11
  let permanentLoaded = false;
27
- /** 会话级缓存:confirm 级工具批准后,后续调用不再弹 */
28
- const approvedTools = new Set();
29
- /** 从磁盘加载永久允许列表(首调时触发,之后用内存缓存;文件不存在/解析失败返空集合) */
12
+ const sessionGrants = [];
13
+ function stable(value) {
14
+ if (Array.isArray(value))
15
+ return value.map(stable);
16
+ if (value && typeof value === 'object') {
17
+ return Object.fromEntries(Object.entries(value)
18
+ .sort(([a], [b]) => a.localeCompare(b))
19
+ .map(([key, item]) => [key, stable(item)]));
20
+ }
21
+ return value;
22
+ }
23
+ function canonicalProjectRoot(root) {
24
+ const resolved = path.resolve(root);
25
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
26
+ }
27
+ /** A grant is bound to the actual command or logical resources, never merely a tool name. */
28
+ export function permissionFingerprint(tool, args) {
29
+ let subject;
30
+ if (tool.name === 'run_command' && typeof args.command === 'string') {
31
+ subject = { command: args.command.trim() };
32
+ }
33
+ else {
34
+ const resources = tool.capabilities?.resources?.(args).filter(Boolean).sort();
35
+ // File mutations may be granted by their concrete resource. Coarse resources such as
36
+ // "workspace" must retain arguments so task/process-like calls cannot become tool-wide.
37
+ subject = resources?.length && typeof args.path === 'string'
38
+ ? { resources }
39
+ : stable(args);
40
+ }
41
+ return crypto.createHash('sha256').update(JSON.stringify(subject)).digest('hex');
42
+ }
43
+ function validGrant(value) {
44
+ if (!value || typeof value !== 'object')
45
+ return false;
46
+ const grant = value;
47
+ return typeof grant.tool === 'string'
48
+ && typeof grant.fingerprint === 'string'
49
+ && grant.fingerprint.length > 0
50
+ && (grant.scope === 'project' || grant.scope === 'session' || grant.scope === 'once');
51
+ }
30
52
  function loadPermanent() {
31
53
  if (permanentLoaded)
32
- return permanentAllow;
54
+ return permanentGrants;
33
55
  permanentLoaded = true;
34
56
  try {
35
- const raw = fs.readFileSync(PERMISSIONS_PATH, 'utf8');
36
- const parsed = JSON.parse(raw);
37
- if (parsed && Array.isArray(parsed.allowForever)) {
38
- permanentAllow = new Set(parsed.allowForever.filter((x) => typeof x === 'string'));
39
- }
57
+ const parsed = JSON.parse(fs.readFileSync(PERMISSIONS_PATH, 'utf8'));
58
+ permanentGrants = Array.isArray(parsed.grants)
59
+ ? parsed.grants.filter(validGrant).filter((grant) => grant.scope === 'project')
60
+ : [];
40
61
  }
41
62
  catch {
42
- // 文件不存在 / 解析失败 → 空集合(不阻断,用户首次使用或配置损坏均安全降级)
63
+ permanentGrants = [];
43
64
  }
44
- return permanentAllow;
65
+ return permanentGrants;
45
66
  }
46
- /** 写入永久允许列表(覆盖写;失败静默,下次启动丢失但不阻断当前会话) */
47
67
  function savePermanent() {
48
68
  try {
49
69
  fs.mkdirSync(path.dirname(PERMISSIONS_PATH), { recursive: true });
50
- fs.writeFileSync(PERMISSIONS_PATH, JSON.stringify({ allowForever: Array.from(permanentAllow) }, null, 2) + '\n', 'utf8');
70
+ fs.writeFileSync(PERMISSIONS_PATH, `${JSON.stringify({ version: 2, grants: permanentGrants }, null, 2)}\n`, 'utf8');
51
71
  }
52
72
  catch {
53
- // 写失败静默(与 updateConfigKey 一致:UI 偏好路径,不阻断 REPL)
73
+ // A failed persistence write must never turn into broader authorization.
54
74
  }
55
75
  }
56
- /** 从 Tool 解析 risk,缺省返 'safe'(只读工具无需标注)。 */
57
76
  export function getToolRisk(tool) {
58
77
  return tool.risk ?? 'safe';
59
78
  }
60
- /** 参数摘要:提取关键参数供确认面板展示(path / command 等) */
61
- function summarizeArgs(tool, args) {
79
+ function summarizeArgs(args) {
62
80
  const lines = [];
63
81
  if (typeof args.path === 'string')
64
82
  lines.push(t('permission.path', { value: args.path }));
65
83
  if (typeof args.command === 'string')
66
84
  lines.push(t('permission.command', { value: args.command }));
67
85
  if (typeof args.prompt === 'string') {
68
- const preview = String(args.prompt).slice(0, 100);
69
- lines.push(t('permission.task', { value: `${preview}${String(args.prompt).length > 100 ? '…' : ''}` }));
86
+ const preview = args.prompt.slice(0, 100);
87
+ lines.push(t('permission.task', { value: `${preview}${args.prompt.length > 100 ? '…' : ''}` }));
70
88
  }
71
- return lines.length > 0 ? lines.join('\n') : t('permission.noArgs');
89
+ return lines.length ? lines.join('\n') : t('permission.noArgs');
72
90
  }
73
- /**
74
- * 检查权限:safe 直接放行;confirm/dangerous 弹面板让用户确认。
75
- *
76
- * 返回 'allow' = 放行, 'deny' = 拒绝(不执行,也不记回滚快照)
77
- * 非 TTY 环境(promptIntervention 内部处理):自动选第一项(允许)并打 stderr 日志,不阻塞。
78
- */
79
- export async function checkPermission(tool, args, signal) {
80
- // 总开关关闭 → 全部放行(零行为变化,向后兼容)
81
- if (!config.permissionEnabled)
82
- return 'allow';
83
- const risk = getToolRisk(tool);
84
- if (risk === 'safe')
91
+ function matches(grant, tool, fingerprint, projectRoot) {
92
+ return grant.tool === tool
93
+ && grant.fingerprint === fingerprint
94
+ && (grant.scope !== 'project' || grant.projectRoot === projectRoot);
95
+ }
96
+ export async function checkPermission(tool, args, signal, options = {}) {
97
+ if (!config.permissionEnabled || getToolRisk(tool) === 'safe')
85
98
  return 'allow';
86
- // 永久允许(跨会话):命中则放行,不弹面板
87
- if (loadPermanent().has(tool.name))
99
+ if (signal?.aborted)
100
+ return 'deny';
101
+ const fingerprint = permissionFingerprint(tool, args);
102
+ const projectRoot = canonicalProjectRoot(options.projectRoot ?? getSandboxRoot() ?? process.cwd());
103
+ if (sessionGrants.some((grant) => matches(grant, tool.name, fingerprint, projectRoot)))
88
104
  return 'allow';
89
- // 会话允许(confirm ):同进程内缓存,命中则放行
90
- if (risk === 'confirm' && approvedTools.has(tool.name))
105
+ if (loadPermanent().some((grant) => matches(grant, tool.name, fingerprint, projectRoot)))
91
106
  return 'allow';
92
- // 构建确认面板
93
- const isDangerous = risk === 'dangerous';
107
+ // CI/pipes must fail closed. Operators can deliberately restore unattended behavior.
108
+ if (!process.stdin.isTTY && !config.permissionNonInteractiveAllow && !options.prompt)
109
+ return 'deny';
110
+ if (signal?.aborted)
111
+ return 'deny';
112
+ const onceOption = t('permission.allow');
113
+ const sessionOption = t('permission.allowSessionResource');
114
+ const projectOption = t('permission.allowProjectResource');
94
115
  const denyOption = t('permission.deny');
95
- const foreverOption = t('permission.allowForever');
96
- const sessionOption = t('permission.allowSession');
97
- const title = isDangerous
98
- ? t('permission.dangerTitle', { tool: tool.name })
99
- : t('permission.confirmTitle', { tool: tool.name });
100
- const detail = summarizeArgs(tool, args) + (isDangerous ? `\n\n${t('permission.dangerWarning')}` : '');
101
- // 选项统一结构:dangerous 也提供"以后不再询问"(用户明确授权即尊重,即使 run_command)
102
- const options = isDangerous
103
- ? [t('permission.confirmExecute'), foreverOption, denyOption]
104
- : [t('permission.allow'), sessionOption, foreverOption, denyOption];
105
- // 弹面板(阻塞直到用户选择;signal 中断时 promptIntervention 内部处理)
106
- const result = await promptIntervention({
116
+ const dangerous = getToolRisk(tool) === 'dangerous';
117
+ const choices = [onceOption, sessionOption, projectOption, denyOption];
118
+ const result = await (options.prompt ?? promptIntervention)({
107
119
  type: 'choice',
108
- title,
109
- detail,
110
- options,
120
+ title: dangerous
121
+ ? t('permission.dangerTitle', { tool: tool.name })
122
+ : t('permission.confirmTitle', { tool: tool.name }),
123
+ detail: summarizeArgs(args) + (dangerous ? `\n\n${t('permission.dangerWarning')}` : ''),
124
+ options: choices,
111
125
  allowCustom: false,
112
126
  });
113
- // 用户取消(Esc / Ctrl+C)→ 拒绝
114
- if (result.action === 'cancelled')
115
- return 'deny';
116
- // 解析选择
117
- const value = result.value ?? '';
118
- if (value === denyOption)
127
+ if (signal?.aborted || result.action === 'cancelled' || result.value === denyOption)
119
128
  return 'deny';
120
- // 永久允许:写入磁盘 + 加入内存集合(跨会话生效)
121
- if (value === foreverOption) {
122
- permanentAllow.add(tool.name);
123
- savePermanent();
124
- return 'allow';
129
+ if (result.value === sessionOption) {
130
+ sessionGrants.push({ tool: tool.name, fingerprint, scope: 'session' });
125
131
  }
126
- // 会话允许(confirm ):加入内存缓存,本次进程内不再弹
127
- if (!isDangerous && value === sessionOption) {
128
- approvedTools.add(tool.name);
132
+ else if (result.value === projectOption) {
133
+ const grant = { tool: tool.name, fingerprint, scope: 'project', projectRoot };
134
+ permanentGrants = loadPermanent().filter((item) => !matches(item, tool.name, fingerprint, projectRoot));
135
+ permanentGrants.push(grant);
136
+ if (options.persistProjectGrant !== false)
137
+ savePermanent();
129
138
  }
130
139
  return 'allow';
131
140
  }
132
- /** 移除工具的永久允许(撤销"以后不再询问"授权)。供未来 `/permissions` 管理命令使用。 */
133
- export function revokePermanentAllow(toolName) {
134
- if (permanentAllow.has(toolName)) {
135
- permanentAllow.delete(toolName);
136
- savePermanent();
137
- }
141
+ export function revokePermanentAllow(toolName, fingerprint) {
142
+ permanentGrants = loadPermanent().filter((grant) => grant.tool !== toolName || (fingerprint !== undefined && grant.fingerprint !== fingerprint));
143
+ savePermanent();
138
144
  }
139
- /** 列出所有永久允许的工具名(供未来 `/permissions` 管理命令使用) */
145
+ export function listPermanentGrants() {
146
+ return loadPermanent().map((grant) => ({ ...grant }));
147
+ }
148
+ /** Compatibility API: returns tools having at least one project-scoped grant. */
140
149
  export function listPermanentAllow() {
141
- return Array.from(loadPermanent());
150
+ return [...new Set(loadPermanent().map((grant) => grant.tool))];
151
+ }
152
+ export function clearSessionPermissionGrants() {
153
+ sessionGrants.length = 0;
154
+ }
155
+ export function resetPermissionGrantsForTests() {
156
+ sessionGrants.length = 0;
157
+ permanentGrants = [];
158
+ permanentLoaded = true;
142
159
  }
@@ -1,7 +1,7 @@
1
1
  import readline from 'node:readline/promises';
2
2
  import { emitKeypressEvents } from 'node:readline';
3
3
  import { stdin, stdout } from 'node:process';
4
- import { config, updateModelConfig, isModelConfigured, updateMemoryConfig, isMemoryEnabled, isProjectSkillEnabled, isProjectSnapshotEnabled, updateProjectSkillConfig, updateSnapshotConfig, updateLanguageConfig, languageFromShell, buildBasePrompt, getPlanModeSuffix, } from '../config/index.js';
4
+ import { config, updateModelConfig, isModelConfigured, updateMemoryConfig, isMemoryEnabled, isSubAgentEnabled, updateSubAgentConfig, isProjectSkillEnabled, isProjectSnapshotEnabled, updateProjectSkillConfig, updateSnapshotConfig, updateLanguageConfig, languageFromShell, buildBasePrompt, getPlanModeSuffix, } from '../config/index.js';
5
5
  import { getLanguage, normalizeLanguage, t, } from '../i18n/index.js';
6
6
  import { updateConfigKey, writeConfigKeys, CONFIG_PATH } from '../config/file.js';
7
7
  import { deletePreset, getPreset, isValidPresetName, listPresets, migrateCurrentToPreset, savePreset, } from '../config/presets.js';
@@ -16,9 +16,9 @@ import * as mouse from '../ui/mouse.js';
16
16
  import * as batch from '../ui/batch.js';
17
17
  import { promptWithSlashMenu, promptTurnPicker, promptSessionPicker, promptThemePicker, promptRevertChoice, } from '../ui/prompt.js';
18
18
  import { promptIntervention } from '../ui/intervention.js';
19
- import { tools, registerToolsExtension } from '../tools/registry.js';
19
+ import { registerToolsExtension } from '../tools/registry.js';
20
20
  import { initializeAllMcp, getMcpTools, closeAllMcp } from '../mcp/index.js';
21
- import { estimateMessagesTokens, reconfigureClient, refreshChatTools, } from '../llm/index.js';
21
+ import { estimateMessagesTokens, reconfigureClient, refreshChatTools, chatTools, } from '../llm/index.js';
22
22
  import { loadImageAttachment, renderChip, MAX_INLINE_BYTES_DEFAULT, } from '../attachments/image.js';
23
23
  import { modelSupportsVision } from '../llm/capabilities.js';
24
24
  import { computePruneStats } from '../context/relevance.js';
@@ -63,6 +63,13 @@ function buildSlashCommands() {
63
63
  { name: 'init', value: '/init', desc: d('commands.memoryInit') },
64
64
  ],
65
65
  },
66
+ {
67
+ name: '/subagent', desc: d('commands.subagent'), children: [
68
+ { name: 'on', value: '/subagent on', desc: d('commands.subagentOn') },
69
+ { name: 'off', value: '/subagent off', desc: d('commands.subagentOff') },
70
+ { name: 'status', value: '/subagent status', desc: d('commands.subagentStatus') },
71
+ ],
72
+ },
66
73
  {
67
74
  name: '/project_skill', desc: d('commands.skill'), children: [
68
75
  { name: 'toggle', value: '/project_skill', desc: d('commands.toggle') },
@@ -322,6 +329,8 @@ function runningStateFor(cmd) {
322
329
  return { status: t('running.memory'), placeholder: t('running.switching') };
323
330
  case '/memory_status':
324
331
  return { status: t('running.memoryStatus'), placeholder: '…' };
332
+ case '/subagent':
333
+ return { status: t('running.subagent'), placeholder: t('running.switching') };
325
334
  case '/project_skill':
326
335
  return { status: t('running.skill'), placeholder: t('running.processing') };
327
336
  case '/snapshot':
@@ -734,12 +743,11 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
734
743
  // 本轮 token 累计:runAgent 返回后写入,供底栏模式 chip 右边显示。undefined=无实测
735
744
  // (后端不开 include_usage / 后端失败时)。
736
745
  let lastTurnUsage;
737
- const toolsLine = tools.map((t) => t.name).join(' · ');
738
746
  const banner = () => ({
739
747
  model: config.model,
740
748
  baseURL: config.baseURL,
741
749
  cwd: process.cwd(),
742
- tools: toolsLine,
750
+ tools: chatTools.map((tool) => tool.function.name).join(' · '),
743
751
  memoryEnabled: isMemoryEnabled(),
744
752
  });
745
753
  // 开场:按 config.theme 切主题(横幅 / 状态行 / 后续渲染皆用新色),再进 alt screen + 状态基线 + 清内容区。
@@ -1858,6 +1866,32 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1858
1866
  await rollbackFlow();
1859
1867
  continue;
1860
1868
  }
1869
+ if (line === '/subagent' || line.startsWith('/subagent ')) {
1870
+ const arg = line.startsWith('/subagent ')
1871
+ ? line.slice('/subagent '.length).trim().toLowerCase()
1872
+ : 'status';
1873
+ if (arg === '' || arg === 'status') {
1874
+ const enabled = isSubAgentEnabled();
1875
+ const state = t(enabled ? 'subagent.stateOn' : 'subagent.stateOff');
1876
+ layout.contentWrite(`${ui.accent}${t('subagent.status', { state })}${ui.reset}\n` +
1877
+ `${ui.dim}MOCODE_SUBAGENT_ENABLED=${enabled ? 'true' : 'false'} · ${CONFIG_PATH}${ui.reset}\n`);
1878
+ continue;
1879
+ }
1880
+ if (arg !== 'on' && arg !== 'off') {
1881
+ layout.contentWrite(`${ui.yellow}${t('subagent.usage')}${ui.reset}\n`);
1882
+ continue;
1883
+ }
1884
+ const enabled = arg === 'on';
1885
+ if (enabled !== isSubAgentEnabled()) {
1886
+ updateSubAgentConfig(enabled);
1887
+ updateConfigKey('MOCODE_SUBAGENT_ENABLED', enabled ? 'true' : 'false');
1888
+ refreshChatTools();
1889
+ history[0] = { role: 'system', content: buildSystemMessage(getAgentMode() === 'plan') };
1890
+ layout.rewriteBanner(bannerLines(banner()));
1891
+ }
1892
+ layout.contentWrite(`${enabled ? ui.green : ui.yellow}${t(enabled ? 'subagent.changedOn' : 'subagent.changedOff')}${ui.reset}\n`);
1893
+ continue;
1894
+ }
1861
1895
  if (line === '/memory_switch' ||
1862
1896
  line.startsWith('/memory_switch ') ||
1863
1897
  line === '/memory_status' ||
@@ -6,6 +6,8 @@ import { toText } from '../context/utils.js';
6
6
  let turnIdCounter = 0;
7
7
  let currentTurnId = 0;
8
8
  let sequenceCounter = 0;
9
+ /** Monotonic process-local generation; repeated writes to one path still invalidate validation. */
10
+ let mutationVersion = 0;
9
11
  let turns = [];
10
12
  let snapshots = [];
11
13
  const rootDir = () => path.resolve(process.cwd());
@@ -128,17 +130,22 @@ export function endPathMutation(capture, op) {
128
130
  const full = safeFullPath(capture.path);
129
131
  if (!full)
130
132
  return;
133
+ let changed = false;
131
134
  const after = readState(full);
132
135
  if (!sameState(capture.before, after)) {
136
+ changed = true;
133
137
  addSnapshot(snapshotFromState(capture.path, capture.before, capture.sequence, op, capture.createdParents));
134
138
  }
135
139
  // write_file 会递归创建父目录;即使最终写文件失败,这些目录也是本轮真实副作用。
136
140
  for (const parentRel of capture.createdParents) {
137
141
  const parent = safeFullPath(parentRel);
138
142
  if (parent && readState(parent).kind !== 'missing') {
143
+ changed = true;
139
144
  addSnapshot(snapshotFromState(parentRel, { kind: 'missing' }, capture.sequence, op));
140
145
  }
141
146
  }
147
+ if (changed)
148
+ mutationVersion += 1;
142
149
  }
143
150
  function isWorkspaceExcluded(full) {
144
151
  const base = path.basename(full).toLowerCase();
@@ -183,13 +190,37 @@ export function beginWorkspaceMutation() {
183
190
  export function endWorkspaceMutation(capture, op) {
184
191
  const after = scanWorkspace();
185
192
  const paths = new Set([...capture.entries.keys(), ...after.keys()]);
193
+ let changed = false;
186
194
  for (const rel of paths) {
187
195
  const beforeState = capture.entries.get(rel) ?? { kind: 'missing' };
188
196
  const afterState = after.get(rel) ?? { kind: 'missing' };
189
197
  if (sameState(beforeState, afterState))
190
198
  continue;
199
+ changed = true;
191
200
  addSnapshot(snapshotFromState(rel, beforeState, capture.sequence, op));
192
201
  }
202
+ if (changed)
203
+ mutationVersion += 1;
204
+ }
205
+ /** Current main turn changes, deduplicated by path, plus a generation for validation invalidation. */
206
+ export function getCurrentTurnMutationState() {
207
+ const order = [];
208
+ const byPath = new Map();
209
+ for (const snapshot of snapshots) {
210
+ if (snapshot.turnId !== currentTurnId)
211
+ continue;
212
+ let change = byPath.get(snapshot.path);
213
+ if (!change) {
214
+ change = { path: snapshot.path, ops: [], snapshotAvailable: true };
215
+ byPath.set(snapshot.path, change);
216
+ order.push(snapshot.path);
217
+ }
218
+ for (const op of snapshot.ops ?? ['file_change']) {
219
+ if (!change.ops.includes(op))
220
+ change.ops.push(op);
221
+ }
222
+ }
223
+ return { version: mutationVersion, changedFiles: order.map((item) => byPath.get(item)) };
193
224
  }
194
225
  export function listTurns() {
195
226
  return turns.slice();
@@ -12,3 +12,4 @@ export { compactHistory, maybeCompact, capToolResultForHistory, truncateMid, con
12
12
  export { runScheduler, manualCompact, createBudgetScheduler, } from './scheduler.js';
13
13
  export { dropContextFromHistory, formatDropResult, } from './drop.js';
14
14
  export { newSessionId, saveSession, loadSession, listSessions, sessionDir, } from './persist.js';
15
+ export { appendCurrentSessionTrace } from './trace.js';
@@ -0,0 +1,26 @@
1
+ import { appendFileSync, mkdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { config } from '../config/index.js';
4
+ import { getCurrentSessionId } from './state.js';
5
+ function boundedValidation(result) {
6
+ if (!result)
7
+ return undefined;
8
+ const max = 4000;
9
+ return result.output.length <= max
10
+ ? result
11
+ : { ...result, output: `${result.output.slice(0, 2000)}\n…[trace output truncated]…\n${result.output.slice(-1900)}` };
12
+ }
13
+ /** Best-effort JSONL trace. Failure must never affect the agent turn. */
14
+ export function appendCurrentSessionTrace(trace) {
15
+ const sessionId = getCurrentSessionId();
16
+ if (!sessionId)
17
+ return;
18
+ try {
19
+ const dir = path.join(config.sessionDir, sessionId);
20
+ mkdirSync(dir, { recursive: true });
21
+ appendFileSync(path.join(dir, 'trace.jsonl'), `${JSON.stringify({ ...trace, validation: boundedValidation(trace.validation) })}\n`, 'utf8');
22
+ }
23
+ catch {
24
+ // Observability is best-effort and cannot block coding work.
25
+ }
26
+ }