codemini-cli 0.6.4 → 0.6.6

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 (42) hide show
  1. package/codemini-web/dist/assets/{AboutDialog-MRopwNIL.js → AboutDialog-dFkTshay.js} +2 -2
  2. package/codemini-web/dist/assets/CodeWikiPanel-Dqup5Sen.js +1 -0
  3. package/codemini-web/dist/assets/ConfigDialog-DTehAh2r.js +1 -0
  4. package/codemini-web/dist/assets/{GitDiffDialog-gSysUg2J.js → GitDiffDialog-D4uMixee.js} +2 -2
  5. package/codemini-web/dist/assets/{MemoryDialog-DFUmo3Kl.js → MemoryDialog-DJqtWamU.js} +3 -3
  6. package/codemini-web/dist/assets/MessageBubble-BGnFIxcq.js +12 -0
  7. package/codemini-web/dist/assets/{PatchDiff-B8rwvEg5.js → PatchDiff-CpHAbmv3.js} +1 -1
  8. package/codemini-web/dist/assets/ProjectSelector-CgJDcTNL.js +1 -0
  9. package/codemini-web/dist/assets/SkillDialog-D1J46nMC.js +8 -0
  10. package/codemini-web/dist/assets/{SoulDialog-BLjUGqqB.js → SoulDialog-DIqK4utD.js} +1 -1
  11. package/codemini-web/dist/assets/chevron-right-BBG4s6Zh.js +1 -0
  12. package/codemini-web/dist/assets/{chunk-BO2N2NFS-6uELoidu.js → chunk-BO2N2NFS-fRXUeu1b.js} +4 -4
  13. package/codemini-web/dist/assets/{highlighted-body-OFNGDK62-gb1UMBZ5.js → highlighted-body-OFNGDK62-CKKvx7S1.js} +1 -1
  14. package/codemini-web/dist/assets/index-DF9s7Tuc.css +2 -0
  15. package/codemini-web/dist/assets/{index-CDXQGwPs.js → index-DyIUhlc8.js} +6 -6
  16. package/codemini-web/dist/assets/input-BKNu4DOt.js +1 -0
  17. package/codemini-web/dist/assets/mermaid-GHXKKRXX-BM8yuNk5.js +1 -0
  18. package/codemini-web/dist/assets/{pencil-BhT11Ztp.js → pencil-C0uznNC_.js} +1 -1
  19. package/codemini-web/dist/assets/{refresh-cw-D7R5Lth6.js → refresh-cw-DUgU5bEu.js} +1 -1
  20. package/codemini-web/dist/assets/select-J6668k2a.js +1 -0
  21. package/codemini-web/dist/assets/{trash-2-BfNZcWfX.js → trash-2-H0DiWqiE.js} +1 -1
  22. package/codemini-web/dist/index.html +2 -2
  23. package/codemini-web/lib/runtime-bridge.js +7 -0
  24. package/codemini-web/server.js +100 -74
  25. package/package.json +1 -1
  26. package/src/commands/skill.js +188 -48
  27. package/src/core/agent-loop.js +73 -34
  28. package/src/core/chat-runtime.js +22 -17
  29. package/src/core/config-store.js +2 -4
  30. package/src/core/git-oplog-change-tracker.js +96 -15
  31. package/src/core/shell-profile.js +3 -2
  32. package/src/core/tools.js +144 -51
  33. package/codemini-web/dist/assets/CodeWikiPanel-UpK5xGE3.js +0 -1
  34. package/codemini-web/dist/assets/ConfigDialog-CNl28wsj.js +0 -1
  35. package/codemini-web/dist/assets/MessageBubble-CGnnViv0.js +0 -12
  36. package/codemini-web/dist/assets/ProjectSelector-BF59M1zb.js +0 -1
  37. package/codemini-web/dist/assets/SkillDialog-CQTjbSiw.js +0 -8
  38. package/codemini-web/dist/assets/chevron-right--85xg7qk.js +0 -1
  39. package/codemini-web/dist/assets/index-1xqD0R5t.css +0 -2
  40. package/codemini-web/dist/assets/input-Ca8O_061.js +0 -1
  41. package/codemini-web/dist/assets/mermaid-GHXKKRXX-ROliF8Yd.js +0 -1
  42. package/codemini-web/dist/assets/select-DBvcHBzs.js +0 -1
@@ -1,10 +1,12 @@
1
1
  import path from 'node:path';
2
2
  import { trimInline as _trimInline, normalizePath } from './string-utils.js';
3
3
  import { captureToInbox, listInbox } from './memory-store.js';
4
- import { requiresApprovalEvaluation } from './command-risk.js';
5
- import { getToolOutputSanitizeOptions, sanitizeTextForModel } from './tool-output.js';
6
- import { normalizeToolArguments } from './tool-args.js';
7
- import { storeResultIfNeeded, summarizeToolResult } from './tool-result-store.js';
4
+ import { requiresApprovalEvaluation } from './command-risk.js';
5
+ import { evaluateCommandPolicy } from './command-policy.js';
6
+ import { getToolOutputSanitizeOptions, sanitizeTextForModel } from './tool-output.js';
7
+ import { normalizeToolArguments } from './tool-args.js';
8
+ import { storeResultIfNeeded, summarizeToolResult } from './tool-result-store.js';
9
+ import { markRunCommandSafeModeApproved } from './tools.js';
8
10
 
9
11
  /**
10
12
  * 安全解析 JSON 字符串。
@@ -336,8 +338,12 @@ function extractToolResultMeta(toolName, result) {
336
338
  }
337
339
 
338
340
  export const trimInline = _trimInline;
339
-
340
- function normalizeAssistantText(value) {
341
+
342
+ export function shouldDenyHighRiskRunEvaluation(config = {}, evaluation = {}) {
343
+ return config?.policy?.allow_dangerous_commands !== true && String(evaluation?.risk || '').toLowerCase() === 'high';
344
+ }
345
+
346
+ function normalizeAssistantText(value) {
341
347
  return String(value || '').trim();
342
348
  }
343
349
 
@@ -613,10 +619,10 @@ export async function runAgentLoop({
613
619
  let lastAutoDreamCheckStep = 0;
614
620
 
615
621
  // Mutable tool list — grows as tool_search loads deferred tools
616
- const activeTools = [...toolDefinitions];
617
-
618
- async function maybeRunAutoDream(stepNumber = 0, { force = false } = {}) {
619
- if ((executionMode === 'auto' ? 'normal' : executionMode) === 'plan') return;
622
+ const activeTools = [...toolDefinitions];
623
+
624
+ async function maybeRunAutoDream(stepNumber = 0, { force = false } = {}) {
625
+ if (executionMode === 'plan') return;
620
626
  const interval = Math.max(1, Number(config?.memory?.auto_dream_check_interval_steps || 20));
621
627
  const normalizedStep = Math.max(1, Number(stepNumber || 1));
622
628
  if (!force && lastAutoDreamCheckStep > 0 && normalizedStep - lastAutoDreamCheckStep < interval) return;
@@ -724,14 +730,11 @@ export async function runAgentLoop({
724
730
 
725
731
  pendingSummaryNudges = 0;
726
732
 
727
- const workMode = executionMode === 'auto' ? 'normal' : executionMode;
728
733
  const normalizedApprovalMode = ['review', 'auto', 'full_access'].includes(String(approvalMode || '').toLowerCase())
729
734
  ? String(approvalMode || '').toLowerCase()
730
- : executionMode === 'auto'
731
- ? 'auto'
732
- : 'review';
735
+ : 'review';
733
736
 
734
- if (workMode === 'plan') {
737
+ if (executionMode === 'plan') {
735
738
  const plannedLines = callsToPlanSummary(toolCalls);
736
739
  finalText = [
737
740
  assistantText || '',
@@ -762,9 +765,16 @@ export async function runAgentLoop({
762
765
  let approved = true;
763
766
  let approvalArgs = args;
764
767
  let preflightErrorContent = '';
768
+ const runPolicyCheck = toolName === 'run'
769
+ ? evaluateCommandPolicy(args?.command || '', config, config?.workspaceRoot || process.cwd())
770
+ : { allowed: true };
771
+ const isSafeModePolicyBlocked = toolName === 'run'
772
+ && config?.policy?.safe_mode !== false
773
+ && !runPolicyCheck.allowed
774
+ && runPolicyCheck.reason !== 'blocked by dangerous command pattern';
765
775
  const isSafeModeRun = toolName === 'run'
766
776
  && config?.policy?.safe_mode !== false
767
- && requiresApprovalEvaluation(args?.command || '', config?.shell?.default);
777
+ && (isSafeModePolicyBlocked || requiresApprovalEvaluation(args?.command || '', config?.shell?.default));
768
778
  const isFileWriteTool = toolName === 'edit' || toolName === 'write' || toolName === 'delete';
769
779
  const needsApproval = normalizedApprovalMode === 'full_access'
770
780
  ? false
@@ -790,20 +800,46 @@ export async function runAgentLoop({
790
800
  if (toolName === 'run' && isSafeModeRun && !preflightErrorContent) {
791
801
  try {
792
802
  const { evaluateCommandWithLLM } = await import('./command-evaluator.js');
793
- const evaluation = await evaluateCommandWithLLM({
794
- command: args?.command || '',
795
- config,
796
- workspaceRoot: config?.workspaceRoot || process.cwd()
797
- });
798
- approvalArgs = { ...args, _risk: evaluation.risk, _evaluation: evaluation };
799
- /* LLM says low-risk + allow → auto-approve, skip confirmation panel */
800
- if (normalizedApprovalMode !== 'review' && evaluation.risk === 'low' && evaluation.recommendation === 'allow') {
801
- approvalResults.set(call.id, { approved: true, args: approvalArgs });
802
- continue;
803
- }
804
- } catch (_) {
805
- approvalArgs = { ...args, _risk: 'high', _evaluation: null };
806
- }
803
+ const evaluation = await evaluateCommandWithLLM({
804
+ command: args?.command || '',
805
+ config,
806
+ workspaceRoot: config?.workspaceRoot || process.cwd()
807
+ });
808
+ approvalArgs = {
809
+ ...args,
810
+ _risk: isSafeModePolicyBlocked && evaluation.risk === 'low' ? 'medium' : evaluation.risk,
811
+ _evaluation: evaluation,
812
+ _policyBlock: isSafeModePolicyBlocked
813
+ ? { reason: runPolicyCheck.reason, suggestion: runPolicyCheck.suggestion || '' }
814
+ : null
815
+ };
816
+ if (shouldDenyHighRiskRunEvaluation(config, evaluation)) {
817
+ preflightErrorContent = clipToolResult({
818
+ error: 'Command blocked by safe mode: high-risk command denied because dangerous commands are disabled',
819
+ evaluation
820
+ }, toolResultMaxChars);
821
+ approvalResults.set(call.id, {
822
+ approved: false,
823
+ args: approvalArgs,
824
+ errorContent: preflightErrorContent
825
+ });
826
+ continue;
827
+ }
828
+ /* LLM says low-risk + allow → auto-approve, skip confirmation panel */
829
+ if (!isSafeModePolicyBlocked && normalizedApprovalMode !== 'review' && evaluation.risk === 'low' && evaluation.recommendation === 'allow') {
830
+ approvalResults.set(call.id, { approved: true, args: approvalArgs });
831
+ continue;
832
+ }
833
+ } catch (_) {
834
+ approvalArgs = {
835
+ ...args,
836
+ _risk: isSafeModePolicyBlocked ? 'medium' : 'high',
837
+ _evaluation: null,
838
+ _policyBlock: isSafeModePolicyBlocked
839
+ ? { reason: runPolicyCheck.reason, suggestion: runPolicyCheck.suggestion || '' }
840
+ : null
841
+ };
842
+ }
807
843
  if (typeof handler?.prepareApproval === 'function') {
808
844
  try {
809
845
  const approval = await handler.prepareApproval(approvalArgs);
@@ -827,10 +863,13 @@ export async function runAgentLoop({
827
863
  arguments: approvalArgs,
828
864
  approvalDetails: toolName === 'delete' ? approvalArgs.approval
829
865
  : (toolName === 'run' ? approvalArgs.approval : undefined)
830
- });
831
- approved = Boolean(decision?.approved);
832
- }
833
- }
866
+ });
867
+ approved = Boolean(decision?.approved);
868
+ if (approved && toolName === 'run' && isSafeModePolicyBlocked) {
869
+ approvalArgs = markRunCommandSafeModeApproved(approvalArgs);
870
+ }
871
+ }
872
+ }
834
873
  approvalResults.set(call.id, { approved, args: approvalArgs });
835
874
  }
836
875
 
@@ -45,7 +45,8 @@ import {
45
45
  createGitOplogChangeTracker,
46
46
  listGitOplogChanges,
47
47
  readGitOplogPatch,
48
- undoGitOplogChange
48
+ undoGitOplogChange,
49
+ undoGitOplogChanges
49
50
  } from './git-oplog-change-tracker.js';
50
51
  import { createNonGitBackupManager } from './non-git-backup.js';
51
52
 
@@ -2606,8 +2607,8 @@ function buildRuntimeStateSnapshot({ currentSession, config, model, executionMod
2606
2607
  sessionId: currentSession?.id || '',
2607
2608
  sessionTitle: currentSession?.title || '',
2608
2609
  messageCount: Array.isArray(currentSession?.messages) ? currentSession.messages.length : 0,
2609
- mode: executionMode === 'auto' ? 'normal' : (executionMode || config.execution?.mode || 'normal'),
2610
- approvalMode: config.execution?.approval_mode || (executionMode === 'auto' ? 'auto' : 'review'),
2610
+ mode: executionMode || config.execution?.mode || 'normal',
2611
+ approvalMode: config.execution?.approval_mode || 'review',
2611
2612
  sdkProvider: config.sdk?.provider || 'openai-compatible',
2612
2613
  agentRole: 'general',
2613
2614
  model: model || config.model?.name || '',
@@ -3103,18 +3104,21 @@ async function askModel({
3103
3104
  projectContextGuidance
3104
3105
  });
3105
3106
 
3106
- const { definitions, handlers, formatters, deferredDefinitions, dispose: disposeTools } = getBuiltinTools({
3107
- workspaceRoot: process.cwd(),
3108
- config: {
3109
- ...config,
3110
- policy: {
3111
- ...(config.policy || {}),
3112
- allowed_paths: [
3113
- ...(Array.isArray(config.policy?.allowed_paths) ? config.policy.allowed_paths : []),
3114
- path.join(getSessionsDir(), String(session.id))
3115
- ]
3116
- }
3117
- },
3107
+ const toolConfig = {
3108
+ ...config,
3109
+ workspaceRoot: process.cwd(),
3110
+ policy: {
3111
+ ...(config.policy || {}),
3112
+ allowed_paths: [
3113
+ ...(Array.isArray(config.policy?.allowed_paths) ? config.policy.allowed_paths : []),
3114
+ path.join(getSessionsDir(), String(session.id))
3115
+ ]
3116
+ }
3117
+ };
3118
+
3119
+ const { definitions, handlers, formatters, deferredDefinitions, dispose: disposeTools } = getBuiltinTools({
3120
+ workspaceRoot: process.cwd(),
3121
+ config: toolConfig,
3118
3122
  sessionId: session.id,
3119
3123
  onSystemEvent: onAgentEvent,
3120
3124
  getTodos: () => normalizeTodos(session.todos),
@@ -3386,7 +3390,7 @@ async function askModel({
3386
3390
  requestToolApproval,
3387
3391
  signal,
3388
3392
  skipAnalysisNudge,
3389
- config,
3393
+ config: toolConfig,
3390
3394
  changeTracker: changeTracker?.enabled
3391
3395
  ? {
3392
3396
  begin: (meta) => beginGitOplogCapture(changeTracker, meta),
@@ -4697,7 +4701,7 @@ export async function createChatRuntime({
4697
4701
  currentSession.model = model;
4698
4702
  }
4699
4703
  const baseSystemPrompt = systemPrompt;
4700
- let executionMode = config.execution?.mode === 'auto' ? 'normal' : (config.execution?.mode || 'normal');
4704
+ let executionMode = config.execution?.mode || 'normal';
4701
4705
  if (hasPendingPlanApproval(currentSession)) {
4702
4706
  executionMode = 'plan';
4703
4707
  }
@@ -6701,6 +6705,7 @@ export async function createChatRuntime({
6701
6705
  getChangeSets: () => listGitOplogChanges(changeTracker),
6702
6706
  getChangeSetPatch: (id) => readGitOplogPatch(changeTracker, id),
6703
6707
  undoChangeSet: (id) => undoGitOplogChange(changeTracker, id),
6708
+ undoChangeSets: (ids) => undoGitOplogChanges(changeTracker, ids),
6704
6709
  reloadConfig: async (options = {}) => {
6705
6710
  config = await loadConfig();
6706
6711
  config.runtime = {
@@ -150,15 +150,13 @@ function normalizePolicyLists(config) {
150
150
  next.model.name = String(next.model.name || DEFAULT_CONFIG.model.name).trim() || DEFAULT_CONFIG.model.name;
151
151
  next.model.fast_name = String(next.model.fast_name || '').trim();
152
152
  const rawExecutionMode = String(next.execution.mode || '').toLowerCase();
153
- const rawApprovalMode = String(next.execution.approval_mode || next.execution.approvalMode || '').toLowerCase().replace(/-/g, '_');
153
+ const rawApprovalMode = String(next.execution.approval_mode || '').toLowerCase().replace(/-/g, '_');
154
154
  next.execution.mode = ['normal', 'plan'].includes(rawExecutionMode)
155
155
  ? rawExecutionMode
156
156
  : 'normal';
157
157
  next.execution.approval_mode = ['review', 'auto', 'full_access'].includes(rawApprovalMode)
158
158
  ? rawApprovalMode
159
- : rawExecutionMode === 'auto'
160
- ? 'auto'
161
- : 'review';
159
+ : 'review';
162
160
  const rawTools = Array.isArray(next.execution.always_allow_tools)
163
161
  ? next.execution.always_allow_tools
164
162
  : [];
@@ -190,13 +190,26 @@ async function buildPatchForFile(root, relativePath, before, after) {
190
190
  oldPath,
191
191
  newPath
192
192
  ], { cwd: tmp, allowFailure: true, timeoutMs: 120_000 });
193
- if (result.code !== 1) return '';
194
- const escaped = relativePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
195
- return result.stdout
196
- .replace(new RegExp(`diff --git a/old/${escaped} b/new/${escaped}`), `diff --git a/${relativePath} b/${relativePath}`)
197
- .replace(new RegExp(`--- a/old/${escaped}`), before.exists ? `--- a/${relativePath}` : '--- /dev/null')
198
- .replace(new RegExp(`\\+\\+\\+ b/new/${escaped}`), after.exists ? `+++ b/${relativePath}` : '+++ /dev/null');
199
- } finally {
193
+ if (result.code !== 1) return '';
194
+ const escaped = relativePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
195
+ let patch = result.stdout
196
+ .replace(new RegExp(`diff --git a/old/${escaped} b/new/${escaped}`), `diff --git a/${relativePath} b/${relativePath}`)
197
+ .replace(new RegExp(`--- a/old/${escaped}`), before.exists ? `--- a/${relativePath}` : '--- /dev/null')
198
+ .replace(new RegExp(`\\+\\+\\+ b/new/${escaped}`), after.exists ? `+++ b/${relativePath}` : '+++ /dev/null');
199
+ if (!before.exists && after.exists && !/^new file mode /m.test(patch)) {
200
+ patch = patch.replace(
201
+ new RegExp(`^(diff --git a/${escaped} b/${escaped})$`, 'm'),
202
+ `$1\nnew file mode 100644`
203
+ );
204
+ }
205
+ if (before.exists && !after.exists && !/^deleted file mode /m.test(patch)) {
206
+ patch = patch.replace(
207
+ new RegExp(`^(diff --git a/${escaped} b/${escaped})$`, 'm'),
208
+ `$1\ndeleted file mode 100644`
209
+ );
210
+ }
211
+ return patch;
212
+ } finally {
200
213
  await fs.rm(tmp, { recursive: true, force: true }).catch(() => {});
201
214
  }
202
215
  }
@@ -360,11 +373,11 @@ export async function readGitOplogPatch(tracker, opId) {
360
373
  return fs.readFile(op.patchPath, 'utf8');
361
374
  }
362
375
 
363
- export async function undoGitOplogChange(tracker, opId) {
364
- if (!isGitOplogChangeTrackerAvailable(tracker)) throw new Error('Git change oplog is not available for this session');
365
- const op = await readGitOplogChange(tracker, opId);
366
- if (op.revertedAt) {
367
- return { ok: false, alreadyReverted: true, changeSetId: op.id, message: 'Change already reverted' };
376
+ export async function undoGitOplogChange(tracker, opId) {
377
+ if (!isGitOplogChangeTrackerAvailable(tracker)) throw new Error('Git change oplog is not available for this session');
378
+ const op = await readGitOplogChange(tracker, opId);
379
+ if (op.revertedAt) {
380
+ return { ok: false, alreadyReverted: true, changeSetId: op.id, message: 'Change already reverted' };
368
381
  }
369
382
  const patch = await fs.readFile(op.patchPath, 'utf8');
370
383
  try {
@@ -382,6 +395,74 @@ export async function undoGitOplogChange(tracker, opId) {
382
395
  timeoutMs: 120_000
383
396
  });
384
397
  op.revertedAt = new Date().toISOString();
385
- await writeJson(path.join(tracker.opsDir, `${op.id}.json`), op);
386
- return { ok: true, changeSetId: op.id };
387
- }
398
+ await writeJson(path.join(tracker.opsDir, `${op.id}.json`), op);
399
+ return { ok: true, changeSetId: op.id };
400
+ }
401
+
402
+ export async function undoGitOplogChanges(tracker, opIds = []) {
403
+ if (!isGitOplogChangeTrackerAvailable(tracker)) throw new Error('Git change oplog is not available for this session');
404
+ const ids = [];
405
+ const seen = new Set();
406
+ for (const rawId of Array.isArray(opIds) ? opIds : [opIds]) {
407
+ const id = String(rawId || '').trim();
408
+ if (!id || seen.has(id)) continue;
409
+ seen.add(id);
410
+ ids.push(id);
411
+ }
412
+ if (!ids.length) throw new Error('Missing change ids');
413
+ if (ids.length === 1) return undoGitOplogChange(tracker, ids[0]);
414
+
415
+ const ops = await Promise.all(ids.map((id) => readGitOplogChange(tracker, id)));
416
+ const reverted = ops.find((op) => op.revertedAt);
417
+ if (reverted) {
418
+ return { ok: false, alreadyReverted: true, changeSetId: reverted.id, message: 'Change already reverted' };
419
+ }
420
+
421
+ const order = new Map(ids.map((id, index) => [id, index]));
422
+ ops.sort((a, b) => {
423
+ const byTime = String(b.createdAt || '').localeCompare(String(a.createdAt || ''));
424
+ if (byTime) return byTime;
425
+ return (order.get(b.id) ?? 0) - (order.get(a.id) ?? 0);
426
+ });
427
+
428
+ const patches = await Promise.all(ops.map(async (op) => ({
429
+ op,
430
+ patch: await fs.readFile(op.patchPath, 'utf8')
431
+ })));
432
+ if (!patches.some((item) => String(item.patch || '').trim())) throw new Error('Missing change patch');
433
+
434
+ const applied = [];
435
+ try {
436
+ for (const item of patches) {
437
+ if (!String(item.patch || '').trim()) continue;
438
+ await runGit(['apply', '-R', '--check', '--whitespace=nowarn'], {
439
+ cwd: tracker.workspaceRoot,
440
+ input: item.patch,
441
+ timeoutMs: 120_000
442
+ });
443
+ await runGit(['apply', '-R', '--whitespace=nowarn'], {
444
+ cwd: tracker.workspaceRoot,
445
+ input: item.patch,
446
+ timeoutMs: 120_000
447
+ });
448
+ applied.push(item);
449
+ }
450
+ } catch (error) {
451
+ for (const item of applied.reverse()) {
452
+ await runGit(['apply', '--whitespace=nowarn'], {
453
+ cwd: tracker.workspaceRoot,
454
+ input: item.patch,
455
+ allowFailure: true,
456
+ timeoutMs: 120_000
457
+ });
458
+ }
459
+ throw new Error(`Cannot undo this change cleanly because newer edits conflict with it. Undo newer changes first, or revert it manually. ${error?.message || ''}`.trim());
460
+ }
461
+
462
+ const revertedAt = new Date().toISOString();
463
+ for (const op of ops) {
464
+ op.revertedAt = revertedAt;
465
+ await writeJson(path.join(tracker.opsDir, `${op.id}.json`), op);
466
+ }
467
+ return { ok: true, changeSetIds: ops.map((op) => op.id) };
468
+ }
@@ -17,8 +17,9 @@ const SHELL_PROFILES = {
17
17
  shell: 'powershell',
18
18
  label: 'PowerShell',
19
19
  command_allowlist: [
20
- 'rg',
21
- 'git',
20
+ 'rg',
21
+ 'cd',
22
+ 'git',
22
23
  'node',
23
24
  'npm',
24
25
  'npx',
package/src/core/tools.js CHANGED
@@ -30,8 +30,8 @@ import {
30
30
  sanitizeTextForModel,
31
31
  summarizeRunOutput
32
32
  } from './tool-output.js';
33
- import {
34
- normalizeFilePathValue,
33
+ import {
34
+ normalizeFilePathValue,
35
35
  normalizePathArgs,
36
36
  parseInlineRangePath,
37
37
  normalizePatternArgs,
@@ -39,14 +39,28 @@ import {
39
39
  normalizeWebFetchArgs,
40
40
  normalizeWebSearchArgs,
41
41
  normalizeWriteArgs
42
- } from './tool-args.js';
43
- const BACKGROUND_TASK_RECENT_OUTPUT_LIMIT = 80;
44
- const BACKGROUND_TASK_POLL_MS = 150;
45
- const MAX_AST_ENCLOSING_BYTES = 300_000;
46
- const MAX_AST_ENCLOSING_LINES = 5_000;
47
- const backgroundTaskRegistry = new Map();
48
- let backgroundTaskCounter = 0;
49
- let backgroundTaskLogCursorCounter = 0;
42
+ } from './tool-args.js';
43
+ const BACKGROUND_TASK_RECENT_OUTPUT_LIMIT = 80;
44
+ const BACKGROUND_TASK_POLL_MS = 150;
45
+ const MAX_AST_ENCLOSING_BYTES = 300_000;
46
+ const MAX_AST_ENCLOSING_LINES = 5_000;
47
+ const RUN_COMMAND_SAFE_MODE_APPROVED = Symbol('runCommandSafeModeApproved');
48
+ const backgroundTaskRegistry = new Map();
49
+ let backgroundTaskCounter = 0;
50
+ let backgroundTaskLogCursorCounter = 0;
51
+
52
+ export function markRunCommandSafeModeApproved(args = {}) {
53
+ const next = { ...(args && typeof args === 'object' ? args : {}) };
54
+ Object.defineProperty(next, RUN_COMMAND_SAFE_MODE_APPROVED, {
55
+ value: true,
56
+ enumerable: false
57
+ });
58
+ return next;
59
+ }
60
+
61
+ export function hasRunCommandSafeModeApproval(args = {}) {
62
+ return Boolean(args?.[RUN_COMMAND_SAFE_MODE_APPROVED]);
63
+ }
50
64
 
51
65
  async function realpathIfExists(targetPath) {
52
66
  try {
@@ -1125,11 +1139,11 @@ async function runCommand(root, config, args) {
1125
1139
  throw new Error('Command blocked by policy');
1126
1140
  }
1127
1141
 
1128
- const check = evaluateCommandPolicy(command, config, root);
1129
- if (!check.allowed) {
1130
- throw new Error(
1131
- `Command blocked by safe mode: ${check.reason}${check.suggestion ? ` | ${check.suggestion}` : ''}`
1132
- );
1142
+ const check = evaluateCommandPolicy(command, config, root);
1143
+ if (!check.allowed && !hasRunCommandSafeModeApproval(args)) {
1144
+ throw new Error(
1145
+ `Command blocked by safe mode: ${check.reason}${check.suggestion ? ` | ${check.suggestion}` : ''}`
1146
+ );
1133
1147
  }
1134
1148
 
1135
1149
  const shouldBackground =
@@ -1296,11 +1310,11 @@ async function startBackgroundTask(root, config, args) {
1296
1310
  ) {
1297
1311
  throw new Error('Command blocked by policy');
1298
1312
  }
1299
- const check = evaluateCommandPolicy(command, config, root);
1300
- if (!check.allowed) {
1301
- throw new Error(
1302
- `Command blocked by safe mode: ${check.reason}${check.suggestion ? ` | ${check.suggestion}` : ''}`
1303
- );
1313
+ const check = evaluateCommandPolicy(command, config, root);
1314
+ if (!check.allowed && !hasRunCommandSafeModeApproval(args)) {
1315
+ throw new Error(
1316
+ `Command blocked by safe mode: ${check.reason}${check.suggestion ? ` | ${check.suggestion}` : ''}`
1317
+ );
1304
1318
  }
1305
1319
 
1306
1320
  const shellSpec = resolveShell(config.shell.default);
@@ -1664,9 +1678,9 @@ function editResult(pathText, action, beforeContent, afterContent, changedLine =
1664
1678
  };
1665
1679
  }
1666
1680
 
1667
- function lineRangeToOffsets(content, startLineRaw, endLineRaw) {
1668
- const lines = splitLines(content);
1669
- const totalLines = lines.length;
1681
+ function lineRangeToOffsets(content, startLineRaw, endLineRaw) {
1682
+ const lines = splitLines(content);
1683
+ const totalLines = lines.length;
1670
1684
  const startLine = Math.max(1, Math.min(totalLines, Number(startLineRaw) || 1));
1671
1685
  const endLine = Math.max(startLine, Math.min(totalLines, Number(endLineRaw) || startLine));
1672
1686
  let startOffset = 0;
@@ -1678,12 +1692,64 @@ function lineRangeToOffsets(content, startLineRaw, endLineRaw) {
1678
1692
  endOffset += lines[i - 1].length;
1679
1693
  if (i < endLine) endOffset += 1;
1680
1694
  }
1681
- return { startLine, endLine, startOffset, endOffset };
1682
- }
1683
-
1684
- async function replaceBlock(root, args, config = {}) {
1685
- const relativePath = String(args?.path || '').trim();
1686
- const newContent = String(args?.new_content || args?.content || '');
1695
+ return { startLine, endLine, startOffset, endOffset };
1696
+ }
1697
+
1698
+ function normalizeNewlinesWithMap(text) {
1699
+ const source = String(text || '');
1700
+ const chars = [];
1701
+ const indexMap = [];
1702
+ for (let i = 0; i < source.length; i += 1) {
1703
+ const ch = source[i];
1704
+ if (ch === '\r') {
1705
+ chars.push('\n');
1706
+ indexMap.push(i);
1707
+ if (source[i + 1] === '\n') i += 1;
1708
+ continue;
1709
+ }
1710
+ chars.push(ch);
1711
+ indexMap.push(i);
1712
+ }
1713
+ return { text: chars.join(''), indexMap };
1714
+ }
1715
+
1716
+ function detectEol(text) {
1717
+ const sample = String(text || '');
1718
+ const crlf = (sample.match(/\r\n/g) || []).length;
1719
+ const loneLf = (sample.match(/(?<!\r)\n/g) || []).length;
1720
+ const loneCr = (sample.match(/\r(?!\n)/g) || []).length;
1721
+ if (crlf >= loneLf && crlf >= loneCr && crlf > 0) return '\r\n';
1722
+ if (loneCr > loneLf && loneCr > 0) return '\r';
1723
+ return '\n';
1724
+ }
1725
+
1726
+ function applyEol(text, eol) {
1727
+ return String(text || '').replace(/\r\n|\r|\n/g, eol || '\n');
1728
+ }
1729
+
1730
+ function findLineEndingEquivalentMatches(content, oldText) {
1731
+ const normalizedOld = normalizeNewlinesWithMap(oldText).text;
1732
+ if (!normalizedOld) return [];
1733
+ const normalizedContent = normalizeNewlinesWithMap(content);
1734
+ const matches = [];
1735
+ let pos = 0;
1736
+ while (true) {
1737
+ const found = normalizedContent.text.indexOf(normalizedOld, pos);
1738
+ if (found === -1) break;
1739
+ const start = normalizedContent.indexMap[found] ?? 0;
1740
+ const endNorm = found + normalizedOld.length;
1741
+ const end = endNorm >= normalizedContent.text.length
1742
+ ? String(content || '').length
1743
+ : normalizedContent.indexMap[endNorm];
1744
+ matches.push({ start, end });
1745
+ pos = found + Math.max(1, normalizedOld.length);
1746
+ }
1747
+ return matches;
1748
+ }
1749
+
1750
+ async function replaceBlock(root, args, config = {}) {
1751
+ const relativePath = String(args?.path || '').trim();
1752
+ const newContent = String(args?.new_content || args?.content || '');
1687
1753
  const target = args?.target || {};
1688
1754
  const state = await getFileState(root, relativePath, config);
1689
1755
  const resolved = resolveReplaceBlockTarget(state, target);
@@ -1712,14 +1778,39 @@ async function replaceText(root, args, config = {}) {
1712
1778
  const rangeStart = Number(args?.start_line || args?.line);
1713
1779
  const rangeEnd = Number(args?.end_line || args?.line);
1714
1780
  const hasRange = Number.isFinite(rangeStart) && rangeStart > 0;
1715
- const range = hasRange
1716
- ? lineRangeToOffsets(state.content, rangeStart, Number.isFinite(rangeEnd) && rangeEnd >= rangeStart ? rangeEnd : rangeStart)
1717
- : null;
1718
- const searchContent = range ? state.content.slice(range.startOffset, range.endOffset) : state.content;
1719
- const occurrences = searchContent.split(oldText).length - 1;
1720
- if (occurrences !== 1) {
1721
- if (replaceAll && occurrences > 0) {
1722
- const replaced = searchContent.replaceAll(oldText, newText);
1781
+ const range = hasRange
1782
+ ? lineRangeToOffsets(state.content, rangeStart, Number.isFinite(rangeEnd) && rangeEnd >= rangeStart ? rangeEnd : rangeStart)
1783
+ : null;
1784
+ const searchContent = range ? state.content.slice(range.startOffset, range.endOffset) : state.content;
1785
+ const occurrences = searchContent.split(oldText).length - 1;
1786
+ let newlineMatches = null;
1787
+ if (occurrences === 0 && /[\r\n]/.test(oldText)) {
1788
+ newlineMatches = findLineEndingEquivalentMatches(searchContent, oldText);
1789
+ if ((replaceAll && newlineMatches.length > 0) || newlineMatches.length === 1) {
1790
+ let cursor = 0;
1791
+ let replaced = '';
1792
+ for (const match of newlineMatches) {
1793
+ const originalMatch = searchContent.slice(match.start, match.end);
1794
+ replaced += searchContent.slice(cursor, match.start);
1795
+ replaced += applyEol(newText, detectEol(originalMatch));
1796
+ cursor = match.end;
1797
+ if (!replaceAll) break;
1798
+ }
1799
+ replaced += searchContent.slice(cursor);
1800
+ const afterContent = range
1801
+ ? `${state.content.slice(0, range.startOffset)}${replaced}${state.content.slice(range.endOffset)}`
1802
+ : replaced;
1803
+ await fs.writeFile(state.target, afterContent, 'utf8');
1804
+ const first = newlineMatches[0];
1805
+ const changedLine = range
1806
+ ? range.startLine + splitLines(searchContent.slice(0, first.start)).length - 1
1807
+ : splitLines(state.content.slice(0, first.start)).length;
1808
+ return editResult(relativePath, 'replace_text', state.content, afterContent, changedLine);
1809
+ }
1810
+ }
1811
+ if (occurrences !== 1) {
1812
+ if (replaceAll && occurrences > 0) {
1813
+ const replaced = searchContent.replaceAll(oldText, newText);
1723
1814
  const afterContent = range
1724
1815
  ? `${state.content.slice(0, range.startOffset)}${replaced}${state.content.slice(range.endOffset)}`
1725
1816
  : state.content.replaceAll(oldText, newText);
@@ -1744,13 +1835,14 @@ async function replaceText(root, args, config = {}) {
1744
1835
  lineDetails.push(` Line ${lineNum}: ${lineText}`);
1745
1836
  searchPos = pos + oldText.length;
1746
1837
  }
1747
- const lineHint = lineDetails.length > 0 ? `\n${lineDetails.join('\n')}\n` : ' ';
1748
- throw new Error(
1749
- occurrences === 0
1750
- ? 'replace_text old_text not found'
1751
- : `replace_text old_text not unique; found ${occurrences} occurrences:${lineHint}Use path:"${relativePath}:N-M" to narrow the range, set replace_all=true, or provide more unique old_text`
1752
- );
1753
- }
1838
+ const lineHint = lineDetails.length > 0 ? `\n${lineDetails.join('\n')}\n` : ' ';
1839
+ const effectiveOccurrences = newlineMatches?.length || occurrences;
1840
+ throw new Error(
1841
+ effectiveOccurrences === 0
1842
+ ? 'replace_text old_text not found'
1843
+ : `replace_text old_text not unique; found ${effectiveOccurrences} occurrences:${lineHint}Use path:"${relativePath}:N-M" to narrow the range, set replace_all=true, or provide more unique old_text`
1844
+ );
1845
+ }
1754
1846
  const replaced = searchContent.replace(oldText, newText);
1755
1847
  const afterContent = range
1756
1848
  ? `${state.content.slice(0, range.startOffset)}${replaced}${state.content.slice(range.endOffset)}`
@@ -2842,13 +2934,14 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2842
2934
  run: Object.assign(
2843
2935
  (args) => runCommand(workspaceRoot, config, args),
2844
2936
  {
2845
- prepareApproval: async (args) => ({
2846
- command: args?.command || '',
2847
- risk: args?._risk || 'high',
2848
- evaluation: args?._evaluation || null
2849
- })
2850
- }
2851
- ),
2937
+ prepareApproval: async (args) => ({
2938
+ command: args?.command || '',
2939
+ risk: args?._risk || 'high',
2940
+ evaluation: args?._evaluation || null,
2941
+ policyBlock: args?._policyBlock || null
2942
+ })
2943
+ }
2944
+ ),
2852
2945
  save_memory: async (args = {}) => {
2853
2946
  const rawScope = String(args.scope || 'global').toLowerCase();
2854
2947
  const memoryScope = rawScope === 'repo' || rawScope === 'project' ? 'project'