codeep 3.3.3 → 3.4.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.
Files changed (79) hide show
  1. package/dist/acp/commands.d.ts +50 -1
  2. package/dist/acp/commands.js +545 -109
  3. package/dist/acp/protocol.d.ts +14 -5
  4. package/dist/acp/server.d.ts +36 -1
  5. package/dist/acp/server.js +581 -155
  6. package/dist/acp/serverHandlers.d.ts +2 -1
  7. package/dist/acp/serverHandlers.js +3 -0
  8. package/dist/acp/session.d.ts +28 -2
  9. package/dist/acp/session.js +25 -6
  10. package/dist/acp/transport.d.ts +40 -4
  11. package/dist/acp/transport.js +218 -25
  12. package/dist/acp/turns.d.ts +20 -0
  13. package/dist/acp/turns.js +30 -0
  14. package/dist/api/index.js +2 -0
  15. package/dist/api/ollamaNative.d.ts +3 -0
  16. package/dist/api/ollamaNative.js +35 -3
  17. package/dist/config/index.d.ts +21 -4
  18. package/dist/config/index.js +178 -123
  19. package/dist/renderer/agentExecution.d.ts +30 -2
  20. package/dist/renderer/agentExecution.js +248 -92
  21. package/dist/renderer/commands/helpers.d.ts +18 -2
  22. package/dist/renderer/commands/helpers.js +28 -5
  23. package/dist/renderer/commands.d.ts +2 -0
  24. package/dist/renderer/commands.js +180 -64
  25. package/dist/renderer/main.d.ts +41 -0
  26. package/dist/renderer/main.js +181 -80
  27. package/dist/utils/agent.d.ts +69 -4
  28. package/dist/utils/agent.js +416 -248
  29. package/dist/utils/agentChat.js +82 -10
  30. package/dist/utils/agents.d.ts +2 -1
  31. package/dist/utils/agents.js +100 -29
  32. package/dist/utils/auditLog.d.ts +4 -3
  33. package/dist/utils/auditLog.js +92 -9
  34. package/dist/utils/checkpoints.js +11 -6
  35. package/dist/utils/codeReview.js +28 -23
  36. package/dist/utils/codeepCloud.d.ts +14 -2
  37. package/dist/utils/codeepCloud.js +56 -20
  38. package/dist/utils/customCommands.js +7 -2
  39. package/dist/utils/git.d.ts +262 -4
  40. package/dist/utils/git.js +1928 -61
  41. package/dist/utils/gitHookInstaller.d.ts +32 -1
  42. package/dist/utils/gitHookInstaller.js +76 -8
  43. package/dist/utils/gitignore.d.ts +8 -0
  44. package/dist/utils/gitignore.js +41 -10
  45. package/dist/utils/headlessReview.d.ts +11 -0
  46. package/dist/utils/headlessReview.js +33 -5
  47. package/dist/utils/history.d.ts +22 -6
  48. package/dist/utils/history.js +140 -26
  49. package/dist/utils/logger.js +6 -7
  50. package/dist/utils/mcpConfig.d.ts +24 -0
  51. package/dist/utils/mcpConfig.js +36 -5
  52. package/dist/utils/mentions.d.ts +28 -5
  53. package/dist/utils/mentions.js +253 -45
  54. package/dist/utils/personalities.js +16 -6
  55. package/dist/utils/planMode.d.ts +13 -7
  56. package/dist/utils/planMode.js +32 -12
  57. package/dist/utils/projectIntelligence.d.ts +2 -0
  58. package/dist/utils/projectIntelligence.js +27 -8
  59. package/dist/utils/projectPaths.d.ts +53 -0
  60. package/dist/utils/projectPaths.js +146 -0
  61. package/dist/utils/shell.d.ts +119 -0
  62. package/dist/utils/shell.js +417 -45
  63. package/dist/utils/skillBundles.js +17 -7
  64. package/dist/utils/skillBundlesCloud.js +20 -3
  65. package/dist/utils/skills.d.ts +24 -2
  66. package/dist/utils/skills.js +235 -43
  67. package/dist/utils/smartContext.js +97 -23
  68. package/dist/utils/telegramApproval.d.ts +10 -2
  69. package/dist/utils/telegramApproval.js +22 -4
  70. package/dist/utils/toolExecution.d.ts +50 -2
  71. package/dist/utils/toolExecution.js +418 -16
  72. package/dist/utils/toolParsing.d.ts +7 -1
  73. package/dist/utils/toolParsing.js +12 -3
  74. package/dist/utils/userProfile.js +58 -16
  75. package/dist/utils/verify.d.ts +25 -4
  76. package/dist/utils/verify.js +259 -74
  77. package/dist/version.d.ts +1 -1
  78. package/dist/version.js +1 -1
  79. package/package.json +1 -1
@@ -2,7 +2,8 @@
2
2
  // Slash command handler for ACP sessions.
3
3
  // Mirrors CLI commands from renderer/commands.ts but returns plain text
4
4
  // responses (no TUI) suitable for streaming back via session/update.
5
- import { config, getCurrentProvider, getModelsForCurrentProvider, setProvider, setApiKey, isConfigured, listSessionsWithInfo, startNewSession, loadSession, saveSession, initializeAsProject, isManuallyInitializedProject, setProjectPermission, hasWritePermission, hasReadPermission, } from '../config/index.js';
5
+ import { config, getCurrentProvider, getModelsForCurrentProvider, setProvider, setApiKey, isConfigured, listSessionsWithInfo, startNewSession, loadSession, saveSession, initializeAsProject, isManuallyInitializedProject, setProjectPermission, hasWritePermission, hasReadPermission, sessionNameProblem, sessionNameTaken, } from '../config/index.js';
6
+ import { symlinkedCodeepNotice } from '../utils/projectPaths.js';
6
7
  import { getProviderList, getProvider } from '../config/providers.js';
7
8
  import { telemetryCommand } from '../commands/core/telemetry.js';
8
9
  import { keysyncCommand } from '../commands/core/keysync.js';
@@ -13,7 +14,19 @@ import { summarizeBundles } from '../utils/skillBundles.js';
13
14
  import { existsSync, mkdirSync } from 'fs';
14
15
  import { join } from 'path';
15
16
  import { chat } from '../api/index.js';
16
- import { runAgent } from '../utils/agent.js';
17
+ import { runAgent, classifyPermissionOutcome, buildDangerousTools } from '../utils/agent.js';
18
+ import { forgetHooksDirectory } from '../utils/toolExecution.js';
19
+ import { shellCommandEnv } from '../utils/shell.js';
20
+ import { beginTurn } from './turns.js';
21
+ /** Pending plans a /go is executing right now. */
22
+ const plansRunning = new Set();
23
+ /**
24
+ * Save the session after a turn a command added, as a plain prompt's turn
25
+ * is saved: only while autosave is on.
26
+ */
27
+ function turnMessages(prompt, response) {
28
+ return response ? [{ role: 'user', content: prompt }, { role: 'assistant', content: response }] : [{ role: 'user', content: prompt }];
29
+ }
17
30
  // ─── Workspace / session init (called on session/new) ─────────────────────────
18
31
  /**
19
32
  * Ensure workspace has a .codeep folder, initialise it as a project if needed,
@@ -66,6 +79,10 @@ export function initWorkspace(workspaceRoot, fresh = false) {
66
79
  ? `**Project:** ${projectCtx.name} (${projectCtx.type})`
67
80
  : '**Project:** detected',
68
81
  hasWrite ? '**Access:** Read & Write' : '**Access:** Read only',
82
+ ...(() => {
83
+ const notice = symlinkedCodeepNotice(workspaceRoot);
84
+ return notice ? ['', `**⚠ ${notice}**`] : [];
85
+ })(),
69
86
  '',
70
87
  sessions.length > 0
71
88
  ? `**Session:** ${codeepSessionId} (${history.length} messages restored)`
@@ -179,8 +196,11 @@ export function loadWorkspace(workspaceRoot, acpSessionId) {
179
196
  *
180
197
  * onChunk is called for streaming output (skills). For simple commands
181
198
  * the full response is returned in CommandResult.response.
199
+ *
200
+ * agentRun carries the options commands that run the agent (/go, custom
201
+ * commands, skill agent steps) must run it with.
182
202
  */
183
- export async function handleCommand(input, session, onChunk, abortSignal) {
203
+ export async function handleCommand(input, session, onChunk, abortSignal, agentRun) {
184
204
  const trimmed = input.trim();
185
205
  if (!trimmed.startsWith('/'))
186
206
  return { handled: false, response: '' };
@@ -237,6 +257,7 @@ export async function handleCommand(input, session, onChunk, abortSignal) {
237
257
  const id = startNewSession();
238
258
  session.codeepSessionId = id;
239
259
  session.history = [];
260
+ session.conversation = (session.conversation ?? 0) + 1;
240
261
  return { handled: true, response: `New session started: \`${id}\`` };
241
262
  }
242
263
  if (sub === 'load' && args[1]) {
@@ -244,14 +265,23 @@ export async function handleCommand(input, session, onChunk, abortSignal) {
244
265
  if (loaded) {
245
266
  session.codeepSessionId = args[1];
246
267
  session.history = loaded;
268
+ session.conversation = (session.conversation ?? 0) + 1;
247
269
  return { handled: true, response: formatSessionPreview(args[1], session.history) };
248
270
  }
249
- return { handled: true, response: `Session not found: \`${args[1]}\`` };
271
+ return { handled: true, response: sessionNameProblem(args[1]) ?? `Session not found: \`${args[1]}\`` };
250
272
  }
251
273
  return { handled: true, response: 'Usage: `/session` · `/session new` · `/session load <name>`' };
252
274
  }
253
275
  case 'save': {
254
276
  const name = args.length ? args.join('-') : session.codeepSessionId;
277
+ const nameProblem = sessionNameProblem(name);
278
+ if (nameProblem)
279
+ return { handled: true, response: nameProblem };
280
+ // /save <name> is how a conversation gets a new name here; it must not
281
+ // replace a different saved conversation that already has that name.
282
+ if (sessionNameTaken(name, session.codeepSessionId, session.workspaceRoot)) {
283
+ return { handled: true, response: `A session named \`${name}\` already exists. Choose another name, or load it with \`/session load ${name}\`.` };
284
+ }
255
285
  if (saveSession(name, session.history, session.workspaceRoot)) {
256
286
  session.codeepSessionId = name;
257
287
  return { handled: true, response: `Session saved as: \`${name}\`` };
@@ -340,15 +370,24 @@ export async function handleCommand(input, session, onChunk, abortSignal) {
340
370
  return { handled: true, response: dropped ? `Dropped ${dropped} file(s). ${session.addedFiles.size} remaining.` : 'File not found in context.' };
341
371
  }
342
372
  // ─── Undo ──────────────────────────────────────────────────────────────────
373
+ // One server can hold sessions in several workspaces; undo only what
374
+ // ran in this one.
343
375
  case 'undo': {
344
376
  const { undoLastAction } = await import('../utils/agent.js');
345
- const result = undoLastAction();
377
+ const result = undoLastAction(session.workspaceRoot);
346
378
  return { handled: true, response: result.success ? `Undo: ${result.message}` : `Cannot undo: ${result.message}` };
347
379
  }
348
380
  case 'undo-all': {
349
381
  const { undoAllActions } = await import('../utils/agent.js');
350
- const result = undoAllActions();
351
- return { handled: true, response: result.success ? `Undone ${result.results.length} action(s).` : 'Nothing to undo.' };
382
+ const result = undoAllActions(session.workspaceRoot);
383
+ // Each action is listed with its outcome: a run can mix restored
384
+ // files with commands that cannot be undone, and a count of both
385
+ // would overstate what was put back.
386
+ if (result.results.length === 0 || (!result.success && result.results.length === 1)) {
387
+ return { handled: true, response: result.results[0] ?? 'Nothing to undo.' };
388
+ }
389
+ const heading = result.success ? '## Undo all' : '## Nothing was undone';
390
+ return { handled: true, response: `${heading}\n\n${result.results.map(r => `- ${r}`).join('\n')}` };
352
391
  }
353
392
  case 'skills': {
354
393
  const sub = args[0]?.toLowerCase();
@@ -502,10 +541,12 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
502
541
  }
503
542
  case 'scan': {
504
543
  onChunk('_Scanning project…_\n\n');
505
- const { scanProject, saveProjectIntelligence, generateContextFromIntelligence } = await import('../utils/projectIntelligence.js');
544
+ const { scanProject, saveProjectIntelligence, generateContextFromIntelligence, INTELLIGENCE_NOT_SAVED } = await import('../utils/projectIntelligence.js');
506
545
  try {
507
546
  const intelligence = await scanProject(session.workspaceRoot);
508
- saveProjectIntelligence(session.workspaceRoot, intelligence);
547
+ if (!saveProjectIntelligence(session.workspaceRoot, intelligence)) {
548
+ return { handled: true, response: INTELLIGENCE_NOT_SAVED };
549
+ }
509
550
  const context = generateContextFromIntelligence(intelligence);
510
551
  onChunk(`## Project Scan\n\n${context}`);
511
552
  return { handled: true, response: '', streaming: true };
@@ -515,14 +556,49 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
515
556
  }
516
557
  }
517
558
  case 'review': {
518
- onChunk('_Running code review…_\n\n');
519
- const { performCodeReview, formatReviewResult } = await import('../utils/codeReview.js');
559
+ // `--staged` / `-s` and `--static` pick the kind of review; any other
560
+ // argument names a file for static analysis.
561
+ const staged = args.includes('--staged') || args.includes('-s');
562
+ const staticOnly = args.includes('--static');
563
+ const files = args.filter(a => !a.startsWith('-'));
520
564
  const projectCtx = getProjectContext(session.workspaceRoot);
521
565
  if (!projectCtx)
522
566
  return { handled: true, response: 'No project context available.' };
523
- const reviewFiles = args.length ? args : undefined;
524
- const result = performCodeReview(projectCtx, reviewFiles);
525
- return { handled: true, response: formatReviewResult(result) };
567
+ const staticReview = async (reviewFiles) => {
568
+ const { performCodeReview, formatReviewResult } = await import('../utils/codeReview.js');
569
+ const result = performCodeReview(projectCtx, reviewFiles);
570
+ // Names that match no file leave nothing to review, which would
571
+ // otherwise read as a clean result.
572
+ if (reviewFiles && result.files.length === 0) {
573
+ return { handled: true, response: `Nothing reviewed: ${reviewFiles.map(f => `\`${f}\``).join(', ')} matched no reviewable file in the workspace.` };
574
+ }
575
+ return { handled: true, response: formatReviewResult(result) };
576
+ };
577
+ if (staticOnly || files.length > 0) {
578
+ onChunk('_Running static analysis…_\n\n');
579
+ return staticReview(files.length ? files : undefined);
580
+ }
581
+ // AI review of the git diff, as in the TUI.
582
+ const { getGitDiff } = await import('../utils/git.js');
583
+ const diffResult = getGitDiff(staged, session.workspaceRoot);
584
+ if (!diffResult.success || !diffResult.diff) {
585
+ onChunk(`_No ${staged ? 'staged' : 'unstaged'} changes found — running static analysis instead…_\n\n`);
586
+ return staticReview();
587
+ }
588
+ onChunk(`_Reviewing ${staged ? 'staged' : 'unstaged'} changes…_\n\n`);
589
+ const diffText = diffResult.diff.length > 12000
590
+ ? diffResult.diff.slice(0, 12000) + '\n\n[diff truncated]'
591
+ : diffResult.diff;
592
+ try {
593
+ await chat(buildDiffReviewPrompt(diffText), [], onChunk, undefined, projectCtx, abortSignal);
594
+ }
595
+ catch (err) {
596
+ if (err.name === 'AbortError') {
597
+ return { handled: true, response: '\n\n_Review cancelled._', streaming: true };
598
+ }
599
+ throw err;
600
+ }
601
+ return { handled: true, response: '', streaming: true };
526
602
  }
527
603
  case 'learn': {
528
604
  onChunk('_Learning from project…_\n\n');
@@ -561,7 +637,8 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
561
637
  }
562
638
  case 'changes': {
563
639
  const { getCurrentSessionActions } = await import('../utils/agent.js');
564
- const actions = getCurrentSessionActions();
640
+ // Changes of the run /undo acts on, in this workspace.
641
+ const actions = getCurrentSessionActions(session.workspaceRoot);
565
642
  if (!actions.length)
566
643
  return { handled: true, response: 'No changes in current session.' };
567
644
  const lines = ['## Session Changes', '', ...actions.map(a => `- **${a.type}**: \`${a.target}\` — ${a.result}`)];
@@ -713,17 +790,14 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
713
790
  const { getSyncToken } = await import('../config/index.js');
714
791
  if (!getSyncToken())
715
792
  return { handled: true, response: 'Not linked to codeep.dev. Run `codeep account` in a terminal first.' };
716
- const { pushUserProfile, pullUserProfile } = await import('../utils/codeepCloud.js');
717
- const pushed = await pushUserProfile();
718
- const pulled = await pullUserProfile();
719
- const lines = [];
720
- if (pushed)
721
- lines.push('✓ Profile pushed to the dashboard');
722
- if (pulled === 1)
723
- lines.push('✓ Profile pulled to this machine');
724
- if (lines.length === 0)
725
- lines.push('Nothing to sync yet — run `/me init` and fill in your profile first.');
726
- return { handled: true, response: lines.join('\n') };
793
+ const { pushUserProfileResult, pullUserProfileResult, describeSyncFailure } = await import('../utils/codeepCloud.js');
794
+ const { formatMeSyncReport } = await import('../renderer/commands/helpers.js');
795
+ // Push first, as the TUI does: the pull may create the local file.
796
+ // Each result says why it failed, so a failed push of an existing
797
+ // profile is not reported as "nothing to sync".
798
+ const pushed = await pushUserProfileResult();
799
+ const pulled = await pullUserProfileResult();
800
+ return { handled: true, response: formatMeSyncReport(pushed, pulled, describeSyncFailure) };
727
801
  }
728
802
  return { handled: true, response: formatProfileView(session.workspaceRoot) };
729
803
  }
@@ -749,9 +823,11 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
749
823
  case 'plan': {
750
824
  // Identical contract to TUI /plan: generate a pre-execution plan,
751
825
  // surface it, hold as pending so /go can execute it without re-planning.
826
+ // Each ACP session has its own pending plan: /go in one thread must
827
+ // not run a plan made in another.
752
828
  if (!args.length) {
753
829
  const { getPendingPlan } = await import('../utils/planMode.js');
754
- const cur = getPendingPlan();
830
+ const cur = getPendingPlan(session.sessionId);
755
831
  return {
756
832
  handled: true,
757
833
  response: cur
@@ -763,7 +839,7 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
763
839
  onChunk(`_Generating plan for: ${task.slice(0, 80)}${task.length > 80 ? '…' : ''}_\n\n`);
764
840
  try {
765
841
  const { generatePlan } = await import('../utils/planMode.js');
766
- const plan = await generatePlan(task);
842
+ const plan = await generatePlan(task, undefined, session.sessionId, abortSignal);
767
843
  return {
768
844
  handled: true,
769
845
  response: `${plan}\n\n---\nRun \`/go\` to execute this plan, or \`/plan <revised task>\` to refine it.`,
@@ -771,34 +847,51 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
771
847
  };
772
848
  }
773
849
  catch (err) {
850
+ if (err.name === 'AbortError') {
851
+ return { handled: true, response: '_Plan generation cancelled._', streaming: true };
852
+ }
774
853
  return { handled: true, response: `Plan generation failed: ${err.message}`, streaming: true };
775
854
  }
776
855
  }
777
856
  case 'go': {
778
857
  const { getPendingPlan, composeExecutionPrompt, clearPendingPlan } = await import('../utils/planMode.js');
779
- const cur = getPendingPlan();
858
+ const cur = getPendingPlan(session.sessionId);
780
859
  if (!cur) {
781
860
  return { handled: true, response: 'No pending plan. Run `/plan <task>` first.' };
782
861
  }
862
+ // The plan stays pending until it has run, so a failed or cancelled
863
+ // run can be started again. Meanwhile a second /go must not start it
864
+ // twice.
865
+ if (plansRunning.has(cur)) {
866
+ return { handled: true, response: 'This plan is already running.' };
867
+ }
783
868
  const prompt = composeExecutionPrompt(cur);
784
- clearPendingPlan();
785
869
  onChunk(`_Executing approved plan…_\n\n`);
870
+ plansRunning.add(cur);
871
+ const recordTurn = beginTurn(session);
786
872
  try {
787
- const { buildProjectContext } = await import('./session.js');
788
- const ctx = buildProjectContext(session.workspaceRoot);
789
- const agentResult = await runAgent(prompt, ctx, {
790
- abortSignal,
791
- onIteration: (_i, msg) => { onChunk(msg + '\n'); },
792
- onThinking: (text) => { onChunk(text); },
793
- });
873
+ const { response } = await runCommandAgent(prompt, session, onChunk, abortSignal, agentRun);
874
+ // A /plan issued meanwhile replaced it; that one has not run.
875
+ if (getPendingPlan(session.sessionId) === cur)
876
+ clearPendingPlan(session.sessionId);
877
+ // The run is part of the conversation: a paused run's "say
878
+ // continue" needs the plan and what was done in the next turn.
879
+ recordTurn(turnMessages(prompt, response));
794
880
  return {
795
881
  handled: true,
796
- response: agentResult.finalResponse || '_(plan executed; no final summary)_',
882
+ response: response || '_(plan executed; no final summary)_',
797
883
  streaming: true,
798
884
  };
799
885
  }
800
886
  catch (err) {
801
- return { handled: true, response: `Plan execution failed: ${err.message}`, streaming: true };
887
+ const retry = 'The plan is still pending — run `/go` to start it again.';
888
+ if (err.name === 'AbortError') {
889
+ return { handled: true, response: `_Plan execution cancelled._ ${retry}`, streaming: true };
890
+ }
891
+ return { handled: true, response: `Plan execution failed: ${err.message}\n\n${retry}`, streaming: true };
892
+ }
893
+ finally {
894
+ plansRunning.delete(cur);
802
895
  }
803
896
  }
804
897
  case 'export': {
@@ -829,7 +922,9 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
829
922
  onChunk('_Reviewing diff…_\n\n');
830
923
  const projectCtx = getProjectContext(session.workspaceRoot);
831
924
  let reviewText = '';
832
- await chat(`Review this git diff and provide concise feedback:\n\n\`\`\`diff\n${preview}\n\`\`\``, session.history, (chunk) => { reviewText += chunk; onChunk(chunk); }, undefined, projectCtx, undefined);
925
+ // A cancel stops the request; the server answers the prompt as
926
+ // cancelled.
927
+ await chat(`Review this git diff and provide concise feedback:\n\n\`\`\`diff\n${preview}\n\`\`\``, session.history, (chunk) => { reviewText += chunk; onChunk(chunk); }, undefined, projectCtx, abortSignal);
833
928
  return { handled: true, response: '', streaming: true };
834
929
  }
835
930
  case 'commands': {
@@ -842,7 +937,7 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
842
937
  // /memory list show all notes
843
938
  // /memory remove <n> remove note by 1-based index
844
939
  // /memory clear wipe all notes
845
- const { loadProjectIntelligence, saveProjectIntelligence } = await import('../utils/projectIntelligence.js');
940
+ const { loadProjectIntelligence, saveProjectIntelligence, INTELLIGENCE_NOT_SAVED } = await import('../utils/projectIntelligence.js');
846
941
  const intelligence = loadProjectIntelligence(session.workspaceRoot);
847
942
  if (!intelligence) {
848
943
  return { handled: true, response: 'No project intelligence found. Run `/scan` first.' };
@@ -861,13 +956,15 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
861
956
  return { handled: true, response: 'Usage: `/memory remove <n>` — run `/memory list` first to see indices.' };
862
957
  }
863
958
  const removed = intelligence.notes.splice(idx - 1, 1)[0];
864
- saveProjectIntelligence(session.workspaceRoot, intelligence);
959
+ if (!saveProjectIntelligence(session.workspaceRoot, intelligence))
960
+ return { handled: true, response: INTELLIGENCE_NOT_SAVED };
865
961
  return { handled: true, response: `Removed note ${idx}: _"${removed}"_` };
866
962
  }
867
963
  if (sub === 'clear') {
868
964
  const count = intelligence.notes.length;
869
965
  intelligence.notes = [];
870
- saveProjectIntelligence(session.workspaceRoot, intelligence);
966
+ if (!saveProjectIntelligence(session.workspaceRoot, intelligence))
967
+ return { handled: true, response: INTELLIGENCE_NOT_SAVED };
871
968
  return { handled: true, response: count ? `Cleared ${count} note${count === 1 ? '' : 's'}.` : '_No notes to clear._' };
872
969
  }
873
970
  // Default: treat all args as the note body.
@@ -876,7 +973,8 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
876
973
  return { handled: true, response: 'Usage: `/memory <note>` · `/memory list` · `/memory remove <n>` · `/memory clear`' };
877
974
  }
878
975
  intelligence.notes.push(note);
879
- saveProjectIntelligence(session.workspaceRoot, intelligence);
976
+ if (!saveProjectIntelligence(session.workspaceRoot, intelligence))
977
+ return { handled: true, response: INTELLIGENCE_NOT_SAVED };
880
978
  return { handled: true, response: `Memory saved (${intelligence.notes.length} total): _"${note}"_` };
881
979
  }
882
980
  case 'checkpoint': {
@@ -898,18 +996,24 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
898
996
  const provider = getCurrentProvider();
899
997
  // Pull file paths the agent has touched in this session from the action
900
998
  // log. Used at /rewind time to scope the git restore suggestion.
901
- const filesTouched = Array.from(new Set(getCurrentSessionActions()
902
- .filter(a => a.target && (a.type === 'write' || a.type === 'edit' || a.type === 'delete' || a.type === 'mkdir'))
999
+ const filesTouched = Array.from(new Set(getCurrentSessionActions(session.workspaceRoot)
1000
+ .filter(a => a.target && a.result !== 'undone' && (a.type === 'write' || a.type === 'edit' || a.type === 'delete' || a.type === 'mkdir'))
903
1001
  .map(a => a.target)));
904
- const cp = createCheckpoint({
905
- workspaceRoot: session.workspaceRoot,
906
- sessionId: session.codeepSessionId,
907
- provider: provider.id,
908
- model: config.get('model'),
909
- messages: session.history,
910
- filesTouched,
911
- name,
912
- });
1002
+ let cp;
1003
+ try {
1004
+ cp = createCheckpoint({
1005
+ workspaceRoot: session.workspaceRoot,
1006
+ sessionId: session.codeepSessionId,
1007
+ provider: provider.id,
1008
+ model: config.get('model'),
1009
+ messages: session.history,
1010
+ filesTouched,
1011
+ name,
1012
+ });
1013
+ }
1014
+ catch (err) {
1015
+ return { handled: true, response: `Could not save the checkpoint: ${err.message}` };
1016
+ }
913
1017
  const lines = [
914
1018
  `Created checkpoint \`${cp.id}\`${cp.name ? ` — **${cp.name}**` : ''}`,
915
1019
  `Captured ${cp.messages.length} message${cp.messages.length === 1 ? '' : 's'}, ${cp.filesTouched.length} file${cp.filesTouched.length === 1 ? '' : 's'} touched${cp.gitHead ? `, git \`${cp.gitHead}\`` : ''}.`,
@@ -979,8 +1083,26 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
979
1083
  }
980
1084
  case 'mcp': {
981
1085
  const sub = args[0]?.toLowerCase();
982
- const { addProjectMcpServer, removeProjectMcpServer, loadMcpServerConfig, loadMcpServerConfigSplit, isWorkspaceMcpTrusted, trustWorkspaceMcp, untrustWorkspaceMcp } = await import('../utils/mcpConfig.js');
1086
+ const { addProjectMcpServer, removeProjectMcpServer, loadMcpServerConfigSplit, selectSessionMcpServers, isWorkspaceMcpTrusted, trustWorkspaceMcp, untrustWorkspaceMcp } = await import('../utils/mcpConfig.js');
983
1087
  const { registerSessionServers } = await import('../utils/mcpRegistry.js');
1088
+ const { handleMcpSamplingRequest } = await import('../utils/mcpSamplingBridge.js');
1089
+ // Restart the session's servers from the current config. Registering
1090
+ // replaces the whole set, so the selection keeps the servers the
1091
+ // client passed, and the sampling bridge is wired as at session start.
1092
+ // `userAdded` is a server the user just added by hand.
1093
+ const restartServers = async (userAdded) => {
1094
+ const { servers, skipped } = selectSessionMcpServers(session.workspaceRoot, {
1095
+ fromClient: session.clientMcpServers,
1096
+ userAdded: userAdded ? [userAdded] : undefined,
1097
+ });
1098
+ const { registered, errors } = await registerSessionServers(session.sessionId, servers, {
1099
+ workspaceRoot: session.workspaceRoot,
1100
+ onSamplingRequest: handleMcpSamplingRequest,
1101
+ });
1102
+ const untrustedNote = skipped.length === 0 ? '' :
1103
+ `\n\n${skipped.length} workspace MCP server${skipped.length === 1 ? '' : 's'} not started — this workspace isn't trusted. Run \`/mcp trust\` to start ${skipped.length === 1 ? 'it' : 'them'}.`;
1104
+ return { servers, registered, errors, untrustedNote };
1105
+ };
984
1106
  if (sub === 'trust') {
985
1107
  if (isWorkspaceMcpTrusted(session.workspaceRoot)) {
986
1108
  return { handled: true, response: '_Workspace MCP servers are already trusted here._' };
@@ -991,8 +1113,7 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
991
1113
  return { handled: true, response: '_Workspace trusted — no workspace MCP servers defined yet._' };
992
1114
  }
993
1115
  onChunk(`_Workspace trusted. Spawning ${workspace.length} MCP server(s)…_\n\n`);
994
- const merged = loadMcpServerConfig(session.workspaceRoot);
995
- const { registered, errors } = await registerSessionServers(session.sessionId, merged, { workspaceRoot: session.workspaceRoot });
1116
+ const { registered, errors } = await restartServers();
996
1117
  const lines = [`Trusted workspace MCP servers (${registered.length} tool(s) available).`];
997
1118
  for (const e of errors)
998
1119
  lines.push(`- \`${e.server}\` failed: ${e.error}`);
@@ -1000,7 +1121,7 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
1000
1121
  }
1001
1122
  if (sub === 'untrust') {
1002
1123
  untrustWorkspaceMcp(session.workspaceRoot);
1003
- return { handled: true, response: '_Workspace MCP trust revoked — workspace servers won\'t spawn for new sessions._' };
1124
+ return { handled: true, response: '_Workspace MCP trust revoked — workspace servers won\'t spawn again. Running ones stop at `/mcp reload` or when the session ends._' };
1004
1125
  }
1005
1126
  if (sub === 'add') {
1006
1127
  // /mcp add <name> <command> [args...]
@@ -1014,14 +1135,13 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
1014
1135
  onChunk(`_Saved MCP server **${name}** to \`.codeep/mcp_servers.json\`. Spawning…_\n\n`);
1015
1136
  // Live re-register so the new server is usable immediately, no
1016
1137
  // session restart needed. registerSessionServers is idempotent —
1017
- // it disposes the old set and brings up the merged one.
1018
- const merged = loadMcpServerConfig(session.workspaceRoot);
1019
- const { registered, errors } = await registerSessionServers(session.sessionId, merged, { workspaceRoot: session.workspaceRoot });
1138
+ // it disposes the old set and brings up the selected one.
1139
+ const { registered, errors, untrustedNote } = await restartServers(name);
1020
1140
  const ok = registered.filter(t => t.serverName === name);
1021
1141
  const failed = errors.find(e => e.server === name);
1022
1142
  if (failed)
1023
- return { handled: true, response: `Saved \`${name}\` but spawn failed: \`${failed.error}\``, streaming: true };
1024
- return { handled: true, response: `Added \`${name}\` (${ok.length} tool${ok.length === 1 ? '' : 's'} available).`, streaming: true };
1143
+ return { handled: true, response: `Saved \`${name}\` but spawn failed: \`${failed.error}\`${untrustedNote}`, streaming: true };
1144
+ return { handled: true, response: `Added \`${name}\` (${ok.length} tool${ok.length === 1 ? '' : 's'} available).${untrustedNote}`, streaming: true };
1025
1145
  }
1026
1146
  if (sub === 'remove') {
1027
1147
  const name = args[1];
@@ -1030,11 +1150,10 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
1030
1150
  const removed = removeProjectMcpServer(session.workspaceRoot, name);
1031
1151
  if (!removed)
1032
1152
  return { handled: true, response: `No project-scoped MCP server named \`${name}\`.` };
1033
- // Re-register with the new (smaller) merged set so the dropped
1034
- // server is actually killed.
1035
- const merged = loadMcpServerConfig(session.workspaceRoot);
1036
- await registerSessionServers(session.sessionId, merged, { workspaceRoot: session.workspaceRoot });
1037
- return { handled: true, response: `Removed \`${name}\` from project config and stopped its process.` };
1153
+ // Re-register with the new (smaller) set so the dropped server is
1154
+ // actually killed.
1155
+ const { untrustedNote } = await restartServers();
1156
+ return { handled: true, response: `Removed \`${name}\` from project config and stopped its process.${untrustedNote}` };
1038
1157
  }
1039
1158
  if (sub === 'resources') {
1040
1159
  const { getSessionResources, awaitSessionReady } = await import('../utils/mcpRegistry.js');
@@ -1168,8 +1287,7 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
1168
1287
  // Reuse the same add helper as `/mcp add`.
1169
1288
  addProjectMcpServer(session.workspaceRoot, server);
1170
1289
  onChunk(`_Saved \`${entry.id}\` to project config. Spawning…_\n\n`);
1171
- const merged = loadMcpServerConfig(session.workspaceRoot);
1172
- const { registered, errors } = await registerSessionServers(session.sessionId, merged, { workspaceRoot: session.workspaceRoot });
1290
+ const { registered, errors, untrustedNote } = await restartServers(entry.id);
1173
1291
  const failed = errors.find(e => e.server === entry.id);
1174
1292
  const lines = [];
1175
1293
  if (failed) {
@@ -1186,26 +1304,25 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
1186
1304
  lines.push(`- \`${e.name}\`${req} — ${e.description}`);
1187
1305
  }
1188
1306
  }
1189
- return { handled: true, response: lines.join('\n'), streaming: true };
1307
+ return { handled: true, response: lines.join('\n') + untrustedNote, streaming: true };
1190
1308
  }
1191
1309
  if (sub === 'reload') {
1192
1310
  // Re-read both config files and re-register. Used after a manual
1193
1311
  // edit of `.codeep/mcp_servers.json` outside the CLI — `/mcp add`
1194
1312
  // and `/mcp remove` already re-register automatically.
1195
1313
  onChunk(`_Reloading MCP server config…_\n\n`);
1196
- const merged = loadMcpServerConfig(session.workspaceRoot);
1197
- const { registered, errors } = await registerSessionServers(session.sessionId, merged, { workspaceRoot: session.workspaceRoot });
1314
+ const { servers, registered, errors, untrustedNote } = await restartServers();
1198
1315
  const lines = [
1199
1316
  `## MCP reloaded`,
1200
1317
  '',
1201
- `**${registered.length}** tool${registered.length === 1 ? '' : 's'} from **${merged.length}** server${merged.length === 1 ? '' : 's'}.`,
1318
+ `**${registered.length}** tool${registered.length === 1 ? '' : 's'} from **${servers.length}** server${servers.length === 1 ? '' : 's'}.`,
1202
1319
  ];
1203
1320
  if (errors.length > 0) {
1204
1321
  lines.push('', '### Failed servers');
1205
1322
  for (const e of errors)
1206
1323
  lines.push(`- **${e.server}** — \`${e.error}\``);
1207
1324
  }
1208
- return { handled: true, response: lines.join('\n'), streaming: true };
1325
+ return { handled: true, response: lines.join('\n') + untrustedNote, streaming: true };
1209
1326
  }
1210
1327
  // Default: list (and 'list' / no-arg behave the same)
1211
1328
  // Read-only inspector for the MCP servers wired into this session.
@@ -1266,6 +1383,7 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
1266
1383
  // below tells the user how to restore them via git if they want.
1267
1384
  const replacedCount = session.history.length;
1268
1385
  session.history = cp.messages;
1386
+ session.conversation = (session.conversation ?? 0) + 1;
1269
1387
  saveSession(session.codeepSessionId, session.history, session.workspaceRoot);
1270
1388
  // If the checkpoint captured a different provider/model, switch back.
1271
1389
  // configOptionsChanged signals the client to refresh its dropdowns.
@@ -1336,44 +1454,43 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
1336
1454
  if (custom) {
1337
1455
  const expandedPrompt = expandCommand(custom, args);
1338
1456
  // Treat the expanded body as if the user had typed it manually:
1339
- // push it as a user message and run the agent (or chat if agent
1340
- // mode is off), then persist the assistant reply.
1341
- session.history.push({ role: 'user', content: expandedPrompt });
1457
+ // run the agent (or chat if agent mode is off) on it, then record
1458
+ // it as a user message with the assistant reply.
1342
1459
  onChunk(`_Running custom command **/${custom.name}** (${custom.scope})…_\n\n`);
1343
1460
  const projectCtx = getProjectContext(session.workspaceRoot);
1344
1461
  const agentMode = config.get('agentMode');
1345
1462
  let response = '';
1463
+ const recordTurn = beginTurn(session);
1346
1464
  try {
1347
1465
  if (agentMode === 'on') {
1348
- const { buildProjectContext } = await import('./session.js');
1349
- const ctx = buildProjectContext(session.workspaceRoot);
1350
- const agentResult = await runAgent(expandedPrompt, ctx, {
1351
- abortSignal,
1352
- onIteration: (_i, msg) => { onChunk(msg + '\n'); },
1353
- onThinking: (text) => { onChunk(text); },
1354
- });
1355
- response = agentResult.finalResponse ?? '';
1466
+ ({ response } = await runCommandAgent(expandedPrompt, session, onChunk, abortSignal, agentRun));
1356
1467
  if (response)
1357
1468
  onChunk(response);
1358
1469
  }
1359
1470
  else {
1360
- await chat(expandedPrompt, session.history.slice(0, -1), // exclude the just-pushed user message
1361
- (chunk) => { response += chunk; onChunk(chunk); }, undefined, projectCtx, undefined);
1471
+ await chat(expandedPrompt, session.history, (chunk) => { response += chunk; onChunk(chunk); }, undefined, projectCtx, abortSignal);
1362
1472
  }
1363
- if (response)
1364
- session.history.push({ role: 'assistant', content: response });
1365
- saveSession(session.codeepSessionId, session.history, session.workspaceRoot);
1473
+ // Recorded only once it ran: a failed run must not leave an
1474
+ // unanswered message in the saved conversation.
1475
+ recordTurn(turnMessages(expandedPrompt, response));
1366
1476
  }
1367
1477
  catch (err) {
1368
- onChunk(`\n\n_Custom command failed: ${err.message}_`);
1478
+ onChunk(err.name === 'AbortError'
1479
+ ? '\n\n_Custom command cancelled._'
1480
+ : `\n\n_Custom command failed: ${err.message}_`);
1369
1481
  }
1370
1482
  return { handled: true, response: '', streaming: true };
1371
1483
  }
1372
1484
  // 2. Built-in skill.
1373
- const { findSkill, parseSkillArgs, executeSkill, trackSkillUsage } = await import('../utils/skills.js');
1485
+ const { findSkill, parseSkillArgs, executeSkill, trackSkillUsage, getSkippedCustomSkills, formatSkippedCustomSkills, } = await import('../utils/skills.js');
1374
1486
  const skill = findSkill(cmd);
1375
1487
  if (!skill) {
1376
- return { handled: true, response: `Unknown command: \`/${cmd}\`\n\nType \`/help\` for available commands, \`/skills\` to list all skills, or \`/commands\` for custom commands.` };
1488
+ // A custom skill file of that name that did not load is why the
1489
+ // command is unknown; say which file and what is wrong with it.
1490
+ // findSkill has just read the skills directory.
1491
+ const skipped = getSkippedCustomSkills().filter(s => s.file.toLowerCase() === `${cmd}.json`);
1492
+ const why = skipped.length ? `\n\n${formatSkippedCustomSkills(skipped)}` : '';
1493
+ return { handled: true, response: `Unknown command: \`/${cmd}\`${why}\n\nType \`/help\` for available commands, \`/skills\` to list all skills, or \`/commands\` for custom commands.` };
1377
1494
  }
1378
1495
  if (skill.requiresWriteAccess && !hasWritePermission(session.workspaceRoot)) {
1379
1496
  return { handled: true, response: 'This skill requires write access. Use `/grant` first.' };
@@ -1383,14 +1500,72 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
1383
1500
  onChunk(`_Running skill **${skill.name}**…_\n\n`);
1384
1501
  const { spawnSync } = await import('child_process');
1385
1502
  const projectCtx = getProjectContext(session.workspaceRoot);
1503
+ // A cancelled prompt runs no further step: a /commit cancelled while
1504
+ // its message was written must not go on to commit.
1505
+ const stopIfCancelled = () => {
1506
+ if (!abortSignal?.aborted)
1507
+ return;
1508
+ const abortError = new Error('Skill cancelled');
1509
+ abortError.name = 'AbortError';
1510
+ throw abortError;
1511
+ };
1512
+ // "Allow always" holds for the rest of the skill, as it holds for the
1513
+ // rest of an agent run.
1514
+ let commandsAllowed = false;
1386
1515
  const skillResult = await executeSkill(skill, params, {
1387
1516
  onCommand: async (shellCmd) => {
1517
+ stopIfCancelled();
1518
+ // Manual mode treats a skill's shell line like the agent's
1519
+ // execute_command: the command policy first, then the user's
1520
+ // say-so unless they turned that question off. Auto mode runs it
1521
+ // without asking, as that mode promises.
1522
+ if (agentRun?.onRequestPermission) {
1523
+ const refused = await checkSkillCommand(shellCmd, session.workspaceRoot);
1524
+ if (refused)
1525
+ throw new Error(`\`${shellCmd}\` was refused: ${refused}`);
1526
+ if (!commandsAllowed && buildDangerousTools(agentRun.extraDangerousTools).has('execute_command')) {
1527
+ const outcome = await agentRun.onRequestPermission({
1528
+ tool: 'execute_command',
1529
+ parameters: { command: shellCmd, args: [] },
1530
+ });
1531
+ const decision = classifyPermissionOutcome(outcome);
1532
+ if (decision === 'allow-always')
1533
+ commandsAllowed = true;
1534
+ else if (decision !== 'allow-once') {
1535
+ throw new Error(`User rejected permission for \`${shellCmd}\``);
1536
+ }
1537
+ stopIfCancelled();
1538
+ }
1539
+ }
1540
+ // A raw process.env here handed the repository's own `.git/config`
1541
+ // back to git: `/commit` runs `git commit`, and a repo-scope
1542
+ // `gpg.program` that Codeep's own commit path neutralises executed
1543
+ // through this spawn instead.
1544
+ //
1545
+ // Built before the spawn, and caught: shellCommandEnv() refuses a
1546
+ // git line in a repository whose config names a program no override
1547
+ // switches off, and a refusal that escaped here would abort the
1548
+ // whole skill from inside executeSkill's callback. Failing the step
1549
+ // with the refusal's own wording is what a step that cannot run
1550
+ // looks like everywhere else in this handler.
1551
+ let env;
1552
+ try {
1553
+ env = shellCommandEnv(shellCmd, session.workspaceRoot);
1554
+ }
1555
+ catch (error) {
1556
+ throw new Error(`\`${shellCmd}\` was not run: ${error instanceof Error ? error.message : String(error)}`);
1557
+ }
1558
+ // A command line is the one thing in a skill that can move this
1559
+ // repository's hooks (`git config core.hooksPath .evil`), and the
1560
+ // write gate caches where they are for the run.
1561
+ forgetHooksDirectory();
1388
1562
  const proc = spawnSync(shellCmd, {
1389
1563
  cwd: session.workspaceRoot,
1390
1564
  encoding: 'utf-8',
1391
1565
  timeout: 60_000,
1392
1566
  shell: true,
1393
1567
  stdio: ['pipe', 'pipe', 'pipe'],
1568
+ env,
1394
1569
  });
1395
1570
  const out = ((proc.stdout || '') + (proc.stderr || '')).trim();
1396
1571
  const block = `\`${shellCmd}\`\n\`\`\`\n${out || '(no output)'}\n\`\`\`\n`;
@@ -1400,37 +1575,298 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
1400
1575
  return out;
1401
1576
  },
1402
1577
  onPrompt: async (prompt) => {
1578
+ stopIfCancelled();
1403
1579
  let response = '';
1404
- await chat(prompt, session.history, (chunk) => { response += chunk; onChunk(chunk); }, undefined, projectCtx, undefined);
1580
+ await chat(prompt, session.history, (chunk) => { response += chunk; onChunk(chunk); }, undefined, projectCtx, abortSignal);
1405
1581
  return response;
1406
1582
  },
1583
+ // A failed run throws, which fails the step. So do checks that
1584
+ // still fail once the work is done: the summary and the errors are
1585
+ // shown, but the steps after it (a commit, a deploy) must not run
1586
+ // on a broken build.
1407
1587
  onAgent: async (task) => {
1408
- const { buildProjectContext } = await import('./session.js');
1409
- const ctx = buildProjectContext(session.workspaceRoot);
1410
- let output = '';
1411
- const agentResult = await runAgent(task, ctx, {
1412
- abortSignal,
1413
- onIteration: (_i, msg) => { onChunk(msg + '\n'); },
1414
- onThinking: (text) => { onChunk(text); },
1415
- });
1416
- if (agentResult.finalResponse) {
1417
- output = agentResult.finalResponse;
1588
+ stopIfCancelled();
1589
+ const { response: output, failedChecks } = await runCommandAgent(task, session, onChunk, abortSignal, agentRun);
1590
+ if (output)
1418
1591
  onChunk(output);
1419
- }
1592
+ if (failedChecks)
1593
+ throw new Error(`Verification failed: ${failedChecks.join(', ')}`);
1420
1594
  return output;
1421
1595
  },
1422
- // Skills in ACP auto-confirm (no TUI)
1423
- onConfirm: async (_message) => true,
1596
+ // Manual mode asks the user through the client. Auto mode runs
1597
+ // without confirmation, as that mode promises.
1598
+ onConfirm: agentRun?.confirm ?? (async (_message) => true),
1424
1599
  onNotify: (message) => { onChunk(`> ${message}\n`); },
1425
1600
  });
1426
1601
  if (!skillResult.success) {
1602
+ if (abortSignal?.aborted) {
1603
+ return { handled: true, response: `_Skill **${skill.name}** cancelled._`, streaming: true };
1604
+ }
1427
1605
  return { handled: true, response: `Skill **${skill.name}** failed: ${skillResult.output}`, streaming: true };
1428
1606
  }
1429
1607
  return { handled: true, response: '', streaming: true };
1430
1608
  }
1431
1609
  }
1432
1610
  }
1611
+ /**
1612
+ * Run the agent for a slash command the way a plain prompt runs: with the
1613
+ * session's permission mode, the client's fs and terminal, the session's
1614
+ * MCP tools and its earlier turns.
1615
+ *
1616
+ * Resolves with the final response. runAgent reports failures in its
1617
+ * result instead of throwing, so this throws for them — an AbortError when
1618
+ * the run was cancelled — the way runAgentSession does. Two outcomes are
1619
+ * not failures of the run itself, and resolve: a run paused at a safety
1620
+ * limit (its notice is the response), and a run whose work is done but
1621
+ * whose checks still fail. That response carries the ✗ block with the
1622
+ * errors, and `failedChecks` names the checks. Throwing there would hide
1623
+ * both and invite running the same work again on a tree that has it.
1624
+ */
1625
+ async function runCommandAgent(task, session, onChunk, abortSignal, agentRun) {
1626
+ const { buildProjectContext, toAgentChatHistory } = await import('./session.js');
1627
+ const result = await runAgent(task, buildProjectContext(session.workspaceRoot), {
1628
+ abortSignal,
1629
+ onIteration: (_i, msg) => { onChunk(msg + '\n'); },
1630
+ onThinking: (text) => { onChunk(text); },
1631
+ // Manual mode's dialog, or — in auto mode, where there is none — the
1632
+ // answer that mode gives. Passing nothing would leave the run with no way
1633
+ // to confirm a write to a file that decides what runs later, and the agent
1634
+ // refuses those rather than doing them unasked.
1635
+ onRequestPermission: agentRun?.onRequestPermission ?? agentRun?.onAutoModePermission,
1636
+ extraDangerousTools: agentRun?.extraDangerousTools,
1637
+ onExecuteCommand: agentRun?.onExecuteCommand,
1638
+ fs: agentRun?.fs,
1639
+ // Servers are registered under the ACP session id.
1640
+ mcpSessionId: session.sessionId,
1641
+ chatHistory: toAgentChatHistory(session.history),
1642
+ });
1643
+ if (result.aborted) {
1644
+ const abortError = new Error('Agent session was cancelled');
1645
+ abortError.name = 'AbortError';
1646
+ throw abortError;
1647
+ }
1648
+ if (result.failedChecks?.length) {
1649
+ return { response: result.finalResponse ?? '', failedChecks: result.failedChecks };
1650
+ }
1651
+ if (!result.success && !result.interrupted) {
1652
+ throw new Error(result.error || result.finalResponse || 'Agent run failed without a specific error message');
1653
+ }
1654
+ return { response: result.finalResponse ?? '' };
1655
+ }
1656
+ /**
1657
+ * Split a skill's shell line into the simple commands it runs, each as
1658
+ * argv, so every one of them can go through the execute_command policy.
1659
+ * `&&`, `||`, `;`, `|` and newlines separate commands. Redirections stay
1660
+ * with their command as arguments (`2>&1`, `>`, `out.txt`), so the policy
1661
+ * sees where output goes. Returns null for a line it cannot read with
1662
+ * certainty — unbalanced quotes, subshells, background jobs, heredocs, and
1663
+ * anything the shell would expand first (`$`, backticks, globs, braces, a
1664
+ * leading `~`) — which the caller must refuse. So a `$` or backtick in the
1665
+ * result is always plain text.
1666
+ *
1667
+ * Exported for unit testing (see commands.slash.test.ts).
1668
+ */
1669
+ export function splitShellCommands(line, windows = process.platform === 'win32') {
1670
+ const commands = [];
1671
+ let words = [];
1672
+ let word = '';
1673
+ let inWord = false;
1674
+ // After `&&`, `||` or `|` another command must follow.
1675
+ let needCommand = false;
1676
+ const endWord = () => {
1677
+ if (inWord)
1678
+ words.push(word);
1679
+ word = '';
1680
+ inWord = false;
1681
+ };
1682
+ const endCommand = () => {
1683
+ endWord();
1684
+ if (words.length === 0)
1685
+ return false;
1686
+ commands.push(words);
1687
+ words = [];
1688
+ needCommand = false;
1689
+ return true;
1690
+ };
1691
+ for (let i = 0; i < line.length; i++) {
1692
+ const c = line[i];
1693
+ const next = line[i + 1];
1694
+ if (c === "'") {
1695
+ const close = line.indexOf("'", i + 1);
1696
+ if (close < 0)
1697
+ return null;
1698
+ word += line.slice(i + 1, close);
1699
+ inWord = true;
1700
+ i = close;
1701
+ }
1702
+ else if (c === '"') {
1703
+ let j = i + 1;
1704
+ for (; j < line.length && line[j] !== '"'; j++) {
1705
+ if (line[j] === '\\' && j + 1 < line.length && '$`"\\'.includes(line[j + 1])) {
1706
+ word += line[++j];
1707
+ }
1708
+ else if (line[j] === '$' || line[j] === '`') {
1709
+ // Expanded inside double quotes too.
1710
+ return null;
1711
+ }
1712
+ else {
1713
+ word += line[j];
1714
+ }
1715
+ }
1716
+ if (j >= line.length)
1717
+ return null;
1718
+ inWord = true;
1719
+ i = j;
1720
+ }
1721
+ else if (c === '\\' && windows) {
1722
+ // cmd.exe has no escape character here: `..\\x` is a path, and the
1723
+ // checks below must see it as one.
1724
+ word += c;
1725
+ inWord = true;
1726
+ }
1727
+ else if (c === '\\') {
1728
+ if (next === undefined || next === '\n')
1729
+ return null;
1730
+ word += next;
1731
+ inWord = true;
1732
+ i++;
1733
+ }
1734
+ else if (c === ' ' || c === '\t') {
1735
+ endWord();
1736
+ }
1737
+ else if (c === '\n') {
1738
+ if (!endCommand() && needCommand)
1739
+ return null;
1740
+ }
1741
+ else if (c === ';') {
1742
+ if (!endCommand())
1743
+ return null;
1744
+ }
1745
+ else if (c === '&' && next === '&') {
1746
+ if (!endCommand())
1747
+ return null;
1748
+ needCommand = true;
1749
+ i++;
1750
+ }
1751
+ else if (c === '|') {
1752
+ if (!endCommand())
1753
+ return null;
1754
+ needCommand = true;
1755
+ if (next === '|' || next === '&')
1756
+ i++;
1757
+ }
1758
+ else if (c === '>' || c === '<' || (c === '&' && next === '>')) {
1759
+ // A file descriptor number written right before it is part of it.
1760
+ let op = '';
1761
+ if (inWord && /^\d+$/.test(word) && /\d/.test(line[i - 1])) {
1762
+ op = word;
1763
+ word = '';
1764
+ inWord = false;
1765
+ }
1766
+ else {
1767
+ endWord();
1768
+ }
1769
+ if (c === '<' && next === '<')
1770
+ return null;
1771
+ // One whole operator: `>` `>>` `>|` `<` `<>` `&>` `&>>`, or a
1772
+ // duplication `>&N` `<&N` (N digits or `-`). It ends there, so a
1773
+ // `&&`, `||` or `|` right after `2>&1` still starts the next command.
1774
+ op += c;
1775
+ if (c === '&') {
1776
+ op += line[++i];
1777
+ if (line[i + 1] === '>')
1778
+ op += line[++i];
1779
+ }
1780
+ else if (c === '>' && (next === '>' || next === '|')) {
1781
+ op += line[++i];
1782
+ }
1783
+ else if (c === '<' && next === '>') {
1784
+ op += line[++i];
1785
+ }
1786
+ else if (next === '&') {
1787
+ op += line[++i];
1788
+ while (/[\d-]/.test(line[i + 1] ?? ''))
1789
+ op += line[++i];
1790
+ // sh reads `>&1x` or `2>&1#…` as one redirect word, not as `>&1`
1791
+ // followed by more text; what that does is not worth modelling.
1792
+ if (line[i + 1] !== undefined && !/[\s;|&<>()]/.test(line[i + 1]))
1793
+ return null;
1794
+ }
1795
+ words.push(op);
1796
+ }
1797
+ else if (c === '&' || c === '(' || c === ')') {
1798
+ return null;
1799
+ }
1800
+ else if (c === '#' && !inWord && (i === 0 || /[\s;|&()]/.test(line[i - 1]))) {
1801
+ // A comment only where a word could start; `x>#y` or `>&1#` is not one.
1802
+ const eol = line.indexOf('\n', i);
1803
+ i = (eol < 0 ? line.length : eol) - 1;
1804
+ }
1805
+ else if ('$`*?[{'.includes(c) || (c === '~' && (!inWord || /[=:]$/.test(word)))) {
1806
+ // Expansions: variables, command substitution, globs, braces and a
1807
+ // home directory. What they turn into is not in the line.
1808
+ return null;
1809
+ }
1810
+ else {
1811
+ word += c;
1812
+ inWord = true;
1813
+ }
1814
+ }
1815
+ if (!endCommand() && needCommand)
1816
+ return null;
1817
+ return commands;
1818
+ }
1819
+ /** Shell builtins that do nothing, as in `npm test || true`. */
1820
+ const SHELL_NO_OPS = new Set(['true', 'false', ':']);
1821
+ /**
1822
+ * Check every command of a skill's shell line against the execute_command
1823
+ * policy (whitelist, blocked patterns, paths, SSRF). Returns why the line
1824
+ * is refused, or null when it may run.
1825
+ */
1826
+ async function checkSkillCommand(line, cwd) {
1827
+ // Skill lines run through spawnSync(..., { shell: true }), which is cmd.exe
1828
+ // on Windows. The splitter reads sh: cmd.exe treats quotes, `^`, `%` and
1829
+ // `!` differently, so a line that is one quoted argument here can be
1830
+ // several commands there, and the rest would never be checked.
1831
+ if (process.platform === 'win32' && /['"^%!&|<>]/.test(line)) {
1832
+ return "it uses characters cmd.exe interprets differently (quotes, ^, %, !, &, |, <, >), so it cannot be checked on Windows";
1833
+ }
1834
+ const commands = splitShellCommands(line);
1835
+ if (!commands || commands.length === 0) {
1836
+ return 'it uses shell syntax that cannot be checked (`$`, backticks, globs, braces, `~`, subshells, background jobs, heredocs or unbalanced quotes)';
1837
+ }
1838
+ const { validateCommandAsync } = await import('../utils/shell.js');
1839
+ // The splitter refused every `$` and backtick the shell would act on, so
1840
+ // those left are quoted text (`git commit -m "fix \`foo\`"`). The
1841
+ // policy's command-substitution patterns must not refuse them.
1842
+ const plain = (word) => word.replace(/[$`]/g, '_');
1843
+ for (const [command, ...args] of commands) {
1844
+ if (SHELL_NO_OPS.has(command) && args.length === 0)
1845
+ continue;
1846
+ const verdict = await validateCommandAsync(command, args.map(plain), { cwd, projectRoot: cwd });
1847
+ if (!verdict.valid)
1848
+ return verdict.reason || 'command validation failed';
1849
+ }
1850
+ return null;
1851
+ }
1433
1852
  // ─── Renderers ────────────────────────────────────────────────────────────────
1853
+ /** The TUI's /review prompt, so both surfaces review a diff the same way. */
1854
+ function buildDiffReviewPrompt(diffText) {
1855
+ return `You are doing a code review. Analyze this git diff and give structured feedback.
1856
+
1857
+ \`\`\`diff
1858
+ ${diffText}
1859
+ \`\`\`
1860
+
1861
+ Review for:
1862
+ 1. **Bugs** — logic errors, off-by-one, null/undefined issues
1863
+ 2. **Security** — injection, auth issues, exposed secrets, unsafe operations
1864
+ 3. **Performance** — unnecessary loops, missing indexes, memory leaks
1865
+ 4. **Edge cases** — unhandled inputs, missing error handling
1866
+ 5. **Code quality** — readability, naming, duplication
1867
+
1868
+ Format: use headers per category, only include categories where you found issues. End with a short overall verdict (1-2 sentences). Be concise and specific — reference file names and line numbers from the diff where possible.`;
1869
+ }
1434
1870
  function buildHelp() {
1435
1871
  // Keep this mirrored with the switch in handleSlashCommand above. Every `case`
1436
1872
  // that isn't a skill alias should have a row here, otherwise users in Zed /