codeep 2.18.0 → 2.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -16
- package/dist/acp/commands.js +11 -55
- package/dist/acp/protocol.d.ts +34 -0
- package/dist/acp/server.d.ts +6 -1
- package/dist/acp/server.js +97 -2
- package/dist/api/index.js +9 -0
- package/dist/commands/core/index.d.ts +19 -0
- package/dist/commands/core/index.js +28 -0
- package/dist/commands/core/keysync.d.ts +2 -0
- package/dist/commands/core/keysync.js +34 -0
- package/dist/commands/core/telemetry.d.ts +2 -0
- package/dist/commands/core/telemetry.js +34 -0
- package/dist/config/index.js +2 -2
- package/dist/renderer/App.d.ts +9 -48
- package/dist/renderer/App.js +113 -338
- package/dist/renderer/Screen.d.ts +13 -0
- package/dist/renderer/Screen.js +22 -0
- package/dist/renderer/commands/registry.js +3 -3
- package/dist/renderer/commands.js +19 -51
- package/dist/renderer/components/CommandAutocomplete.d.ts +46 -0
- package/dist/renderer/components/CommandAutocomplete.js +103 -0
- package/dist/renderer/components/HunkPicker.d.ts +48 -0
- package/dist/renderer/components/HunkPicker.js +140 -0
- package/dist/renderer/components/MentionPicker.d.ts +60 -0
- package/dist/renderer/components/MentionPicker.js +111 -0
- package/dist/renderer/components/PasteDialog.d.ts +43 -0
- package/dist/renderer/components/PasteDialog.js +70 -0
- package/dist/renderer/layout.js +1 -0
- package/dist/renderer/main.js +15 -39
- package/dist/utils/agent.js +121 -26
- package/dist/utils/agentChat.d.ts +11 -4
- package/dist/utils/agentChat.js +53 -25
- package/dist/utils/codeepCloud.d.ts +3 -0
- package/dist/utils/codeepCloud.js +62 -7
- package/dist/utils/personalities.d.ts +63 -5
- package/dist/utils/personalities.js +583 -31
- package/dist/utils/shell.d.ts +11 -1
- package/dist/utils/shell.js +169 -82
- package/dist/utils/ssrfGuard.d.ts +18 -0
- package/dist/utils/ssrfGuard.js +83 -0
- package/dist/utils/taskPlanner.d.ts +7 -1
- package/dist/utils/taskPlanner.js +16 -7
- package/dist/utils/tokenTracker.d.ts +8 -1
- package/dist/utils/tokenTracker.js +38 -5
- package/dist/utils/toolExecution.d.ts +1 -0
- package/dist/utils/toolExecution.js +48 -88
- package/dist/utils/tools.d.ts +3 -3
- package/dist/utils/tools.js +18 -13
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// components/MentionPicker.ts
|
|
2
|
+
// `@mention` file autocomplete — mid-sentence `@path/to/file` picker.
|
|
3
|
+
//
|
|
4
|
+
// Extracted from App.ts (P2 refactor) following the component convention.
|
|
5
|
+
// Separate from the `/command` Autocomplete because mentions appear
|
|
6
|
+
// mid-sentence (not just at the start) and insert a file path (not a slash
|
|
7
|
+
// command). `atStart` is the index of the `@` in the editor value, used to
|
|
8
|
+
// replace `@query` with `@selectedPath` on Tab.
|
|
9
|
+
//
|
|
10
|
+
// App keeps the editor mutations (setValue/setCursorPos) as side effects;
|
|
11
|
+
// this module owns the state shape, navigation, selection math, and render.
|
|
12
|
+
import { fg, style } from '../ansi.js';
|
|
13
|
+
import { PRIMARY_COLOR } from './uiConstants.js';
|
|
14
|
+
export function createMentionPickerState() {
|
|
15
|
+
return { open: false, index: 0, items: [], atStart: 0, root: '' };
|
|
16
|
+
}
|
|
17
|
+
/** Set the picker to a fresh query result. */
|
|
18
|
+
export function openMentionPicker(state, items, atStart, root) {
|
|
19
|
+
return { open: items.length > 0, index: 0, items, atStart, root };
|
|
20
|
+
}
|
|
21
|
+
/** Close the picker and clear its items. */
|
|
22
|
+
export function closeMentionPicker(state) {
|
|
23
|
+
return { ...state, open: false, items: [] };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Handle one key event while the mention picker is open. Returns the next
|
|
27
|
+
* state plus an action; `select` carries the chosen suggestion and the
|
|
28
|
+
* `@` index so App can rewrite the editor buffer.
|
|
29
|
+
*/
|
|
30
|
+
export function handleMentionPickerKey(state, event) {
|
|
31
|
+
switch (event.key) {
|
|
32
|
+
case 'up':
|
|
33
|
+
return {
|
|
34
|
+
state: { ...state, index: Math.max(0, state.index - 1) },
|
|
35
|
+
action: { type: 'navigate', delta: -1 },
|
|
36
|
+
};
|
|
37
|
+
case 'down':
|
|
38
|
+
return {
|
|
39
|
+
state: {
|
|
40
|
+
...state,
|
|
41
|
+
index: Math.min(state.items.length - 1, state.index + 1),
|
|
42
|
+
},
|
|
43
|
+
action: { type: 'navigate', delta: 1 },
|
|
44
|
+
};
|
|
45
|
+
case 'tab':
|
|
46
|
+
if (state.items.length > 0) {
|
|
47
|
+
const suggestion = state.items[state.index];
|
|
48
|
+
return {
|
|
49
|
+
state: closeMentionPicker(state),
|
|
50
|
+
action: { type: 'select', suggestion, atStart: state.atStart },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return { state, action: { type: 'none' } };
|
|
54
|
+
default:
|
|
55
|
+
return { state, action: { type: 'none' } };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Compute the editor buffer after applying a mention selection. Pure:
|
|
60
|
+
* returns the next value + cursor position for App to set.
|
|
61
|
+
*
|
|
62
|
+
* `atStart` is the index OF the `@`, so the `before` slice EXCLUDES it —
|
|
63
|
+
* the sigil is re-added or the completed path is no longer a mention and
|
|
64
|
+
* the file never gets attached.
|
|
65
|
+
*/
|
|
66
|
+
export function applyMentionToBuffer(value, cursor, atStart, suggestion) {
|
|
67
|
+
const before = value.slice(0, atStart) + '@';
|
|
68
|
+
const after = value.slice(cursor);
|
|
69
|
+
const next = before + suggestion.insertPath + ' ' + after;
|
|
70
|
+
return { value: next, cursor: (before + suggestion.insertPath + ' ').length };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Paint the mention picker below the status bar. Mirrors the layout of the
|
|
74
|
+
* `/` Autocomplete (separator → title → items → footer) but shows file
|
|
75
|
+
* paths with their parent directory as the description, and an `@` prefix.
|
|
76
|
+
*/
|
|
77
|
+
export function renderMentionPicker(screen, state, startY) {
|
|
78
|
+
const items = state.items;
|
|
79
|
+
const maxVisible = Math.min(items.length, 8);
|
|
80
|
+
let y = startY;
|
|
81
|
+
// Separator line
|
|
82
|
+
screen.horizontalLine(y++, '─', PRIMARY_COLOR);
|
|
83
|
+
// Title
|
|
84
|
+
screen.writeLine(y++, 'Add file to context (@mention)', PRIMARY_COLOR + style.bold);
|
|
85
|
+
// Items: `path` + directory detail
|
|
86
|
+
const visibleStart = Math.max(0, state.index - maxVisible + 1);
|
|
87
|
+
const visibleItems = items.slice(visibleStart, visibleStart + maxVisible);
|
|
88
|
+
for (let i = 0; i < visibleItems.length; i++) {
|
|
89
|
+
const item = visibleItems[i];
|
|
90
|
+
const actualIndex = visibleStart + i;
|
|
91
|
+
const isSelected = actualIndex === state.index;
|
|
92
|
+
const prefix = isSelected ? '► ' : ' ';
|
|
93
|
+
const pathText = ('@' + item.label).padEnd(40);
|
|
94
|
+
if (isSelected) {
|
|
95
|
+
screen.write(0, y, prefix, PRIMARY_COLOR);
|
|
96
|
+
screen.write(prefix.length, y, pathText, PRIMARY_COLOR + style.bold);
|
|
97
|
+
screen.write(prefix.length + pathText.length, y, item.detail, fg.white);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
screen.write(0, y, prefix, '');
|
|
101
|
+
screen.write(prefix.length, y, pathText, fg.cyan);
|
|
102
|
+
screen.write(prefix.length + pathText.length, y, item.detail, fg.gray);
|
|
103
|
+
}
|
|
104
|
+
y++;
|
|
105
|
+
}
|
|
106
|
+
// Footer
|
|
107
|
+
const scrollInfo = items.length > maxVisible
|
|
108
|
+
? ` (${visibleStart + 1}-${visibleStart + visibleItems.length}/${items.length})`
|
|
109
|
+
: '';
|
|
110
|
+
screen.writeLine(y, `↑↓ navigate • Tab select • Esc cancel${scrollInfo}`, fg.gray);
|
|
111
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Screen } from '../Screen';
|
|
2
|
+
/** Stats + preview for the pasted text (built by layout.buildPasteInfo). */
|
|
3
|
+
export interface PasteDialogInfo {
|
|
4
|
+
chars: number;
|
|
5
|
+
lines: number;
|
|
6
|
+
preview: string;
|
|
7
|
+
fullText: string;
|
|
8
|
+
}
|
|
9
|
+
export interface PasteDialogState {
|
|
10
|
+
open: boolean;
|
|
11
|
+
info: PasteDialogInfo | null;
|
|
12
|
+
}
|
|
13
|
+
export declare function createPasteDialogState(): PasteDialogState;
|
|
14
|
+
/** What App should do after a key press. */
|
|
15
|
+
export type PasteDialogAction = {
|
|
16
|
+
type: 'none';
|
|
17
|
+
} | {
|
|
18
|
+
type: 'cancel';
|
|
19
|
+
} | {
|
|
20
|
+
type: 'add-to-input';
|
|
21
|
+
text: string;
|
|
22
|
+
} | {
|
|
23
|
+
type: 'send-directly';
|
|
24
|
+
text: string;
|
|
25
|
+
};
|
|
26
|
+
/** Minimal key shape App's KeyEvent satisfies. */
|
|
27
|
+
export interface PasteDialogKeyEvent {
|
|
28
|
+
key: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Handle one key event for the paste dialog. Returns the next dialog state
|
|
32
|
+
* plus an action describing what App should perform as a side effect.
|
|
33
|
+
* The dialog closes on every recognized key.
|
|
34
|
+
*/
|
|
35
|
+
export declare function handlePasteDialogKey(state: PasteDialogState, event: PasteDialogKeyEvent): {
|
|
36
|
+
state: PasteDialogState;
|
|
37
|
+
action: PasteDialogAction;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Paint the paste dialog below the status bar. Mirrors the rendering
|
|
41
|
+
* previously inlined in App.renderInlinePasteInfo.
|
|
42
|
+
*/
|
|
43
|
+
export declare function renderPasteDialog(screen: Screen, state: PasteDialogState, startY: number, width: number): void;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// components/PasteDialog.ts
|
|
2
|
+
// Large-paste confirmation dialog ("Paste Detected").
|
|
3
|
+
//
|
|
4
|
+
// Extracted from App.ts (P2 refactor) following the component convention
|
|
5
|
+
// (Settings/Export/Search/HunkPicker): a state object + key handler + render
|
|
6
|
+
// function. When a large clipboard paste arrives, App stores the text here;
|
|
7
|
+
// the user then picks one of three actions — add to the input buffer
|
|
8
|
+
// (`y`/Enter), send directly as a message (`s`), or cancel (`n`/Esc).
|
|
9
|
+
// The key handler returns `{ action, state }` so App can perform the
|
|
10
|
+
// side-effectful parts (editor.insert / message submit) itself — the
|
|
11
|
+
// component stays pure and testable.
|
|
12
|
+
import { fg, style } from '../ansi.js';
|
|
13
|
+
import { PRIMARY_COLOR } from './uiConstants.js';
|
|
14
|
+
export function createPasteDialogState() {
|
|
15
|
+
return { open: false, info: null };
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Handle one key event for the paste dialog. Returns the next dialog state
|
|
19
|
+
* plus an action describing what App should perform as a side effect.
|
|
20
|
+
* The dialog closes on every recognized key.
|
|
21
|
+
*/
|
|
22
|
+
export function handlePasteDialogKey(state, event) {
|
|
23
|
+
const closed = createPasteDialogState();
|
|
24
|
+
if (event.key === 'escape' || event.key === 'n') {
|
|
25
|
+
return { state: closed, action: { type: 'cancel' } };
|
|
26
|
+
}
|
|
27
|
+
if (event.key === 'enter' || event.key === 'y') {
|
|
28
|
+
const text = state.info?.fullText ?? '';
|
|
29
|
+
return { state: closed, action: { type: 'add-to-input', text } };
|
|
30
|
+
}
|
|
31
|
+
if (event.key === 's') {
|
|
32
|
+
const text = state.info?.fullText ?? '';
|
|
33
|
+
return { state: closed, action: { type: 'send-directly', text } };
|
|
34
|
+
}
|
|
35
|
+
return { state, action: { type: 'none' } };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Paint the paste dialog below the status bar. Mirrors the rendering
|
|
39
|
+
* previously inlined in App.renderInlinePasteInfo.
|
|
40
|
+
*/
|
|
41
|
+
export function renderPasteDialog(screen, state, startY, width) {
|
|
42
|
+
const info = state.info;
|
|
43
|
+
if (!info)
|
|
44
|
+
return;
|
|
45
|
+
let y = startY;
|
|
46
|
+
// Separator line
|
|
47
|
+
screen.horizontalLine(y++, '─', PRIMARY_COLOR);
|
|
48
|
+
// Title with stats
|
|
49
|
+
screen.write(0, y, 'Paste Detected ', PRIMARY_COLOR + style.bold);
|
|
50
|
+
screen.write(15, y, `(${info.chars} chars, ${info.lines} lines)`, fg.cyan);
|
|
51
|
+
y++;
|
|
52
|
+
// Preview box
|
|
53
|
+
y++;
|
|
54
|
+
const previewLines = info.preview.split('\n').slice(0, 5);
|
|
55
|
+
for (const line of previewLines) {
|
|
56
|
+
const displayLine = line.length > width - 4 ? line.slice(0, width - 7) + '...' : line;
|
|
57
|
+
screen.writeLine(y++, ' ' + displayLine, fg.gray);
|
|
58
|
+
}
|
|
59
|
+
if (info.lines > 5) {
|
|
60
|
+
screen.writeLine(y++, ` ... (${info.lines - 5} more lines)`, fg.gray);
|
|
61
|
+
}
|
|
62
|
+
y++;
|
|
63
|
+
// Options
|
|
64
|
+
screen.write(0, y, '[Y/Enter] ', fg.green);
|
|
65
|
+
screen.write(10, y, 'Add to input', fg.white);
|
|
66
|
+
screen.write(25, y, '[S] ', fg.yellow);
|
|
67
|
+
screen.write(29, y, 'Send directly', fg.white);
|
|
68
|
+
screen.write(45, y, '[N/Esc] ', fg.red);
|
|
69
|
+
screen.write(53, y, 'Cancel', fg.white);
|
|
70
|
+
}
|
package/dist/renderer/layout.js
CHANGED
|
@@ -38,6 +38,7 @@ export function bottomPanelHeight(s) {
|
|
|
38
38
|
}
|
|
39
39
|
if (s.hunkPickerOpen) {
|
|
40
40
|
// Title + progress + path + header + up to 12 diff lines + more marker + legend.
|
|
41
|
+
// Kept in sync with components/HunkPicker.ts (hunkPickerPanelHeight).
|
|
41
42
|
return 18;
|
|
42
43
|
}
|
|
43
44
|
if (s.statusOpen) {
|
package/dist/renderer/main.js
CHANGED
|
@@ -19,7 +19,6 @@ import { getProviderList, isNoApiKeyProvider, resolveReasoningTier } from '../co
|
|
|
19
19
|
import { getSessionStats, getCostBreakdown, getRecordCount } from '../utils/tokenTracker.js';
|
|
20
20
|
import { getGitStatus, isGitRepository } from '../utils/git.js';
|
|
21
21
|
import { reportStats, syncSession, generateProjectId } from '../utils/codeepCloud.js';
|
|
22
|
-
import { checkApiRateLimit } from '../utils/ratelimit.js';
|
|
23
22
|
import { expandFileAndFolderMentions, expandGitMentions } from '../utils/mentions.js';
|
|
24
23
|
import { expandWebMentions } from '../utils/webFetch.js';
|
|
25
24
|
import { handleCommand as dispatchCommand } from './commands.js';
|
|
@@ -169,11 +168,10 @@ async function handleSubmit(message) {
|
|
|
169
168
|
executeAgentTask(enhancedTask, dryRun, ctx);
|
|
170
169
|
return;
|
|
171
170
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
171
|
+
// API rate limiting now lives at the transport layer (api/index.ts chat()
|
|
172
|
+
// + utils/agentChat.ts agentChat/agentChatFallback) — the single choke
|
|
173
|
+
// point shared by TUI, ACP and sub-agents. Checking here too would count
|
|
174
|
+
// the same request against the window twice.
|
|
177
175
|
// Auto agent mode
|
|
178
176
|
const agentMode = config.get('agentMode') || 'off';
|
|
179
177
|
if (agentMode === 'on' && projectContext && hasWriteAccess && !isAgentRunningFlag) {
|
|
@@ -275,40 +273,14 @@ async function handleSubmit(message) {
|
|
|
275
273
|
projectId,
|
|
276
274
|
messages: messagesNow,
|
|
277
275
|
});
|
|
278
|
-
|
|
279
|
-
sessionId,
|
|
280
|
-
sessionName: displayName,
|
|
281
|
-
messageCount: messagesNow.length,
|
|
282
|
-
cliVersion: getCurrentVersion(),
|
|
283
|
-
projectName: projectContext?.name,
|
|
284
|
-
projectId,
|
|
285
|
-
language: projectContext?.type,
|
|
286
|
-
isGit: isGitRepository(process.cwd()),
|
|
287
|
-
};
|
|
288
|
-
// Cloud stats are append-only events, so report only this prompt's delta.
|
|
289
|
-
// Sending the full session accumulator after every prompt makes totals grow
|
|
290
|
-
// 1× + 2× + 3× and is the source of the inflated dashboard token count.
|
|
291
|
-
const costBreakdown = getCostBreakdown(tokenReportStart);
|
|
292
|
-
if (costBreakdown.length > 0) {
|
|
293
|
-
for (const entry of costBreakdown) {
|
|
294
|
-
reportStats({
|
|
295
|
-
...sharedFields,
|
|
296
|
-
model: entry.model,
|
|
297
|
-
provider: entry.provider,
|
|
298
|
-
inputTokens: entry.promptTokens || undefined,
|
|
299
|
-
outputTokens: entry.completionTokens || undefined,
|
|
300
|
-
cacheCreationTokens: entry.cacheCreationTokens || undefined,
|
|
301
|
-
cacheReadTokens: entry.cacheReadTokens || undefined,
|
|
302
|
-
estimatedCost: entry.estimatedCost || undefined,
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
else {
|
|
307
|
-
reportStats({ ...sharedFields, model: config.get('model'), provider: config.get('provider') });
|
|
308
|
-
}
|
|
276
|
+
reportTurnStats(true);
|
|
309
277
|
}
|
|
310
278
|
catch (error) {
|
|
311
279
|
app.endStreaming();
|
|
280
|
+
// The turn still consumed tokens even though it failed or was aborted, so
|
|
281
|
+
// report the delta before returning — gracefulShutdown no longer sends a
|
|
282
|
+
// cumulative catch-all that would have swept them up later.
|
|
283
|
+
reportTurnStats(false);
|
|
312
284
|
const err = error;
|
|
313
285
|
if (err.name === 'AbortError')
|
|
314
286
|
return;
|
|
@@ -569,11 +541,15 @@ Commands (in chat):
|
|
|
569
541
|
console.log(' Cloud key sync is off — skipping API keys. Enable with: /keysync on');
|
|
570
542
|
}
|
|
571
543
|
// Also pull portable personal config — personalities + custom commands +
|
|
572
|
-
// the user profile.
|
|
573
|
-
|
|
544
|
+
// the user profile. Web-edited personalities replace their local copy
|
|
545
|
+
// after a safety backup; commands/profile retain additive merge rules.
|
|
546
|
+
const { pullPersonalities, pullCommands, pullUserProfile, getLastPersonalityPullBackupCount } = await import('../utils/codeepCloud.js');
|
|
574
547
|
const pCount = await pullPersonalities();
|
|
575
548
|
if (typeof pCount === 'number' && pCount > 0) {
|
|
576
549
|
console.log(` Pulled ${pCount} personalit${pCount === 1 ? 'y' : 'ies'}.`);
|
|
550
|
+
const backups = getLastPersonalityPullBackupCount();
|
|
551
|
+
if (backups > 0)
|
|
552
|
+
console.log(` Backed up ${backups} replaced local cop${backups === 1 ? 'y' : 'ies'} in ~/.codeep/backups/personalities/.`);
|
|
577
553
|
}
|
|
578
554
|
const cCount = await pullCommands();
|
|
579
555
|
if (typeof cCount === 'number' && cCount > 0) {
|
package/dist/utils/agent.js
CHANGED
|
@@ -14,6 +14,7 @@ const debug = (...args) => {
|
|
|
14
14
|
import { agentChat, getAgentSystemPrompt, getFallbackSystemPrompt, loadProjectRules, loadProgressLog, writeProgressLog, formatChatHistoryForAgent, summarizeEarlierHistory, } from './agentChat.js';
|
|
15
15
|
import { ApiError } from '../api/index.js';
|
|
16
16
|
import { loadUserProfilePrompt } from './userProfile.js';
|
|
17
|
+
import { getActivePersonality, getPersonalityToolAllowlist, isPersonalityToolCallAllowed, resolvePersonalityRuntimeModel, } from './personalities.js';
|
|
17
18
|
export { loadProjectRules, loadProgressLog, writeProgressLog, formatChatHistoryForAgent };
|
|
18
19
|
/**
|
|
19
20
|
* Calculate dynamic timeout based on task complexity
|
|
@@ -187,6 +188,20 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
187
188
|
const startTime = Date.now();
|
|
188
189
|
const actions = [];
|
|
189
190
|
const messages = [];
|
|
191
|
+
// A structured custom bot is resolved once per run. This keeps a cloud sync
|
|
192
|
+
// or file edit from changing policy halfway through an in-flight request.
|
|
193
|
+
const activePersonality = getActivePersonality(projectContext.root);
|
|
194
|
+
const currentRuntime = {
|
|
195
|
+
providerId: String(config.get('provider')),
|
|
196
|
+
model: String(config.get('model')),
|
|
197
|
+
protocol: config.get('protocol'),
|
|
198
|
+
};
|
|
199
|
+
const personalityModel = activePersonality
|
|
200
|
+
? resolvePersonalityRuntimeModel(activePersonality, currentRuntime)
|
|
201
|
+
: null;
|
|
202
|
+
const chatRuntime = {
|
|
203
|
+
...(personalityModel ?? currentRuntime),
|
|
204
|
+
};
|
|
190
205
|
// Start history session for undo support. Skipped for nested (delegated)
|
|
191
206
|
// runs so we don't reset the parent's currentSession singleton — the
|
|
192
207
|
// sub-agent's actions still record into the parent's open session.
|
|
@@ -204,7 +219,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
204
219
|
name: projectContext.name,
|
|
205
220
|
type: projectContext.type,
|
|
206
221
|
structure: projectContext.structure,
|
|
207
|
-
});
|
|
222
|
+
}, chatRuntime);
|
|
208
223
|
if (taskPlan.tasks.length > 1) {
|
|
209
224
|
opts.onTaskPlan?.(taskPlan);
|
|
210
225
|
// Mark first task as in_progress
|
|
@@ -224,8 +239,8 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
224
239
|
const smartContext = gatherSmartContext(targetFile, projectContext, prompt);
|
|
225
240
|
const smartContextStr = formatSmartContext(smartContext);
|
|
226
241
|
// Check if provider supports native tools
|
|
227
|
-
const protocol = config.get('protocol');
|
|
228
|
-
const providerId = config.get('provider');
|
|
242
|
+
const protocol = chatRuntime.protocol ?? config.get('protocol');
|
|
243
|
+
const providerId = chatRuntime.providerId ?? config.get('provider');
|
|
229
244
|
const useNativeTools = supportsNativeTools(providerId, protocol);
|
|
230
245
|
// Fetch the MCP tool catalog once per agent run. The session id keys into
|
|
231
246
|
// mcpRegistry; if no MCP servers are registered (or mcpSessionId is unset,
|
|
@@ -239,6 +254,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
239
254
|
// read <uri>` manually. Servers that don't expose resources or prompts
|
|
240
255
|
// get no virtual tools — the wrappers are only emitted where useful.
|
|
241
256
|
let mcpToolDefs = [];
|
|
257
|
+
const registeredMcpToolNames = new Set();
|
|
242
258
|
if (opts.mcpSessionId) {
|
|
243
259
|
try {
|
|
244
260
|
const { getSessionTools, getSessionVirtualTools } = await import('./mcpRegistry.js');
|
|
@@ -251,6 +267,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
251
267
|
description: t.description,
|
|
252
268
|
inputSchema: t.inputSchema,
|
|
253
269
|
}));
|
|
270
|
+
mcpToolDefs.forEach(tool => registeredMcpToolNames.add(tool.name));
|
|
254
271
|
}
|
|
255
272
|
catch {
|
|
256
273
|
// Don't let a registry blip kill the whole agent run.
|
|
@@ -312,10 +329,31 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
312
329
|
// Agent loading must never block the run.
|
|
313
330
|
}
|
|
314
331
|
}
|
|
332
|
+
const updateRuntimeToolAllowlist = () => {
|
|
333
|
+
const personalityToolAllowlist = activePersonality
|
|
334
|
+
? getPersonalityToolAllowlist(activePersonality, registeredMcpToolNames)
|
|
335
|
+
: undefined;
|
|
336
|
+
let effectiveToolAllowlist;
|
|
337
|
+
if (!personalityToolAllowlist && !opts.allowedTools)
|
|
338
|
+
effectiveToolAllowlist = undefined;
|
|
339
|
+
else if (!personalityToolAllowlist)
|
|
340
|
+
effectiveToolAllowlist = [...(opts.allowedTools ?? [])];
|
|
341
|
+
else if (!opts.allowedTools)
|
|
342
|
+
effectiveToolAllowlist = personalityToolAllowlist;
|
|
343
|
+
else {
|
|
344
|
+
const delegated = new Set(opts.allowedTools);
|
|
345
|
+
effectiveToolAllowlist = personalityToolAllowlist.filter(tool => delegated.has(tool));
|
|
346
|
+
}
|
|
347
|
+
if (effectiveToolAllowlist)
|
|
348
|
+
chatRuntime.allowedToolNames = effectiveToolAllowlist;
|
|
349
|
+
else
|
|
350
|
+
delete chatRuntime.allowedToolNames;
|
|
351
|
+
};
|
|
352
|
+
updateRuntimeToolAllowlist();
|
|
315
353
|
// Build system prompt - use fallback format if native tools not supported
|
|
316
354
|
let systemPrompt = useNativeTools
|
|
317
|
-
? getAgentSystemPrompt(projectContext)
|
|
318
|
-
: getFallbackSystemPrompt(projectContext, mcpToolDefs);
|
|
355
|
+
? getAgentSystemPrompt(projectContext, chatRuntime)
|
|
356
|
+
: getFallbackSystemPrompt(projectContext, mcpToolDefs, chatRuntime);
|
|
319
357
|
// Delegated sub-agent role — its defining instruction. Injected right after
|
|
320
358
|
// the base prompt so it frames everything that follows. Empty for normal runs.
|
|
321
359
|
if (opts.roleAddendum) {
|
|
@@ -371,16 +409,16 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
371
409
|
// Active personality goes LAST — appended after skills / project rules /
|
|
372
410
|
// smart context so its tone overrides earlier conventions. Set via
|
|
373
411
|
// `/personality <name>`; empty when no personality is active.
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
systemPrompt +=
|
|
412
|
+
if (activePersonality?.prompt) {
|
|
413
|
+
systemPrompt += activePersonality.prompt;
|
|
414
|
+
if (activePersonality.restrictTools) {
|
|
415
|
+
const capabilities = activePersonality.tools?.join(', ') || '(none recognised)';
|
|
416
|
+
systemPrompt += `\n\n## Enforced custom-bot capabilities\nThis run is restricted to: ${capabilities}. `
|
|
417
|
+
+ 'The runtime enforces this policy even if other prompt text asks for a disallowed tool. '
|
|
418
|
+
+ 'Tests permit matching test runners; Git permits a conservative set of built-in git commands. '
|
|
419
|
+
+ 'Broader executables (including gh) require Terminal.';
|
|
379
420
|
}
|
|
380
421
|
}
|
|
381
|
-
catch {
|
|
382
|
-
// Personality loading must never block an agent run.
|
|
383
|
-
}
|
|
384
422
|
// Initial user message with optional task plan
|
|
385
423
|
let initialPrompt = prompt;
|
|
386
424
|
if (taskPlan) {
|
|
@@ -551,16 +589,24 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
551
589
|
// requiring a session restart to see new tools.
|
|
552
590
|
if (opts.mcpSessionId) {
|
|
553
591
|
try {
|
|
554
|
-
const { consumeSessionCatalogChanges, getSessionTools } = await import('./mcpRegistry.js');
|
|
592
|
+
const { consumeSessionCatalogChanges, getSessionTools, getSessionVirtualTools } = await import('./mcpRegistry.js');
|
|
555
593
|
const dirty = consumeSessionCatalogChanges(opts.mcpSessionId);
|
|
556
594
|
if (dirty.has('tools')) {
|
|
557
|
-
const refreshed = await
|
|
558
|
-
|
|
595
|
+
const [refreshed, refreshedVirtuals] = await Promise.all([
|
|
596
|
+
getSessionTools(opts.mcpSessionId),
|
|
597
|
+
getSessionVirtualTools(opts.mcpSessionId),
|
|
598
|
+
]);
|
|
599
|
+
const localToolDefs = mcpToolDefs.filter(tool => tool.name === 'invoke_skill' || tool.name === 'delegate');
|
|
600
|
+
const refreshedMcpDefs = [...refreshed, ...refreshedVirtuals].map(t => ({
|
|
559
601
|
name: t.agentName,
|
|
560
602
|
description: t.description,
|
|
561
603
|
inputSchema: t.inputSchema,
|
|
562
604
|
}));
|
|
563
|
-
|
|
605
|
+
mcpToolDefs = [...refreshedMcpDefs, ...localToolDefs];
|
|
606
|
+
registeredMcpToolNames.clear();
|
|
607
|
+
refreshedMcpDefs.forEach(tool => registeredMcpToolNames.add(tool.name));
|
|
608
|
+
updateRuntimeToolAllowlist();
|
|
609
|
+
debug(`MCP tool catalog refreshed mid-run: ${refreshedMcpDefs.length} tool(s)`);
|
|
564
610
|
}
|
|
565
611
|
}
|
|
566
612
|
catch {
|
|
@@ -573,7 +619,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
573
619
|
while (true) {
|
|
574
620
|
try {
|
|
575
621
|
chatResponse = await agentChat(messages, systemPrompt, opts.onChunk, opts.abortSignal, dynamicTimeout * (1 + retryCount * 0.5), // Increase timeout on retry
|
|
576
|
-
mcpToolDefs);
|
|
622
|
+
mcpToolDefs, chatRuntime);
|
|
577
623
|
consecutiveTimeouts = 0; // Reset consecutive count on success
|
|
578
624
|
consecutiveRateLimits = 0;
|
|
579
625
|
break;
|
|
@@ -772,6 +818,24 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
772
818
|
const toolResults = [];
|
|
773
819
|
for (const toolCall of toolCalls) {
|
|
774
820
|
opts.onToolCall?.(toolCall);
|
|
821
|
+
// Structured custom-bot policy is a runtime security boundary, not a
|
|
822
|
+
// prompt suggestion. It runs before permission UI or external ACP
|
|
823
|
+
// terminal delegation, so disallowed commands cannot escape via a
|
|
824
|
+
// different execution surface.
|
|
825
|
+
if (activePersonality && !isPersonalityToolCallAllowed(activePersonality, toolCall, registeredMcpToolNames)) {
|
|
826
|
+
const allowed = activePersonality.declaredTools?.join(', ') || 'none';
|
|
827
|
+
const denied = {
|
|
828
|
+
success: false,
|
|
829
|
+
output: '',
|
|
830
|
+
error: `Tool "${toolCall.tool}" is blocked by custom bot "${activePersonality.displayName}".`,
|
|
831
|
+
tool: toolCall.tool,
|
|
832
|
+
parameters: toolCall.parameters,
|
|
833
|
+
};
|
|
834
|
+
opts.onToolResult?.(denied, toolCall);
|
|
835
|
+
actions.push(createActionLog(toolCall, denied));
|
|
836
|
+
toolResults.push(`Tool ${toolCall.tool} is blocked by the active custom bot. Allowed capabilities: ${allowed}.`);
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
775
839
|
// Tool scoping for delegated sub-agents: reject any tool outside the
|
|
776
840
|
// agent's allowlist up front — no permission prompt, no execution.
|
|
777
841
|
if (opts.allowedTools && !opts.allowedTools.includes(toolCall.tool)) {
|
|
@@ -949,7 +1013,16 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
949
1013
|
// Support legacy boolean values: true -> 'all', false -> 'off'
|
|
950
1014
|
const autoVerify = autoVerifyRaw === true ? 'all' : autoVerifyRaw === false ? 'off' : autoVerifyRaw;
|
|
951
1015
|
const maxFixAttempts = opts.maxFixAttempts ?? config.get('agentMaxFixAttempts');
|
|
952
|
-
|
|
1016
|
+
const botAllowsTerminal = !activePersonality?.restrictTools || activePersonality.tools?.includes('terminal') === true;
|
|
1017
|
+
const botAllowsTests = botAllowsTerminal || activePersonality?.tools?.includes('tests') === true;
|
|
1018
|
+
const verificationPolicy = {
|
|
1019
|
+
runBuild: (autoVerify === 'all' || autoVerify === 'build') && botAllowsTerminal,
|
|
1020
|
+
runTest: (autoVerify === 'all' || autoVerify === 'test') && botAllowsTests,
|
|
1021
|
+
runTypecheck: (autoVerify === 'all' || autoVerify === 'typecheck') && botAllowsTerminal,
|
|
1022
|
+
runLint: false,
|
|
1023
|
+
};
|
|
1024
|
+
const hasPermittedVerification = verificationPolicy.runBuild || verificationPolicy.runTest || verificationPolicy.runTypecheck;
|
|
1025
|
+
if (autoVerify !== 'off' && !opts.dryRun && hasPermittedVerification) {
|
|
953
1026
|
// Check if we made any file changes worth verifying
|
|
954
1027
|
const hasFileChanges = actions.some(a => a.type === 'write' || a.type === 'edit' || a.type === 'delete');
|
|
955
1028
|
if (hasFileChanges) {
|
|
@@ -962,12 +1035,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
962
1035
|
}
|
|
963
1036
|
opts.onIteration?.(iteration, `Verification attempt ${fixAttempt + 1}/${maxFixAttempts}`);
|
|
964
1037
|
// Run verifications based on selected mode
|
|
965
|
-
const verifyResults = await runAllVerifications(projectContext.root || process.cwd(),
|
|
966
|
-
runBuild: autoVerify === 'all' || autoVerify === 'build',
|
|
967
|
-
runTest: autoVerify === 'all' || autoVerify === 'test',
|
|
968
|
-
runTypecheck: autoVerify === 'all' || autoVerify === 'typecheck',
|
|
969
|
-
runLint: false,
|
|
970
|
-
});
|
|
1038
|
+
const verifyResults = await runAllVerifications(projectContext.root || process.cwd(), verificationPolicy);
|
|
971
1039
|
opts.onVerification?.(verifyResults);
|
|
972
1040
|
// Filter errors: only keep those related to files the agent touched
|
|
973
1041
|
const touchedFiles = new Set(actions
|
|
@@ -1031,7 +1099,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
1031
1099
|
}
|
|
1032
1100
|
// Get AI response to fix errors
|
|
1033
1101
|
try {
|
|
1034
|
-
const fixResponse = await agentChat(messages, systemPrompt, opts.onChunk, opts.abortSignal, undefined, mcpToolDefs);
|
|
1102
|
+
const fixResponse = await agentChat(messages, systemPrompt, opts.onChunk, opts.abortSignal, undefined, mcpToolDefs, chatRuntime);
|
|
1035
1103
|
const { content: fixContent, toolCalls: fixToolCalls } = fixResponse;
|
|
1036
1104
|
if (fixToolCalls.length === 0) {
|
|
1037
1105
|
// Agent gave up or thinks it's fixed
|
|
@@ -1043,6 +1111,32 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
1043
1111
|
const fixResults = [];
|
|
1044
1112
|
for (const toolCall of fixToolCalls) {
|
|
1045
1113
|
opts.onToolCall?.(toolCall);
|
|
1114
|
+
if (activePersonality && !isPersonalityToolCallAllowed(activePersonality, toolCall, registeredMcpToolNames)) {
|
|
1115
|
+
const denied = {
|
|
1116
|
+
success: false,
|
|
1117
|
+
output: '',
|
|
1118
|
+
error: `Tool "${toolCall.tool}" is blocked by custom bot "${activePersonality.displayName}".`,
|
|
1119
|
+
tool: toolCall.tool,
|
|
1120
|
+
parameters: toolCall.parameters,
|
|
1121
|
+
};
|
|
1122
|
+
opts.onToolResult?.(denied, toolCall);
|
|
1123
|
+
actions.push(createActionLog(toolCall, denied));
|
|
1124
|
+
fixResults.push(`Tool ${toolCall.tool} blocked by the active custom bot.`);
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
if (opts.allowedTools && !opts.allowedTools.includes(toolCall.tool)) {
|
|
1128
|
+
const denied = {
|
|
1129
|
+
success: false,
|
|
1130
|
+
output: '',
|
|
1131
|
+
error: `Tool "${toolCall.tool}" is not available to this sub-agent.`,
|
|
1132
|
+
tool: toolCall.tool,
|
|
1133
|
+
parameters: toolCall.parameters,
|
|
1134
|
+
};
|
|
1135
|
+
opts.onToolResult?.(denied, toolCall);
|
|
1136
|
+
actions.push(createActionLog(toolCall, denied));
|
|
1137
|
+
fixResults.push(`Tool ${toolCall.tool} is not allowed for this sub-agent.`);
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1046
1140
|
const toolResult = await executeTool(toolCall, projectContext.root || process.cwd(), opts.fs, opts.mcpSessionId);
|
|
1047
1141
|
opts.onToolResult?.(toolResult, toolCall);
|
|
1048
1142
|
const actionLog = createActionLog(toolCall, toolResult);
|
|
@@ -1074,6 +1168,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
1074
1168
|
if (!opts.nested
|
|
1075
1169
|
&& (opts.depth ?? 0) === 0
|
|
1076
1170
|
&& !opts.dryRun
|
|
1171
|
+
&& !activePersonality?.restrictTools
|
|
1077
1172
|
&& config.get('agentAutoReview') === true
|
|
1078
1173
|
&& !opts.abortSignal?.aborted
|
|
1079
1174
|
&& actions.some(a => a.type === 'write' || a.type === 'edit' || a.type === 'delete')) {
|
|
@@ -16,6 +16,13 @@ import { Message } from '../config/index';
|
|
|
16
16
|
import { AdditionalToolDef } from './tools';
|
|
17
17
|
import type { AgentChatResponse } from './agentStream';
|
|
18
18
|
export type { AgentChatResponse };
|
|
19
|
+
/** Per-run overrides used by structured custom bots. They never mutate config. */
|
|
20
|
+
export interface AgentChatRuntime {
|
|
21
|
+
providerId?: string;
|
|
22
|
+
model?: string;
|
|
23
|
+
protocol?: 'openai' | 'anthropic';
|
|
24
|
+
allowedToolNames?: string[];
|
|
25
|
+
}
|
|
19
26
|
/**
|
|
20
27
|
* Custom error class for timeout
|
|
21
28
|
*/
|
|
@@ -67,8 +74,8 @@ export declare function summarizeEarlierHistory(history?: Array<{
|
|
|
67
74
|
role: 'user' | 'assistant';
|
|
68
75
|
content: string;
|
|
69
76
|
}>, maxChars?: number): Promise<string>;
|
|
70
|
-
export declare function getAgentSystemPrompt(projectContext: ProjectContext): string;
|
|
71
|
-
export declare function getFallbackSystemPrompt(projectContext: ProjectContext, additionalTools?: AdditionalToolDef[]): string;
|
|
77
|
+
export declare function getAgentSystemPrompt(projectContext: ProjectContext, runtime?: AgentChatRuntime): string;
|
|
78
|
+
export declare function getFallbackSystemPrompt(projectContext: ProjectContext, additionalTools?: AdditionalToolDef[], runtime?: AgentChatRuntime): string;
|
|
72
79
|
/**
|
|
73
80
|
* Make a chat API call for agent mode with native tool support.
|
|
74
81
|
* Falls back to agentChatFallback() if provider doesn't support tools.
|
|
@@ -80,8 +87,8 @@ export declare function agentChat(messages: Message[], systemPrompt: string, onC
|
|
|
80
87
|
* invoke). Optional — built-in tools work the same whether this is
|
|
81
88
|
* omitted or an empty array.
|
|
82
89
|
*/
|
|
83
|
-
additionalTools?: AdditionalToolDef[]): Promise<AgentChatResponse>;
|
|
90
|
+
additionalTools?: AdditionalToolDef[], runtime?: AgentChatRuntime): Promise<AgentChatResponse>;
|
|
84
91
|
/**
|
|
85
92
|
* Fallback chat without native tools (text-based tool format)
|
|
86
93
|
*/
|
|
87
|
-
export declare function agentChatFallback(messages: Message[], systemPrompt: string, onChunk?: (chunk: string) => void, abortSignal?: AbortSignal, dynamicTimeout?: number): Promise<AgentChatResponse>;
|
|
94
|
+
export declare function agentChatFallback(messages: Message[], systemPrompt: string, onChunk?: (chunk: string) => void, abortSignal?: AbortSignal, dynamicTimeout?: number, additionalTools?: AdditionalToolDef[], runtime?: AgentChatRuntime): Promise<AgentChatResponse>;
|