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.
Files changed (51) hide show
  1. package/README.md +53 -16
  2. package/dist/acp/commands.js +11 -55
  3. package/dist/acp/protocol.d.ts +34 -0
  4. package/dist/acp/server.d.ts +6 -1
  5. package/dist/acp/server.js +97 -2
  6. package/dist/api/index.js +9 -0
  7. package/dist/commands/core/index.d.ts +19 -0
  8. package/dist/commands/core/index.js +28 -0
  9. package/dist/commands/core/keysync.d.ts +2 -0
  10. package/dist/commands/core/keysync.js +34 -0
  11. package/dist/commands/core/telemetry.d.ts +2 -0
  12. package/dist/commands/core/telemetry.js +34 -0
  13. package/dist/config/index.js +2 -2
  14. package/dist/renderer/App.d.ts +9 -48
  15. package/dist/renderer/App.js +113 -338
  16. package/dist/renderer/Screen.d.ts +13 -0
  17. package/dist/renderer/Screen.js +22 -0
  18. package/dist/renderer/commands/registry.js +3 -3
  19. package/dist/renderer/commands.js +19 -51
  20. package/dist/renderer/components/CommandAutocomplete.d.ts +46 -0
  21. package/dist/renderer/components/CommandAutocomplete.js +103 -0
  22. package/dist/renderer/components/HunkPicker.d.ts +48 -0
  23. package/dist/renderer/components/HunkPicker.js +140 -0
  24. package/dist/renderer/components/MentionPicker.d.ts +60 -0
  25. package/dist/renderer/components/MentionPicker.js +111 -0
  26. package/dist/renderer/components/PasteDialog.d.ts +43 -0
  27. package/dist/renderer/components/PasteDialog.js +70 -0
  28. package/dist/renderer/layout.js +1 -0
  29. package/dist/renderer/main.js +15 -39
  30. package/dist/utils/agent.js +121 -26
  31. package/dist/utils/agentChat.d.ts +11 -4
  32. package/dist/utils/agentChat.js +53 -25
  33. package/dist/utils/codeepCloud.d.ts +3 -0
  34. package/dist/utils/codeepCloud.js +62 -7
  35. package/dist/utils/personalities.d.ts +63 -5
  36. package/dist/utils/personalities.js +583 -31
  37. package/dist/utils/shell.d.ts +11 -1
  38. package/dist/utils/shell.js +169 -82
  39. package/dist/utils/ssrfGuard.d.ts +18 -0
  40. package/dist/utils/ssrfGuard.js +83 -0
  41. package/dist/utils/taskPlanner.d.ts +7 -1
  42. package/dist/utils/taskPlanner.js +16 -7
  43. package/dist/utils/tokenTracker.d.ts +8 -1
  44. package/dist/utils/tokenTracker.js +38 -5
  45. package/dist/utils/toolExecution.d.ts +1 -0
  46. package/dist/utils/toolExecution.js +48 -88
  47. package/dist/utils/tools.d.ts +3 -3
  48. package/dist/utils/tools.js +18 -13
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.js +1 -1
  51. 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
+ }
@@ -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) {
@@ -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
- const rateCheck = checkApiRateLimit();
173
- if (!rateCheck.allowed) {
174
- app.notify(rateCheck.message || 'Rate limit exceeded', 5000);
175
- return;
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
- const sharedFields = {
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. Additive merge (never clobbers local files).
573
- const { pullPersonalities, pullCommands, pullUserProfile } = await import('../utils/codeepCloud.js');
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) {
@@ -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
- try {
375
- const { getActivePersonalityPrompt } = await import('./personalities.js');
376
- const personalityPrompt = getActivePersonalityPrompt(projectContext.root);
377
- if (personalityPrompt) {
378
- systemPrompt += personalityPrompt;
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 getSessionTools(opts.mcpSessionId);
558
- mcpToolDefs = refreshed.map(t => ({
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
- debug(`MCP tool catalog refreshed mid-run: ${mcpToolDefs.length} tool(s)`);
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
- if (autoVerify !== 'off' && !opts.dryRun) {
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>;