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.
- package/dist/acp/commands.d.ts +50 -1
- package/dist/acp/commands.js +545 -109
- package/dist/acp/protocol.d.ts +14 -5
- package/dist/acp/server.d.ts +36 -1
- package/dist/acp/server.js +581 -155
- package/dist/acp/serverHandlers.d.ts +2 -1
- package/dist/acp/serverHandlers.js +3 -0
- package/dist/acp/session.d.ts +28 -2
- package/dist/acp/session.js +25 -6
- package/dist/acp/transport.d.ts +40 -4
- package/dist/acp/transport.js +218 -25
- package/dist/acp/turns.d.ts +20 -0
- package/dist/acp/turns.js +30 -0
- package/dist/api/index.js +2 -0
- package/dist/api/ollamaNative.d.ts +3 -0
- package/dist/api/ollamaNative.js +35 -3
- package/dist/config/index.d.ts +21 -4
- package/dist/config/index.js +178 -123
- package/dist/renderer/agentExecution.d.ts +30 -2
- package/dist/renderer/agentExecution.js +248 -92
- package/dist/renderer/commands/helpers.d.ts +18 -2
- package/dist/renderer/commands/helpers.js +28 -5
- package/dist/renderer/commands.d.ts +2 -0
- package/dist/renderer/commands.js +180 -64
- package/dist/renderer/main.d.ts +41 -0
- package/dist/renderer/main.js +181 -80
- package/dist/utils/agent.d.ts +69 -4
- package/dist/utils/agent.js +416 -248
- package/dist/utils/agentChat.js +82 -10
- package/dist/utils/agents.d.ts +2 -1
- package/dist/utils/agents.js +100 -29
- package/dist/utils/auditLog.d.ts +4 -3
- package/dist/utils/auditLog.js +92 -9
- package/dist/utils/checkpoints.js +11 -6
- package/dist/utils/codeReview.js +28 -23
- package/dist/utils/codeepCloud.d.ts +14 -2
- package/dist/utils/codeepCloud.js +56 -20
- package/dist/utils/customCommands.js +7 -2
- package/dist/utils/git.d.ts +262 -4
- package/dist/utils/git.js +1928 -61
- package/dist/utils/gitHookInstaller.d.ts +32 -1
- package/dist/utils/gitHookInstaller.js +76 -8
- package/dist/utils/gitignore.d.ts +8 -0
- package/dist/utils/gitignore.js +41 -10
- package/dist/utils/headlessReview.d.ts +11 -0
- package/dist/utils/headlessReview.js +33 -5
- package/dist/utils/history.d.ts +22 -6
- package/dist/utils/history.js +140 -26
- package/dist/utils/logger.js +6 -7
- package/dist/utils/mcpConfig.d.ts +24 -0
- package/dist/utils/mcpConfig.js +36 -5
- package/dist/utils/mentions.d.ts +28 -5
- package/dist/utils/mentions.js +253 -45
- package/dist/utils/personalities.js +16 -6
- package/dist/utils/planMode.d.ts +13 -7
- package/dist/utils/planMode.js +32 -12
- package/dist/utils/projectIntelligence.d.ts +2 -0
- package/dist/utils/projectIntelligence.js +27 -8
- package/dist/utils/projectPaths.d.ts +53 -0
- package/dist/utils/projectPaths.js +146 -0
- package/dist/utils/shell.d.ts +119 -0
- package/dist/utils/shell.js +417 -45
- package/dist/utils/skillBundles.js +17 -7
- package/dist/utils/skillBundlesCloud.js +20 -3
- package/dist/utils/skills.d.ts +24 -2
- package/dist/utils/skills.js +235 -43
- package/dist/utils/smartContext.js +97 -23
- package/dist/utils/telegramApproval.d.ts +10 -2
- package/dist/utils/telegramApproval.js +22 -4
- package/dist/utils/toolExecution.d.ts +50 -2
- package/dist/utils/toolExecution.js +418 -16
- package/dist/utils/toolParsing.d.ts +7 -1
- package/dist/utils/toolParsing.js +12 -3
- package/dist/utils/userProfile.js +58 -16
- package/dist/utils/verify.d.ts +25 -4
- package/dist/utils/verify.js +259 -74
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/acp/commands.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
519
|
-
|
|
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
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
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 {
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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 {
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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:
|
|
882
|
+
response: response || '_(plan executed; no final summary)_',
|
|
797
883
|
streaming: true,
|
|
798
884
|
};
|
|
799
885
|
}
|
|
800
886
|
catch (err) {
|
|
801
|
-
|
|
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
|
-
|
|
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
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
1018
|
-
const
|
|
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}
|
|
1024
|
-
return { handled: true, response: `Added \`${name}\` (${ok.length} tool${ok.length === 1 ? '' : 's'} available)
|
|
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)
|
|
1034
|
-
//
|
|
1035
|
-
const
|
|
1036
|
-
|
|
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
|
|
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
|
|
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 **${
|
|
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
|
-
//
|
|
1340
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1409
|
-
const
|
|
1410
|
-
|
|
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
|
-
//
|
|
1423
|
-
|
|
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 /
|