@@ -217,11 +254,32 @@ export function ChatInput({
)
}
onKeyDown={handleKeyDown}
+ onPaste={(e) => {
+ const files = Array.from(e.clipboardData.files).filter((file) => file.type.startsWith('image/'));
+ if (files.length > 0) {
+ addImageFiles(files);
+ }
+ }}
placeholder="Ask anything… use @ to add files or folders"
disabled={loading}
rows={3}
aria-label="Chat message input"
/>
+ {attachments.length > 0 && (
+
@@ -275,6 +333,25 @@ export function ChatInput({
+
{
+ if (e.target.files) addImageFiles(e.target.files);
+ e.currentTarget.value = '';
+ }}
+ />
+
fileInputRef.current?.click()}
+ disabled={loading}
+ >
+
+
{onRetry && (
@@ -304,7 +381,7 @@ export function ChatInput({
label="Send message"
variant="accent"
onClick={handleSend}
- disabled={!value.trim()}
+ disabled={!value.trim() && attachments.length === 0}
>
diff --git a/src/webview-ui/src/components/Icons.tsx b/src/webview-ui/src/components/Icons.tsx
index 63b11fb2..7043e607 100644
--- a/src/webview-ui/src/components/Icons.tsx
+++ b/src/webview-ui/src/components/Icons.tsx
@@ -83,6 +83,16 @@ export function IconMarkdown(props: IconProps) {
);
}
+export function IconImage(props: IconProps) {
+ return (
+
+ );
+}
+
export function IconContext(props: IconProps) {
return (
+ {!providerValidation.ok && (
+
+ {providerValidation.errors.join(' ')}
+
+ )}
@@ -1002,7 +1011,10 @@ export function SettingsPanel({
className="settings-input"
placeholder={settings.hasGithubToken ? 'Token saved - enter to replace' : 'Enter GitHub token...'}
value={githubToken}
- onChange={(e) => setGithubToken(e.target.value)}
+ onChange={(e) => {
+ setGithubToken(e.target.value);
+ markDirty();
+ }}
/>
@@ -1212,7 +1224,7 @@ export function SettingsPanel({
type="button"
className="btn btn--primary"
onClick={handleSaveAll}
- disabled={!dirty && !apiKey.trim() && !githubToken.trim()}
+ disabled={(!dirty && !apiKey.trim() && !githubToken.trim()) || (dirty && !providerValidation.ok)}
>
{saved ? 'Saved' : 'Save changes'}
diff --git a/src/webview-ui/src/state/store.ts b/src/webview-ui/src/state/store.ts
index f0863fa7..6cfeac38 100644
--- a/src/webview-ui/src/state/store.ts
+++ b/src/webview-ui/src/state/store.ts
@@ -11,6 +11,7 @@ import type {
AgentLiveStatusView,
SubagentStatusView,
TokenUsageView,
+ ReviewDiffView,
} from '../../../vscode/webview/messages';
import { initialWebviewState } from '../../../vscode/webview/messages';
@@ -29,7 +30,8 @@ export type WebviewAction =
| { type: 'SET_AGENT_ACTIVITY'; payload: AgentActivityEntry[] }
| { type: 'SET_AGENT_LIVE_STATUS'; payload: AgentLiveStatusView | null }
| { type: 'SET_SUBAGENTS'; payload: SubagentStatusView[] }
- | { type: 'SET_TOKEN_USAGE'; payload: TokenUsageView };
+ | { type: 'SET_TOKEN_USAGE'; payload: TokenUsageView }
+ | { type: 'SET_REVIEW_DIFF'; payload: ReviewDiffView | null };
export const initialState: WebviewState = initialWebviewState();
@@ -127,6 +129,9 @@ export function webviewReducer(state: WebviewState, action: WebviewAction): Webv
case 'SET_TOKEN_USAGE':
return { ...state, tokenUsage: action.payload };
+ case 'SET_REVIEW_DIFF':
+ return { ...state, reviewDiff: action.payload };
+
default:
return state;
}
diff --git a/src/webview-ui/src/state/useVsCodeMessaging.ts b/src/webview-ui/src/state/useVsCodeMessaging.ts
index 75deb0e3..bc228766 100644
--- a/src/webview-ui/src/state/useVsCodeMessaging.ts
+++ b/src/webview-ui/src/state/useVsCodeMessaging.ts
@@ -73,6 +73,9 @@ export function useVsCodeMessaging() {
case 'setTokenUsage':
dispatch({ type: 'SET_TOKEN_USAGE', payload: message.payload });
break;
+ case 'setReviewDiff':
+ dispatch({ type: 'SET_REVIEW_DIFF', payload: message.payload });
+ break;
case 'setContextPaths':
if (message.payload.requestId === pathSearchRequestIdRef.current) {
setPathSuggestions(message.payload.paths);
diff --git a/src/webview-ui/src/styles/global.css b/src/webview-ui/src/styles/global.css
index da4b60f4..ed456c2c 100644
--- a/src/webview-ui/src/styles/global.css
+++ b/src/webview-ui/src/styles/global.css
@@ -1183,6 +1183,36 @@ body,
opacity: 0.6;
}
+.composer__attachments {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 0 10px 6px;
+}
+
+.composer__attachment {
+ display: inline-flex;
+ max-width: 180px;
+ padding: 3px 8px;
+ border: 1px solid var(--thunder-border);
+ border-radius: 5px;
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--thunder-fg);
+ font: inherit;
+ font-size: 11px;
+ cursor: pointer;
+}
+
+.composer__attachment-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.composer__file-input {
+ display: none;
+}
+
.composer__footer {
display: flex;
align-items: center;
@@ -1396,6 +1426,152 @@ body,
background: rgba(255, 255, 255, 0.05);
}
+.onboarding {
+ display: grid;
+ gap: 14px;
+ max-width: 720px;
+ margin: 0 auto;
+}
+
+.onboarding__header,
+.review-panel__header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.onboarding__title,
+.review-panel__title {
+ margin: 0;
+ font-size: 18px;
+ line-height: 1.25;
+}
+
+.onboarding__subtitle,
+.review-panel__subtitle {
+ margin: 4px 0 0;
+ color: var(--thunder-muted);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.onboarding__steps {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 6px;
+}
+
+.onboarding__step {
+ min-height: 30px;
+ border: 1px solid var(--thunder-border);
+ border-radius: 5px;
+ background: transparent;
+ color: var(--thunder-muted);
+ font: inherit;
+ font-size: 11px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.onboarding__step--active {
+ border-color: var(--thunder-accent);
+ color: var(--thunder-fg);
+}
+
+.onboarding__body,
+.review-panel__empty {
+ display: grid;
+ gap: 12px;
+ padding: 14px;
+ border: 1px solid var(--thunder-border);
+ border-radius: 8px;
+ background: var(--thunder-surface);
+}
+
+.onboarding__body h3,
+.review-panel__empty h3 {
+ margin: 0;
+ font-size: 14px;
+}
+
+.onboarding__body p,
+.review-panel__empty p {
+ margin: 0;
+ color: var(--thunder-muted);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.onboarding__actions,
+.review-panel__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.review-panel {
+ display: grid;
+ align-content: start;
+ gap: 12px;
+}
+
+.review-panel__files {
+ display: grid;
+ gap: 8px;
+}
+
+.review-file {
+ border: 1px solid var(--thunder-border);
+ border-radius: 8px;
+ background: var(--thunder-surface);
+ overflow: hidden;
+}
+
+.review-file__summary {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 8px;
+ padding: 9px 10px;
+ cursor: pointer;
+}
+
+.review-file__status {
+ min-width: 22px;
+ color: var(--thunder-accent);
+ font-size: 10px;
+ font-weight: 800;
+}
+
+.review-file__path {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace);
+ font-size: 11px;
+}
+
+.review-file__stats {
+ color: var(--thunder-muted);
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.review-file__diff {
+ max-height: 420px;
+ margin: 0;
+ padding: 10px;
+ overflow: auto;
+ border-top: 1px solid var(--thunder-border);
+ background: var(--thunder-surface-2);
+ color: var(--thunder-fg);
+ font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace);
+ font-size: 11px;
+ line-height: 1.45;
+ white-space: pre;
+}
+
.btn--small {
min-height: 24px;
padding: 3px 8px;
@@ -2711,6 +2887,10 @@ body,
line-height: 1.45;
}
+.settings-inline-note--error {
+ color: var(--thunder-error-fg);
+}
+
.settings-inline-note code {
font-size: 10px;
}
diff --git a/test/act/act-orchestration.test.ts b/test/act/act-orchestration.test.ts
index 3d4775d2..b3ac8151 100644
--- a/test/act/act-orchestration.test.ts
+++ b/test/act/act-orchestration.test.ts
@@ -142,6 +142,19 @@ describe('Act orchestration boundary', () => {
expect(route.summary).toContain('GitHub issue');
});
+ it('does not use the GitHub issue route for reference-only issue detections', () => {
+ const message = 'Can you summarize https://github.com/acme/app/issues/7?';
+ const analysis = analyzeTask(message, 'agent');
+ const route = routeActIntent(message, analysis, {
+ mode: 'agent',
+ githubIssueMode: false,
+ orchestrationEnabled: true,
+ });
+
+ expect(route.summary).not.toContain('GitHub issue');
+ expect(route.shouldVerify).toBe(false);
+ });
+
it('adds ACT skill tool guidance to Agent system prompts when tools are enabled', () => {
const prompt = buildSystemPrompt('agent', true);
expect(prompt).toContain('ACT SKILLS:');
diff --git a/test/audit-pack.test.ts b/test/audit-pack.test.ts
index d4fb9175..1fdd0e6e 100644
--- a/test/audit-pack.test.ts
+++ b/test/audit-pack.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
-import { AuditPackBuilder } from '../src/core/audit';
+import { AuditPackBuilder, verifyAuditPack } from '../src/core/audit';
describe('AuditPackBuilder', () => {
it('builds a zip with expected entries and redacts secrets', () => {
@@ -32,15 +32,34 @@ describe('AuditPackBuilder', () => {
'tool-audit.json',
'approvals.json',
'redaction-report.json',
+ 'signature.json',
]);
expect(result.buffer.slice(0, 2).toString()).toBe('PK');
expect(zipText).toContain('session.jsonl');
expect(zipText).toContain('[REDACTED]');
expect(zipText).not.toContain('sk-abc1234567890');
expect(result.redactionReport.secretKeyRedactions).toBeGreaterThan(0);
+ expect(verifyAuditPack(result.buffer).ok).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
-});
+ it('detects signed audit pack tampering', () => {
+ const result = new AuditPackBuilder().build({
+ sessionId: 's1',
+ workspace: process.cwd(),
+ extensionVersion: '1.0.0',
+ summaryMarkdown: '# Summary',
+ signingKey: 'enterprise-secret',
+ });
+ expect(verifyAuditPack(result.buffer, 'enterprise-secret').ok).toBe(true);
+
+ const tampered = Buffer.from(result.buffer);
+ const idx = tampered.indexOf('Summary');
+ tampered.write('Tamper!', idx, 'utf8');
+ const verification = verifyAuditPack(tampered, 'enterprise-secret');
+ expect(verification.ok).toBe(false);
+ expect(verification.errors.some((error) => error.includes('hash mismatch'))).toBe(true);
+ });
+});
diff --git a/test/messages.test.ts b/test/messages.test.ts
index 2f66a28b..68988532 100644
--- a/test/messages.test.ts
+++ b/test/messages.test.ts
@@ -9,6 +9,7 @@ describe('Webview message protocol', () => {
expect(state.messages).toHaveLength(0);
expect(state.approvals).toHaveLength(0);
expect(state.indexing.running).toBe(false);
+ expect(state.settings.hasGithubToken).toBe(false);
});
it('has default context toggles with diagnostics off by default', () => {
diff --git a/test/top10-features.test.ts b/test/top10-features.test.ts
new file mode 100644
index 00000000..91272148
--- /dev/null
+++ b/test/top10-features.test.ts
@@ -0,0 +1,151 @@
+import { describe, expect, it, vi } from 'vitest';
+import { validateProviderSettings } from '../src/core/config/ui/mappers';
+import { detectEditorRebuildCommand } from '../src/vscode/nativeModuleHealth';
+import { parseReviewDiffFiles } from '../src/core/scm/ReviewDiffCollector';
+import { OpenAiCompatibleProvider } from '../src/core/llm/OpenAiCompatibleProvider';
+import { HeadlessAgentRunner } from '../src/core/headless';
+import { createProvider } from '../src/core/llm/createProvider';
+import { detectModelCapabilities } from '../src/core/llm/modelCapabilities';
+import { WebhookEmitter } from '../src/core/telemetry/WebhookEmitter';
+
+describe('provider setup validation', () => {
+ it('requires provider-specific fields', () => {
+ expect(validateProviderSettings({
+ providerType: 'azure-openai',
+ baseUrl: 'not-a-url',
+ model: '',
+ apiVersion: '',
+ region: 'us-east-1',
+ contextWindow: 512,
+ }).errors).toEqual(expect.arrayContaining([
+ 'API base URL must be a valid URL.',
+ 'Azure deployment name is required.',
+ 'Azure API version is required.',
+ 'Context window must be at least 1024 tokens.',
+ ]));
+ });
+
+ it('accepts a complete local OpenAI-compatible preset', () => {
+ expect(validateProviderSettings({
+ providerType: 'openai-compatible',
+ baseUrl: 'http://localhost:11434/v1',
+ model: 'qwen3-coder:30b',
+ contextWindow: 8192,
+ }).ok).toBe(true);
+ });
+});
+
+describe('native module health helpers', () => {
+ it('uses a Cursor-specific rebuild command when Cursor env is present', () => {
+ expect(detectEditorRebuildCommand({ CURSOR_TRACE_ID: '1' } as NodeJS.ProcessEnv)).toBe(
+ 'THUNDER_EDITOR=cursor npm run rebuild:native'
+ );
+ });
+});
+
+describe('review diff parsing', () => {
+ it('groups file diffs and counts changed lines', () => {
+ const files = parseReviewDiffFiles([
+ 'diff --git a/src/a.ts b/src/a.ts',
+ 'index 111..222 100644',
+ '--- a/src/a.ts',
+ '+++ b/src/a.ts',
+ '@@ -1 +1,2 @@',
+ '-old',
+ '+new',
+ '+next',
+ '',
+ ].join('\n'), ['M\tsrc/a.ts']);
+
+ expect(files).toHaveLength(1);
+ expect(files[0]).toMatchObject({ path: 'src/a.ts', status: 'M', additions: 2, deletions: 1 });
+ });
+});
+
+describe('multimodal provider payloads', () => {
+ it('formats image attachments for OpenAI-compatible providers', async () => {
+ const provider = new OpenAiCompatibleProvider({
+ baseUrl: 'https://example.test/v1',
+ model: 'vision-model',
+ });
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ choices: [{ message: { content: 'ok' } }] }),
+ });
+ vi.stubGlobal('fetch', fetchMock);
+
+ try {
+ for await (const _delta of provider.complete({
+ stream: false,
+ messages: [{
+ role: 'user',
+ content: 'what is this?',
+ attachments: [{ kind: 'image', mimeType: 'image/png', data: 'abc123', name: 'screen.png' }],
+ }],
+ })) {
+ // consume
+ }
+ } finally {
+ vi.unstubAllGlobals();
+ }
+
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
+ expect(body.messages[0].content[0]).toEqual({ type: 'text', text: 'what is this?' });
+ expect(body.messages[0].content[1].image_url.url).toBe('data:image/png;base64,abc123');
+ });
+});
+
+describe('headless runner', () => {
+ it('returns deterministic echo answers and JSON plans without VS Code APIs', async () => {
+ const runner = new HeadlessAgentRunner({ cwd: process.cwd(), providerType: 'echo' });
+ await expect(runner.ask('hello')).resolves.toContain('Echo: hello');
+
+ const plan = runner.plan('ship a feature');
+ expect(plan.steps.map((step) => step.id)).toEqual(['discover', 'design', 'execute', 'verify']);
+ });
+});
+
+describe('model capability detection', () => {
+ it('detects vision and reasoning support while respecting overrides', () => {
+ const detected = detectModelCapabilities('openai', 'gpt-4.1', 128_000);
+ expect(detected.supportsVision).toBe(true);
+ expect(detected.supportsReasoning).toBe(false);
+
+ const reasoner = createProvider({
+ type: 'openai-compatible',
+ baseUrl: 'http://localhost:11434/v1',
+ model: 'qwen3-coder:30b',
+ supportsVision: false,
+ supportsReasoning: true,
+ });
+ expect(reasoner.capabilities.supportsReasoning).toBe(true);
+ expect(reasoner.capabilities.supportsVision).toBe(false);
+ });
+});
+
+describe('telemetry webhook emitter', () => {
+ it('posts sanitized events with an HMAC signature header', async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true });
+ vi.stubGlobal('fetch', fetchMock);
+ const emitter = new WebhookEmitter();
+ emitter.configure({ url: 'https://siem.example.test/events', secret: 'secret' });
+
+ try {
+ emitter.emit({
+ ts: 1,
+ time: '2026-07-02 00:00:00.000',
+ sessionId: 's1',
+ type: 'tool_end',
+ message: 'Tool finished',
+ data: { tool: 'read_file' },
+ });
+ await emitter.flush();
+ } finally {
+ vi.unstubAllGlobals();
+ }
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock.mock.calls[0][0]).toBe('https://siem.example.test/events');
+ expect(fetchMock.mock.calls[0][1].headers['X-Mitii-Signature']).toMatch(/^sha256=/);
+ });
+});
From 3df06288c8b246ea69f223685c4caa8214a39b49 Mon Sep 17 00:00:00 2001
From: codewithshinde
Date: Thu, 2 Jul 2026 17:59:11 -0500
Subject: [PATCH 04/12] feat: enhance commit message detection and response
handling
---
benchmark/tasks.json | 6 +++
package-lock.json | 4 +-
package.json | 2 +-
src/core/app/ThunderController.ts | 11 +----
src/core/microtasks/types.ts | 5 ++-
src/core/modes/ask/AskIntentRouter.ts | 5 ++-
src/core/orchestration/ChatOrchestrator.ts | 35 +++++++++++++++-
src/core/safety/ToolExecutor.ts | 15 ++++++-
src/vscode/webview/ThunderWebviewProvider.ts | 2 +
test/microtasks.test.ts | 3 +-
test/unit.test.ts | 42 ++++++++++++++++++++
11 files changed, 111 insertions(+), 19 deletions(-)
diff --git a/benchmark/tasks.json b/benchmark/tasks.json
index 1235074d..9b9089d6 100644
--- a/benchmark/tasks.json
+++ b/benchmark/tasks.json
@@ -5,6 +5,12 @@
"prompt": "Summarize the project structure and identify the main extension entry point.",
"verify": "stdout_contains:Echo:"
},
+ {
+ "id": "staged-commit-message-ask",
+ "mode": "ask",
+ "prompt": "Need commit message for the changes in stage @mitii-ai-agent",
+ "verify": "stdout_contains:Echo:"
+ },
{
"id": "release-plan",
"mode": "plan",
diff --git a/package-lock.json b/package-lock.json
index 84457209..0e8b0a82 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "mitii-ai-agent",
- "version": "2.7.18",
+ "version": "2.7.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mitii-ai-agent",
- "version": "2.7.18",
+ "version": "2.7.19",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"dependencies": {
diff --git a/package.json b/package.json
index 56d8cb7b..46f13c58 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "mitii-ai-agent",
"displayName": "Mitii AI Agent",
"description": "Local-first VS Code AI coding agent with precise repo context and safe Plan/Act workflow",
- "version": "2.7.18",
+ "version": "2.7.19",
"publisher": "mitii",
"icon": "media/mitii-short-logo.png",
"license": "AGPL-3.0-or-later",
diff --git a/src/core/app/ThunderController.ts b/src/core/app/ThunderController.ts
index 7dd3c153..5f504ec8 100644
--- a/src/core/app/ThunderController.ts
+++ b/src/core/app/ThunderController.ts
@@ -895,14 +895,7 @@ export class ThunderController {
const onboardingCompleted = this.context.globalState.get(ONBOARDING_STATE_KEY, false);
const providerConfigured = config.provider.type !== 'echo' || Boolean(apiKey);
- const approvals: ApprovalRequestView[] = (this.approvalQueue?.getPending() ?? []).map((r) => ({
- id: r.id,
- toolName: r.toolName,
- inputPreview: r.inputPreview,
- files: r.files,
- risk: r.risk,
- reason: r.reason,
- }));
+ const approvals: ApprovalRequestView[] = (this.approvalQueue?.getPending() ?? []).map(toApprovalView);
return {
...initialWebviewState(),
@@ -2597,7 +2590,7 @@ export class ThunderController {
}
}
-function toApprovalView(r: import('../safety/ApprovalQueue').ApprovalRequest): ApprovalRequestView {
+export function toApprovalView(r: import('../safety/ApprovalQueue').ApprovalRequest): ApprovalRequestView {
return {
id: r.id,
toolName: r.toolName,
diff --git a/src/core/microtasks/types.ts b/src/core/microtasks/types.ts
index 8898fb0a..fd72f981 100644
--- a/src/core/microtasks/types.ts
+++ b/src/core/microtasks/types.ts
@@ -12,7 +12,9 @@ export interface MicroTaskResult {
}
const MICRO_TASK_PATTERNS: Array<[MicroTaskId, RegExp]> = [
- ['commit_message', /\b(commit message|write commit|git commit)\b/i],
+ ['commit_message', /\b(commit message|commit msg|write commit|git commit)\b/i],
+ ['commit_message', /\b(?:commit|message|subject|summary)\b[\s\S]{0,80}\b(?:staged|stage|cached|git diff)\b/i],
+ ['commit_message', /\b(?:staged|stage|cached|git diff)\b[\s\S]{0,80}\b(?:commit|message|subject|summary)\b/i],
['release_notes_draft', /\b(release notes?|what'?s new)\b/i],
['changelog_entry', /\b(changelog|what changed since)\b/i],
];
@@ -22,4 +24,3 @@ export function detectMicroTask(userMessage: string): MicroTaskId | null {
if (!text) return null;
return MICRO_TASK_PATTERNS.find(([, pattern]) => pattern.test(text))?.[0] ?? null;
}
-
diff --git a/src/core/modes/ask/AskIntentRouter.ts b/src/core/modes/ask/AskIntentRouter.ts
index 9e8bed02..99129cfb 100644
--- a/src/core/modes/ask/AskIntentRouter.ts
+++ b/src/core/modes/ask/AskIntentRouter.ts
@@ -7,7 +7,9 @@ const IMPLEMENT_RE = /\b(how (?:do|would|should) i (?:add|implement|build|create
const DEBUG_RE = /\b(why .+(?:fail|failing|broken|error)|build failing|test failing|root cause|diagnos|debug)\b/i;
const CROSS_PROJECT_RE = /\b(across projects?|between projects?|cross[- ]project|relate to|flow from|agent\s*(?:->|to)\s*docs|docs\s*(?:->|to)\s*agent|extension\s*(?:->|to)\s*website|monorepo)\b/i;
const GENERAL_KNOWLEDGE_RE = /^(what is|what are|define|explain the concept|difference between)\b/i;
-const CODEBASE_RE = /\b(codebase|repo|repository|workspace|project|this app|this extension|this code|our code|src\/|\.tsx?|\.jsx?|\.py|\.go|\.rs|\.mdx?|package\.json)\b/i;
+const CODEBASE_RE = /\b(codebase|repo|repository|workspace|project|this app|this extension|this code|our code|src\/|\.tsx?|\.jsx?|\.py|\.go|\.rs|\.mdx?|package\.json)\b|@[\w./-]+/i;
+const SCM_CONTEXT_RE =
+ /\b(commit message|commit msg|git commit|git diff|working tree|staged changes?|changes? in (?:stage|staging))\b|\b(?:commit|message|subject|summary)\b[\s\S]{0,80}\b(?:staged|stage|cached)\b|\b(?:staged|stage|cached)\b[\s\S]{0,80}\b(?:commit|message|subject|summary)\b/i;
export function routeAskIntent(userMessage: string): AskRoute {
const text = userMessage.trim();
@@ -36,6 +38,7 @@ function classifyAskIntent(text: string): AskIntent {
if (COMPARE_RE.test(text)) return 'compare';
if (ARCHITECTURE_RE.test(text)) return 'architecture';
if (LOCATE_RE.test(text)) return 'locate';
+ if (SCM_CONTEXT_RE.test(text)) return 'explain_code';
if (GENERAL_KNOWLEDGE_RE.test(text) && !CODEBASE_RE.test(text)) return 'general_knowledge';
if (CODEBASE_RE.test(text)) return 'explain_code';
return text.includes('?') ? 'explain_code' : 'general_knowledge';
diff --git a/src/core/orchestration/ChatOrchestrator.ts b/src/core/orchestration/ChatOrchestrator.ts
index 74f9d1d7..fdbe060f 100644
--- a/src/core/orchestration/ChatOrchestrator.ts
+++ b/src/core/orchestration/ChatOrchestrator.ts
@@ -74,6 +74,14 @@ import { detectMicroTask, type MicroTaskExecutor } from '../microtasks';
const log = createLogger('ChatOrchestrator');
+export const EMPTY_ASSISTANT_RESPONSE_MESSAGE =
+ 'I did not receive any response from the model for this turn. Please try again, or switch models if it keeps happening.';
+
+export function normalizeAssistantResponse(fullResponse: string): { content: string; wasEmpty: boolean } {
+ if (fullResponse.trim()) return { content: fullResponse, wasEmpty: false };
+ return { content: EMPTY_ASSISTANT_RESPONSE_MESSAGE, wasEmpty: true };
+}
+
export type ContextPackCallback = (pack: ContextPack, views: ContextItemView[], budget: ContextBudgetView) => void;
export type PlanCallback = (plan: PlanView | null) => void;
export type ActivityCallback = (entry: AgentActivityEntry) => void;
@@ -201,6 +209,14 @@ export class ChatOrchestrator {
});
}
+ private emitEmptyResponse(providerId: string): void {
+ this.emitActivity('error', 'Model returned an empty response', providerId);
+ this.deps.sessionLog?.append('error', 'Model returned an empty response', {
+ provider: providerId,
+ fallbackMessage: EMPTY_ASSISTANT_RESPONSE_MESSAGE,
+ });
+ }
+
private setLiveStatus(
label: string | null,
detail?: string,
@@ -246,7 +262,11 @@ export class ChatOrchestrator {
sessionTiming.start('microtask');
const result = await this.deps.microTaskExecutorFactory(provider).execute(microTaskId, userMessage);
sessionTiming.end('microtask', sessionLog, result.metadata);
- const content = result.content;
+ const normalizedResult = normalizeAssistantResponse(result.content);
+ const content = normalizedResult.content;
+ if (normalizedResult.wasEmpty) {
+ this.emitEmptyResponse(provider.id);
+ }
this.saveTurn(session.id, 'user', userMessage);
const emptyPack = emptyContextPack();
await this.finishTurn(
@@ -1106,6 +1126,13 @@ export class ChatOrchestrator {
}
}
+ const normalizedResponse = normalizeAssistantResponse(fullResponse);
+ if (normalizedResponse.wasEmpty) {
+ fullResponse = normalizedResponse.content;
+ this.emitEmptyResponse(provider.id);
+ yield fullResponse;
+ }
+
await this.finishTurn(
session,
provider,
@@ -1144,7 +1171,11 @@ export class ChatOrchestrator {
const tokens = promptTokens || estimateChatRequestTokens({ messages: usageMessages });
this.emitTurnTokenUsage(tokens, pack, fullResponse, usageMessages, compacted);
- if (!fullResponse) return;
+ const normalizedResponse = normalizeAssistantResponse(fullResponse);
+ fullResponse = normalizedResponse.content;
+ if (normalizedResponse.wasEmpty) {
+ this.emitEmptyResponse(provider.id);
+ }
this.saveTurn(session.id, 'assistant', fullResponse);
this.deps.sessionLog?.append('assistant_message', fullResponse.slice(0, 200), {
diff --git a/src/core/safety/ToolExecutor.ts b/src/core/safety/ToolExecutor.ts
index 1cc4c1d0..870e6f0c 100644
--- a/src/core/safety/ToolExecutor.ts
+++ b/src/core/safety/ToolExecutor.ts
@@ -137,9 +137,22 @@ export class ToolExecutor {
if (policy.decision === 'require_approval') {
if (!this.approvalQueue.hasApprovalGrant(sessionId, resolvedName)) {
- this.approvalQueue.createRequest(sessionId, resolvedName, input, policy, {
+ const request = this.approvalQueue.createRequest(sessionId, resolvedName, input, policy, {
toolCallId: context?.toolCallId,
});
+ this.sessionLog?.append('approval_request', `${request.kind ?? 'approval'}: ${resolvedName}`, {
+ id: request.id,
+ toolName: request.toolName,
+ kind: request.kind,
+ risk: request.risk,
+ reason: request.reason,
+ files: request.files,
+ contentLength: request.contentLength,
+ question: request.question,
+ options: request.options,
+ optionCount: request.options?.length ?? 0,
+ toolCallId: request.toolCallId,
+ });
this.onPendingApproval?.();
this.logRejectedToolCall(resolvedName, input, false, 'Awaiting approval', 'Awaiting approval');
return { success: false, output: '', pendingApproval: true, error: 'Awaiting approval' };
diff --git a/src/vscode/webview/ThunderWebviewProvider.ts b/src/vscode/webview/ThunderWebviewProvider.ts
index d7e714a1..5359aa41 100644
--- a/src/vscode/webview/ThunderWebviewProvider.ts
+++ b/src/vscode/webview/ThunderWebviewProvider.ts
@@ -531,6 +531,7 @@ export class ThunderWebviewProvider implements vscode.WebviewViewProvider {
files: r.files,
risk: r.risk,
reason: r.reason,
+ contentLength: r.contentLength,
kind: r.kind,
question: r.question,
options: r.options,
@@ -651,6 +652,7 @@ export class ThunderWebviewProvider implements vscode.WebviewViewProvider {
files: r.files,
risk: r.risk,
reason: r.reason,
+ contentLength: r.contentLength,
kind: r.kind,
question: r.question,
options: r.options,
diff --git a/test/microtasks.test.ts b/test/microtasks.test.ts
index 7523cbf3..b56be62a 100644
--- a/test/microtasks.test.ts
+++ b/test/microtasks.test.ts
@@ -8,6 +8,8 @@ import type { LlmProvider } from '../src/core/llm/types';
describe('microtasks', () => {
it('detects supported micro-task intents', () => {
expect(detectMicroTask('write commit message please')).toBe('commit_message');
+ expect(detectMicroTask('Need commit message for the changes in stage @mitii-ai-agent')).toBe('commit_message');
+ expect(detectMicroTask('suggest a subject for staged changes')).toBe('commit_message');
expect(detectMicroTask('what changed since v1.2.0')).toBe('changelog_entry');
expect(detectMicroTask("draft what's new")).toBe('release_notes_draft');
expect(detectMicroTask('explain the auth flow')).toBeNull();
@@ -80,4 +82,3 @@ describe('microtasks', () => {
expect(redactSensitiveDiff('+password=abc123')).toBe('+[redacted sensitive line]');
});
});
-
diff --git a/test/unit.test.ts b/test/unit.test.ts
index 2396ee77..b8243e1d 100644
--- a/test/unit.test.ts
+++ b/test/unit.test.ts
@@ -767,6 +767,23 @@ describe('ThunderMode normalization', () => {
});
});
+describe('ChatOrchestrator response handling', () => {
+ it('turns empty model output into an explicit assistant message', async () => {
+ const { EMPTY_ASSISTANT_RESPONSE_MESSAGE, normalizeAssistantResponse } =
+ await import('../src/core/orchestration/ChatOrchestrator');
+
+ expect(normalizeAssistantResponse('')).toEqual({
+ content: EMPTY_ASSISTANT_RESPONSE_MESSAGE,
+ wasEmpty: true,
+ });
+ expect(normalizeAssistantResponse(' ')).toEqual({
+ content: EMPTY_ASSISTANT_RESPONSE_MESSAGE,
+ wasEmpty: true,
+ });
+ expect(normalizeAssistantResponse('ok')).toEqual({ content: 'ok', wasEmpty: false });
+ });
+});
+
describe('Plan parser', () => {
it('flattens rich phase plans into executable steps', async () => {
const { parsePlanFromText } = await import('../src/core/plans/PlanActEngine');
@@ -995,6 +1012,27 @@ describe('fuzzyFileMatch', () => {
});
describe('ApprovalQueue', () => {
+ it('maps clarifying questions with options for persisted UI state', async () => {
+ const { ApprovalQueue } = await import('../src/core/safety/ApprovalQueue');
+ const { toApprovalView } = await import('../src/core/app/ThunderController');
+ const queue = new ApprovalQueue();
+ const req = queue.createRequest('s1', 'ask_question', {
+ question: 'Which project should I inspect?',
+ options: ['agent', 'docs'],
+ }, {
+ decision: 'require_approval',
+ reason: 'Clarifying question requires user response',
+ });
+
+ expect(toApprovalView(req)).toMatchObject({
+ id: req.id,
+ toolName: 'ask_question',
+ kind: 'question',
+ question: 'Which project should I inspect?',
+ options: ['agent', 'docs'],
+ });
+ });
+
it('stores full input for large write_file payloads', async () => {
const { ApprovalQueue } = await import('../src/core/safety/ApprovalQueue');
const queue = new ApprovalQueue();
@@ -2244,6 +2282,10 @@ describe('Ask v2 routing, scope, and impact', () => {
intent: 'general_knowledge',
groundingRequired: false,
});
+ expect(routeAskIntent('Need commit message for the changes in stage @mitii-ai-agent')).toMatchObject({
+ intent: 'explain_code',
+ groundingRequired: true,
+ });
});
it('discovers monorepo projects and persists a catalog file', async () => {
From 62b38efb7cddc50d69098e50ea8e2ab305bb8441 Mon Sep 17 00:00:00 2001
From: codewithshinde
Date: Thu, 2 Jul 2026 19:45:13 -0500
Subject: [PATCH 05/12] feat: Refactor SettingsPanel to manage provider
profiles and autonomy presets
- Removed safety settings from SettingsPanel and integrated autonomy presets.
- Added functionality to save and load provider profiles with associated settings.
- Updated UI components to reflect changes in provider management.
- Introduced loading states for saving settings and testing connections.
- Enhanced workspace settings section with improved layout and statistics display.
- Added new utility functions for handling autonomy presets and their derived safety settings.
- Updated global styles to accommodate new UI elements and loading overlays.
- Added unit tests for new features and refactored existing tests for consistency.
---
.github/workflows/ci.yml | 2 +
.vscodeignore | 1 -
benchmark/README.md | 122 ++++
benchmark/fixtures/nest-api/package.json | 20 +
.../fixtures/nest-api/src/app.controller.ts | 12 +
benchmark/fixtures/nest-api/src/app.module.ts | 10 +
.../fixtures/nest-api/src/app.service.ts | 8 +
benchmark/fixtures/nest-api/src/main.ts | 9 +
benchmark/fixtures/nest-api/tsconfig.json | 13 +
benchmark/fixtures/next-app/app/layout.tsx | 12 +
benchmark/fixtures/next-app/app/page.tsx | 8 +
benchmark/fixtures/next-app/package.json | 16 +
benchmark/fixtures/node-express/package.json | 14 +
benchmark/fixtures/node-express/src/index.js | 17 +
.../fixtures/node-express/src/routes/users.js | 14 +
.../fixtures/node-express/test/users.test.js | 7 +
benchmark/fixtures/react-vite/package.json | 20 +
benchmark/fixtures/react-vite/src/App.tsx | 10 +
.../react-vite/src/components/Button.tsx | 8 +
benchmark/run-benchmark.mjs | 124 ++--
benchmark/tasks.json | 26 -
benchmark/tasks/agent.json | 48 ++
benchmark/tasks/ask.json | 47 ++
benchmark/tasks/index.json | 10 +
benchmark/tasks/integration.json | 12 +
benchmark/tasks/plan.json | 38 ++
benchmark/tasks/regression.json | 74 +++
benchmark/tasks/smoke.json | 26 +
benchmark/verify.mjs | 134 +++++
package-lock.json | 4 +-
package.json | 8 +-
scripts/copy-bundled-skills.mjs | 16 +
scripts/sync-bundled-skills.sh | 10 +-
src/core/app/ThunderController.ts | 131 ++++-
src/core/config/schema.ts | 1 +
src/core/config/ui/payloads.ts | 1 +
src/core/headless/HeadlessAgentHost.ts | 549 ++++++++++++++++++
src/core/headless/HeadlessConfig.ts | 94 +++
.../headless/HeadlessDiagnosticsService.ts | 47 ++
src/core/headless/headlessDiscoverFiles.ts | 58 ++
src/core/headless/index.ts | 2 +
src/core/mcp/builtinServers.ts | 10 +
src/core/mcp/mcpToggles.ts | 6 +-
src/core/plans/promptBuilder.ts | 2 +-
src/core/providers/ProviderProfilesService.ts | 182 ++++++
src/core/rules/ProjectRulesService.ts | 78 +--
.../core/skills/bundled}/README.md | 0
.../skills/bundled}/audit-cleanup/SKILL.md | 0
.../browser-testing-with-devtools/SKILL.md | 39 ++
.../bundled}/code-review-and-quality/SKILL.md | 0
.../code-smells-and-tech-debt/SKILL.md | 0
.../debugging-and-error-recovery/SKILL.md | 0
.../bundled}/environment-and-secrets/SKILL.md | 0
.../git-workflow-and-versioning/SKILL.md | 0
.../performance-optimization/SKILL.md | 0
.../planning-and-task-breakdown/SKILL.md | 0
.../bundled}/test-driven-development/SKILL.md | 0
.../bundled}/using-agent-skills/SKILL.md | 0
src/core/skills/installBundledSkills.ts | 17 +-
src/core/skills/resolveBundledSkillsRoot.ts | 32 +
src/node/cli.ts | 70 ++-
src/node/vscode-shim.ts | 54 ++
src/vscode/webview/ThunderWebviewProvider.ts | 23 +
src/vscode/webview/messages.ts | 24 +
src/webview-ui/src/App.tsx | 21 +-
src/webview-ui/src/components/ChatInput.tsx | 26 +-
.../src/components/SettingsPanel.tsx | 252 ++++----
.../components/WorkspaceSettingsSection.tsx | 138 ++---
src/webview-ui/src/styles/global.css | 71 +++
src/webview-ui/src/utils/autonomyPreset.ts | 68 +++
test/benchmark/benchmark.harness.test.ts | 86 +++
test/benchmark/headless-agent-host.test.ts | 59 ++
test/config-ui-mappers.test.ts | 2 +
test/setup.ts | 30 +
test/unit.test.ts | 37 +-
75 files changed, 2689 insertions(+), 421 deletions(-)
create mode 100644 benchmark/README.md
create mode 100644 benchmark/fixtures/nest-api/package.json
create mode 100644 benchmark/fixtures/nest-api/src/app.controller.ts
create mode 100644 benchmark/fixtures/nest-api/src/app.module.ts
create mode 100644 benchmark/fixtures/nest-api/src/app.service.ts
create mode 100644 benchmark/fixtures/nest-api/src/main.ts
create mode 100644 benchmark/fixtures/nest-api/tsconfig.json
create mode 100644 benchmark/fixtures/next-app/app/layout.tsx
create mode 100644 benchmark/fixtures/next-app/app/page.tsx
create mode 100644 benchmark/fixtures/next-app/package.json
create mode 100644 benchmark/fixtures/node-express/package.json
create mode 100644 benchmark/fixtures/node-express/src/index.js
create mode 100644 benchmark/fixtures/node-express/src/routes/users.js
create mode 100644 benchmark/fixtures/node-express/test/users.test.js
create mode 100644 benchmark/fixtures/react-vite/package.json
create mode 100644 benchmark/fixtures/react-vite/src/App.tsx
create mode 100644 benchmark/fixtures/react-vite/src/components/Button.tsx
delete mode 100644 benchmark/tasks.json
create mode 100644 benchmark/tasks/agent.json
create mode 100644 benchmark/tasks/ask.json
create mode 100644 benchmark/tasks/index.json
create mode 100644 benchmark/tasks/integration.json
create mode 100644 benchmark/tasks/plan.json
create mode 100644 benchmark/tasks/regression.json
create mode 100644 benchmark/tasks/smoke.json
create mode 100644 benchmark/verify.mjs
create mode 100644 scripts/copy-bundled-skills.mjs
create mode 100644 src/core/headless/HeadlessAgentHost.ts
create mode 100644 src/core/headless/HeadlessConfig.ts
create mode 100644 src/core/headless/HeadlessDiagnosticsService.ts
create mode 100644 src/core/headless/headlessDiscoverFiles.ts
create mode 100644 src/core/providers/ProviderProfilesService.ts
rename {bundled-skills => src/core/skills/bundled}/README.md (100%)
rename {bundled-skills => src/core/skills/bundled}/audit-cleanup/SKILL.md (100%)
create mode 100644 src/core/skills/bundled/browser-testing-with-devtools/SKILL.md
rename {bundled-skills => src/core/skills/bundled}/code-review-and-quality/SKILL.md (100%)
rename {bundled-skills => src/core/skills/bundled}/code-smells-and-tech-debt/SKILL.md (100%)
rename {bundled-skills => src/core/skills/bundled}/debugging-and-error-recovery/SKILL.md (100%)
rename {bundled-skills => src/core/skills/bundled}/environment-and-secrets/SKILL.md (100%)
rename {bundled-skills => src/core/skills/bundled}/git-workflow-and-versioning/SKILL.md (100%)
rename {bundled-skills => src/core/skills/bundled}/performance-optimization/SKILL.md (100%)
rename {bundled-skills => src/core/skills/bundled}/planning-and-task-breakdown/SKILL.md (100%)
rename {bundled-skills => src/core/skills/bundled}/test-driven-development/SKILL.md (100%)
rename {bundled-skills => src/core/skills/bundled}/using-agent-skills/SKILL.md (100%)
create mode 100644 src/core/skills/resolveBundledSkillsRoot.ts
create mode 100644 src/node/vscode-shim.ts
create mode 100644 src/webview-ui/src/utils/autonomyPreset.ts
create mode 100644 test/benchmark/benchmark.harness.test.ts
create mode 100644 test/benchmark/headless-agent-host.test.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 287860b9..b9f84f2a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,3 +23,5 @@ jobs:
- run: npm ci
- run: npm run release:check-assets
- run: npm test
+ - run: npm run compile:cli
+ - run: npm run benchmark:smoke
diff --git a/.vscodeignore b/.vscodeignore
index e6d12c89..bd3dd890 100644
--- a/.vscodeignore
+++ b/.vscodeignore
@@ -26,4 +26,3 @@ vitest.config.ts
!dist/**
!media/**
-!bundled-skills/**
diff --git a/benchmark/README.md b/benchmark/README.md
new file mode 100644
index 00000000..1f00a021
--- /dev/null
+++ b/benchmark/README.md
@@ -0,0 +1,122 @@
+# Mitii Enterprise Benchmark
+
+Reproducible evaluation harness for **Ask**, **Plan**, and **Agent** modes across JavaScript/TypeScript stacks (Node, Express, React, NestJS, Next.js).
+
+## Architecture
+
+```text
+benchmark/
+ run-benchmark.mjs # Orchestrator
+ verify.mjs # Verification rules
+ tasks/ # Task definitions by mode + regression
+ fixtures/ # Pinned sample repos
+```
+
+The CLI uses `HeadlessAgentHost`, which wires the **real** production agent pipeline (`ChatOrchestrator`, indexing, tools, skills, MCP) outside VS Code.
+
+| Runtime | When | What runs |
+|---------|------|-----------|
+| `stub` | Smoke / echo | Fast wiring checks via `HeadlessAgentRunner` |
+| `real` | Enterprise eval | Full agent: index, retrieval, tools, session logs |
+
+## Quick start
+
+```bash
+cd mitii-ai-agent
+npm run compile:cli
+npm run benchmark:smoke # CI-friendly (3 tasks, echo/stub)
+npm run benchmark:all # All tasks, real runtime
+```
+
+### Live model evaluation
+
+```bash
+export MITII_API_KEY=...
+node benchmark/run-benchmark.mjs --tier all --runtime real --provider openai-compatible --model gpt-4o-mini
+```
+
+### Browser / Puppeteer lane
+
+```bash
+mitii agent "Verify the home page title" \
+ --runtime real \
+ --enable-puppeteer \
+ --approval auto \
+ --cwd benchmark/fixtures/react-vite
+```
+
+Built-in MCP: `@modelcontextprotocol/server-puppeteer` (toggle via `thunder.mcp.builtinServers.puppeteer` or `--enable-puppeteer`).
+
+## Task tiers
+
+| Tier | File | Focus |
+|------|------|-------|
+| `smoke` | `tasks/smoke.json` | CLI wiring |
+| `ask` | `tasks/ask.json` | Retrieval & Q&A on fixtures |
+| `plan` | `tasks/plan.json` | Structured planning |
+| `agent` | `tasks/agent.json` | Tool loop & guidance |
+| `regression` | `tasks/regression.json` | Fixed bugs from CHANGELOG |
+| `all` | `tasks/index.json` | Everything |
+
+## Fixture repos
+
+| Fixture | Stack |
+|---------|-------|
+| `node-express` | Express API + routes |
+| `react-vite` | React + TypeScript UI |
+| `nest-api` | NestJS modules/controllers |
+| `next-app` | Next.js App Router |
+
+## Verification rules
+
+| Rule | Meaning |
+|------|---------|
+| `exit_0` | CLI exit code 0 |
+| `stdout_contains:` | Output includes text |
+| `stdout_not_empty` | Non-empty stdout |
+| `json_path:` | JSON stdout has key |
+| `jsonl_event:` | Agent stream event |
+| `file_exists:` | File in fixture |
+| `file_contains::` | File content match |
+| `skills_installed:` | `.mitii/skills` count |
+| `command_exit_0:` | Shell command passes |
+| `session_log_has:` | JSONL session event |
+
+## Reports
+
+Written to `.mitii/benchmark/report.json` and `report.md`:
+
+- Pass/fail per task
+- Duration
+- Verification breakdown
+- Score percentage
+
+## Skills in core
+
+Bundled skills live at `src/core/skills/bundled/` (copied to `dist/core/skills/bundled` on compile). Workspace install uses `installBundledSkills()` on init.
+
+## Tests
+
+```bash
+npm test -- test/benchmark
+```
+
+- `benchmark.harness.test.ts` — verify rules, fixtures, skills path
+- `headless-agent-host.test.ts` — stub + real host initialization
+
+## CI
+
+Smoke benchmark runs in GitHub Actions after unit tests (`npm run benchmark:smoke`).
+
+## Regression coverage map
+
+| CHANGELOG area | Benchmark task |
+|----------------|----------------|
+| Verify command discovery | `regression-verify-command-discovery` |
+| Skill-aware planning | `regression-skill-routing-plan` |
+| Empty model response | `regression-empty-response-handling` |
+| Phase-lock / agent loop | `regression-phase-lock-guidance` |
+| Act MCP exclusions | `regression-act-mcp-exclusion-note` |
+| Windows-safe paths | `regression-windows-path-safe` |
+| Micro-task commit msg | `regression-microtask-commit-msg-hint` |
+| Sequential-thinking cap | `regression-sequential-thinking-cap` |
diff --git a/benchmark/fixtures/nest-api/package.json b/benchmark/fixtures/nest-api/package.json
new file mode 100644
index 00000000..49ee7ec6
--- /dev/null
+++ b/benchmark/fixtures/nest-api/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "benchmark-nest-api",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "start": "node dist/main.js",
+ "build": "tsc -p tsconfig.json",
+ "test": "node -e \"console.log('jest placeholder')\"",
+ "lint": "node -e \"console.log('lint ok')\""
+ },
+ "dependencies": {
+ "@nestjs/common": "^10.3.0",
+ "@nestjs/core": "^10.3.0",
+ "reflect-metadata": "^0.2.2",
+ "rxjs": "^7.8.1"
+ },
+ "devDependencies": {
+ "typescript": "^5.5.2"
+ }
+}
diff --git a/benchmark/fixtures/nest-api/src/app.controller.ts b/benchmark/fixtures/nest-api/src/app.controller.ts
new file mode 100644
index 00000000..cce879ee
--- /dev/null
+++ b/benchmark/fixtures/nest-api/src/app.controller.ts
@@ -0,0 +1,12 @@
+import { Controller, Get } from '@nestjs/common';
+import { AppService } from './app.service';
+
+@Controller()
+export class AppController {
+ constructor(private readonly appService: AppService) {}
+
+ @Get()
+ getHello(): string {
+ return this.appService.getHello();
+ }
+}
diff --git a/benchmark/fixtures/nest-api/src/app.module.ts b/benchmark/fixtures/nest-api/src/app.module.ts
new file mode 100644
index 00000000..86628031
--- /dev/null
+++ b/benchmark/fixtures/nest-api/src/app.module.ts
@@ -0,0 +1,10 @@
+import { Module } from '@nestjs/common';
+import { AppController } from './app.controller';
+import { AppService } from './app.service';
+
+@Module({
+ imports: [],
+ controllers: [AppController],
+ providers: [AppService],
+})
+export class AppModule {}
diff --git a/benchmark/fixtures/nest-api/src/app.service.ts b/benchmark/fixtures/nest-api/src/app.service.ts
new file mode 100644
index 00000000..bcf9c059
--- /dev/null
+++ b/benchmark/fixtures/nest-api/src/app.service.ts
@@ -0,0 +1,8 @@
+import { Injectable } from '@nestjs/common';
+
+@Injectable()
+export class AppService {
+ getHello(): string {
+ return 'Hello Nest Benchmark!';
+ }
+}
diff --git a/benchmark/fixtures/nest-api/src/main.ts b/benchmark/fixtures/nest-api/src/main.ts
new file mode 100644
index 00000000..b639ed42
--- /dev/null
+++ b/benchmark/fixtures/nest-api/src/main.ts
@@ -0,0 +1,9 @@
+import { NestFactory } from '@nestjs/core';
+import { AppModule } from './app.module';
+
+async function bootstrap() {
+ const app = await NestFactory.create(AppModule);
+ await app.listen(3001);
+}
+
+void bootstrap();
diff --git a/benchmark/fixtures/nest-api/tsconfig.json b/benchmark/fixtures/nest-api/tsconfig.json
new file mode 100644
index 00000000..bced18cd
--- /dev/null
+++ b/benchmark/fixtures/nest-api/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "module": "commonjs",
+ "target": "ES2020",
+ "experimentalDecorators": true,
+ "emitDecoratorMetadata": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "esModuleInterop": true
+ },
+ "include": ["src/**/*"]
+}
diff --git a/benchmark/fixtures/next-app/app/layout.tsx b/benchmark/fixtures/next-app/app/layout.tsx
new file mode 100644
index 00000000..77517100
--- /dev/null
+++ b/benchmark/fixtures/next-app/app/layout.tsx
@@ -0,0 +1,12 @@
+export const metadata = {
+ title: 'Benchmark Next App',
+ description: 'Mitii benchmark fixture',
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/benchmark/fixtures/next-app/app/page.tsx b/benchmark/fixtures/next-app/app/page.tsx
new file mode 100644
index 00000000..4046cced
--- /dev/null
+++ b/benchmark/fixtures/next-app/app/page.tsx
@@ -0,0 +1,8 @@
+export default function HomePage() {
+ return (
+
+ Next.js Benchmark Home
+ Fixture for Ask, Plan, and Agent benchmarks.
+
+ );
+}
diff --git a/benchmark/fixtures/next-app/package.json b/benchmark/fixtures/next-app/package.json
new file mode 100644
index 00000000..49d3a374
--- /dev/null
+++ b/benchmark/fixtures/next-app/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "benchmark-next-app",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "test": "node -e \"console.log('next test placeholder')\"",
+ "lint": "node -e \"console.log('lint ok')\""
+ },
+ "dependencies": {
+ "next": "^14.2.5",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ }
+}
diff --git a/benchmark/fixtures/node-express/package.json b/benchmark/fixtures/node-express/package.json
new file mode 100644
index 00000000..a27b7f86
--- /dev/null
+++ b/benchmark/fixtures/node-express/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "benchmark-node-express",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "start": "node src/index.js",
+ "test": "node --test test/users.test.js",
+ "lint": "node -e \"console.log('lint ok')\""
+ },
+ "dependencies": {
+ "express": "^4.19.2"
+ }
+}
diff --git a/benchmark/fixtures/node-express/src/index.js b/benchmark/fixtures/node-express/src/index.js
new file mode 100644
index 00000000..56f62462
--- /dev/null
+++ b/benchmark/fixtures/node-express/src/index.js
@@ -0,0 +1,17 @@
+import express from 'express';
+import usersRouter from './routes/users.js';
+
+const app = express();
+app.use(express.json());
+app.use('/users', usersRouter);
+
+app.get('/health', (_req, res) => {
+ res.json({ ok: true });
+});
+
+const port = process.env.PORT || 3000;
+app.listen(port, () => {
+ console.log(`listening on ${port}`);
+});
+
+export default app;
diff --git a/benchmark/fixtures/node-express/src/routes/users.js b/benchmark/fixtures/node-express/src/routes/users.js
new file mode 100644
index 00000000..7b016c0e
--- /dev/null
+++ b/benchmark/fixtures/node-express/src/routes/users.js
@@ -0,0 +1,14 @@
+import { Router } from 'express';
+
+const router = Router();
+
+router.get('/', (_req, res) => {
+ // BUG: wrong message for benchmark agent fix task
+ res.json({ users: [], message: 'pending' });
+});
+
+router.get('/:id', (req, res) => {
+ res.json({ id: req.params.id, name: 'Sample User' });
+});
+
+export default router;
diff --git a/benchmark/fixtures/node-express/test/users.test.js b/benchmark/fixtures/node-express/test/users.test.js
new file mode 100644
index 00000000..5fa21766
--- /dev/null
+++ b/benchmark/fixtures/node-express/test/users.test.js
@@ -0,0 +1,7 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+test('users route module exports router', async () => {
+ const mod = await import('../src/routes/users.js');
+ assert.ok(mod.default);
+});
diff --git a/benchmark/fixtures/react-vite/package.json b/benchmark/fixtures/react-vite/package.json
new file mode 100644
index 00000000..6b1b6c19
--- /dev/null
+++ b/benchmark/fixtures/react-vite/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "benchmark-react-vite",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "test": "node -e \"console.log('vitest placeholder')\"",
+ "lint": "node -e \"console.log('lint ok')\""
+ },
+ "dependencies": {
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "typescript": "^5.5.2",
+ "vite": "^5.3.1"
+ }
+}
diff --git a/benchmark/fixtures/react-vite/src/App.tsx b/benchmark/fixtures/react-vite/src/App.tsx
new file mode 100644
index 00000000..1480282b
--- /dev/null
+++ b/benchmark/fixtures/react-vite/src/App.tsx
@@ -0,0 +1,10 @@
+import Button from './components/Button';
+
+export default function App() {
+ return (
+
+ Benchmark React App
+
+
+ );
+}
diff --git a/benchmark/fixtures/react-vite/src/components/Button.tsx b/benchmark/fixtures/react-vite/src/components/Button.tsx
new file mode 100644
index 00000000..3a5a5b19
--- /dev/null
+++ b/benchmark/fixtures/react-vite/src/components/Button.tsx
@@ -0,0 +1,8 @@
+export interface ButtonProps {
+ label: string;
+ variant?: 'primary';
+}
+
+export default function Button({ label, variant = 'primary' }: ButtonProps) {
+ return ;
+}
diff --git a/benchmark/run-benchmark.mjs b/benchmark/run-benchmark.mjs
index e8ffe0bd..df564fa4 100644
--- a/benchmark/run-benchmark.mjs
+++ b/benchmark/run-benchmark.mjs
@@ -1,93 +1,133 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { spawnSync } from 'child_process';
import { dirname, join, resolve } from 'path';
+import { fileURLToPath } from 'url';
+import { verifyTask, summarizeVerifications } from './verify.mjs';
-const cwd = resolve(process.argv.includes('--cwd') ? process.argv[process.argv.indexOf('--cwd') + 1] : process.cwd());
-const tasksPath = resolve(process.argv.includes('--tasks') ? process.argv[process.argv.indexOf('--tasks') + 1] : 'benchmark/tasks.json');
-const outputPath = resolve(process.argv.includes('--output') ? process.argv[process.argv.indexOf('--output') + 1] : '.mitii/benchmark/report.json');
-const provider = process.argv.includes('--provider') ? process.argv[process.argv.indexOf('--provider') + 1] : 'echo';
-if (!existsSync('dist/cli.js')) {
- const compile = spawnSync('npm', ['run', 'compile:cli'], { cwd, stdio: 'inherit' });
+const benchmarkDir = dirname(fileURLToPath(import.meta.url));
+const packageRoot = resolve(benchmarkDir, '..');
+const args = process.argv.slice(2);
+const cwd = resolve(valueOf(args, '--cwd') ?? process.cwd());
+const tasksPath = resolve(valueOf(args, '--tasks') ?? join(benchmarkDir, 'tasks/index.json'));
+const outputPath = resolve(valueOf(args, '--output') ?? join(cwd, '.mitii/benchmark/report.json'));
+const provider = valueOf(args, '--provider') ?? 'echo';
+const tier = valueOf(args, '--tier') ?? 'smoke';
+const runtime = valueOf(args, '--runtime') ?? (tier === 'smoke' && provider === 'echo' ? 'stub' : 'real');
+const approval = valueOf(args, '--approval') ?? 'auto';
+const enablePuppeteer = args.includes('--enable-puppeteer');
+
+if (!existsSync(join(packageRoot, 'dist/cli.js'))) {
+ const compile = spawnSync('npm', ['run', 'compile:cli'], { cwd: packageRoot, stdio: 'inherit' });
if (compile.status) process.exit(compile.status ?? 1);
}
-const cliPath = 'dist/cli.js';
-const tasks = JSON.parse(readFileSync(tasksPath, 'utf8'));
-const results = tasks.map((task) => runTask(task));
+const cliPath = join(packageRoot, 'dist/cli.js');
+const taskIndex = JSON.parse(readFileSync(tasksPath, 'utf8'));
+const selectedTasks = loadTasks(taskIndex, tier);
+const fixtureRoot = join(packageRoot, 'benchmark/fixtures');
+
+const results = selectedTasks.map((task) => runTask(task, { cliPath, packageRoot, fixtureRoot }));
const passed = results.filter((result) => result.passed).length;
const report = {
cwd,
+ packageRoot,
provider,
+ runtime,
+ tier,
startedAt: new Date().toISOString(),
summary: {
total: results.length,
passed,
failed: results.length - passed,
+ score: results.length ? Math.round((passed / results.length) * 100) : 0,
},
+ verificationSummary: summarizeVerifications(results),
results,
};
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
writeFileSync(outputPath.replace(/\.json$/, '.md'), toMarkdown(report), 'utf8');
-console.log(`${passed}/${results.length} benchmark tasks passed`);
+console.log(`${passed}/${results.length} benchmark tasks passed (${report.summary.score}%)`);
if (passed !== results.length) process.exitCode = 1;
-function runTask(task) {
- const args = [cliPath, task.mode, task.prompt, '--cwd', cwd, '--provider', provider];
- if (task.mode !== 'ask') args.push('--json');
+function loadTasks(index, selectedTier) {
+ const baseDir = dirname(tasksPath);
+ const files = Array.isArray(index.includes) ? index.includes : [index.tasksFile ?? 'tasks.json'];
+ const all = files.flatMap((file) => {
+ const path = resolve(baseDir, file);
+ return JSON.parse(readFileSync(path, 'utf8'));
+ });
+ return all.filter((task) => !task.tier || task.tier === selectedTier || selectedTier === 'all');
+}
+
+function runTask(task, ctx) {
+ const fixtureCwd = task.fixture ? join(ctx.fixtureRoot, task.fixture) : cwd;
+ const extraArgs = [
+ '--cwd', fixtureCwd,
+ '--provider', provider,
+ '--runtime', runtime,
+ '--approval', approval,
+ ];
+ if (enablePuppeteer || task.enablePuppeteer) extraArgs.push('--enable-puppeteer');
+ if (task.model) extraArgs.push('--model', task.model);
+ const taskRuntime = task.runtime ?? runtime;
+ const runtimeIndex = extraArgs.indexOf('--runtime');
+ if (runtimeIndex >= 0) extraArgs[runtimeIndex + 1] = taskRuntime;
+
+ const cliArgs = [ctx.cliPath, task.mode, task.prompt, ...extraArgs];
+ if (task.mode !== 'ask') cliArgs.push('--json');
+
const started = Date.now();
- const result = spawnSync('node', args, { cwd, encoding: 'utf8' });
+ const result = spawnSync('node', cliArgs, { cwd: packageRoot, encoding: 'utf8', env: process.env });
const durationMs = Date.now() - started;
const stdout = result.stdout ?? '';
const stderr = result.stderr ?? '';
- const passed = result.status === 0 && verify(task.verify, stdout);
+ const verifications = (task.verify ?? []).map((rule) => verifyTask(rule, {
+ stdout,
+ stderr,
+ exitCode: result.status ?? 1,
+ cwd: fixtureCwd,
+ packageRoot: ctx.packageRoot,
+ mode: task.mode,
+ }));
+ const passed = result.status === 0 && verifications.every((v) => v.passed);
+
return {
id: task.id,
+ category: task.category ?? 'general',
mode: task.mode,
+ fixture: task.fixture ?? null,
passed,
durationMs,
exitCode: result.status,
+ verifications,
stdout: stdout.slice(0, 4000),
stderr: stderr.slice(0, 2000),
};
}
-function verify(rule, stdout) {
- if (!rule) return true;
- if (rule.startsWith('stdout_contains:')) return stdout.includes(rule.slice('stdout_contains:'.length));
- if (rule.startsWith('json_path:')) {
- const key = rule.slice('json_path:'.length);
- try {
- return Boolean(JSON.parse(stdout)[key]);
- } catch {
- return false;
- }
- }
- if (rule.startsWith('jsonl_event:')) {
- const type = rule.slice('jsonl_event:'.length);
- return stdout.split(/\r?\n/).some((line) => {
- try {
- return JSON.parse(line).type === type;
- } catch {
- return false;
- }
- });
- }
- return false;
+function valueOf(argv, name) {
+ const idx = argv.indexOf(name);
+ return idx >= 0 ? argv[idx + 1] : undefined;
}
function toMarkdown(report) {
return [
- '# Mitii Benchmark Report',
+ '# Mitii Enterprise Benchmark Report',
'',
`Provider: ${report.provider}`,
- `Score: ${report.summary.passed}/${report.summary.total}`,
+ `Runtime: ${report.runtime}`,
+ `Tier: ${report.tier}`,
+ `Score: ${report.summary.passed}/${report.summary.total} (${report.summary.score}%)`,
+ '',
+ '## Verification summary',
+ ...Object.entries(report.verificationSummary).map(([k, v]) => `- ${k}: ${v.passed}/${v.total}`),
'',
- '| Task | Mode | Result | Duration |',
- '|---|---|---|---:|',
+ '| Task | Category | Mode | Fixture | Result | Duration |',
+ '|---|---|---|---|---:|---:|',
...report.results.map((result) =>
- `| ${result.id} | ${result.mode} | ${result.passed ? 'pass' : 'fail'} | ${result.durationMs} ms |`
+ `| ${result.id} | ${result.category} | ${result.mode} | ${result.fixture ?? '-'} | ${result.passed ? 'pass' : 'fail'} | ${result.durationMs} ms |`
),
'',
].join('\n');
diff --git a/benchmark/tasks.json b/benchmark/tasks.json
deleted file mode 100644
index 9b9089d6..00000000
--- a/benchmark/tasks.json
+++ /dev/null
@@ -1,26 +0,0 @@
-[
- {
- "id": "repo-map-summary",
- "mode": "ask",
- "prompt": "Summarize the project structure and identify the main extension entry point.",
- "verify": "stdout_contains:Echo:"
- },
- {
- "id": "staged-commit-message-ask",
- "mode": "ask",
- "prompt": "Need commit message for the changes in stage @mitii-ai-agent",
- "verify": "stdout_contains:Echo:"
- },
- {
- "id": "release-plan",
- "mode": "plan",
- "prompt": "Plan a release hygiene pass covering changelog, README version badge, and media assets.",
- "verify": "json_path:steps"
- },
- {
- "id": "headless-agent-events",
- "mode": "agent",
- "prompt": "Review the current working tree and produce implementation guidance.",
- "verify": "jsonl_event:end"
- }
-]
diff --git a/benchmark/tasks/agent.json b/benchmark/tasks/agent.json
new file mode 100644
index 00000000..8b25385b
--- /dev/null
+++ b/benchmark/tasks/agent.json
@@ -0,0 +1,48 @@
+[
+ {
+ "id": "agent-node-fix-typo",
+ "tier": "agent",
+ "category": "node",
+ "mode": "agent",
+ "fixture": "node-express",
+ "prompt": "Fix the bug in src/routes/users.js where the list endpoint returns the wrong status message. Apply the fix and explain verification.",
+ "verify": ["exit_0", "jsonl_event:end", "file_contains:src/routes/users.js:success"]
+ },
+ {
+ "id": "agent-react-button-variant",
+ "tier": "agent",
+ "category": "react",
+ "mode": "agent",
+ "fixture": "react-vite",
+ "prompt": "Add a secondary variant to Button.tsx and use it in App.tsx. Provide a verification checklist.",
+ "verify": ["exit_0", "jsonl_event:end", "file_exists:src/App.tsx"]
+ },
+ {
+ "id": "agent-nest-add-dto",
+ "tier": "agent",
+ "category": "nestjs",
+ "mode": "agent",
+ "fixture": "nest-api",
+ "prompt": "Introduce a CreateUserDto and wire validation in AppController.",
+ "verify": ["exit_0", "jsonl_event:end", "file_exists:src/app.controller.ts"]
+ },
+ {
+ "id": "agent-next-loading-ui",
+ "tier": "agent",
+ "category": "nextjs",
+ "mode": "agent",
+ "fixture": "next-app",
+ "prompt": "Add a loading.tsx for the home route and document how to verify it in dev.",
+ "verify": ["exit_0", "jsonl_event:end", "file_exists:app/page.tsx"]
+ },
+ {
+ "id": "agent-browser-skill-available",
+ "tier": "agent",
+ "category": "browser",
+ "mode": "agent",
+ "fixture": "react-vite",
+ "enablePuppeteer": false,
+ "prompt": "List available Mitii skills for browser testing and when to use puppeteer MCP.",
+ "verify": ["exit_0", "jsonl_event:end", "skills_installed:10"]
+ }
+]
diff --git a/benchmark/tasks/ask.json b/benchmark/tasks/ask.json
new file mode 100644
index 00000000..f18dea2d
--- /dev/null
+++ b/benchmark/tasks/ask.json
@@ -0,0 +1,47 @@
+[
+ {
+ "id": "ask-node-entry",
+ "tier": "ask",
+ "category": "node",
+ "mode": "ask",
+ "fixture": "node-express",
+ "prompt": "Explain how requests flow through this Express app. Where is the users router mounted?",
+ "verify": ["exit_0", "stdout_not_empty", "file_exists:src/index.js"]
+ },
+ {
+ "id": "ask-react-component",
+ "tier": "ask",
+ "category": "react",
+ "mode": "ask",
+ "fixture": "react-vite",
+ "prompt": "What does the Button component render and what props does it accept?",
+ "verify": ["exit_0", "stdout_not_empty", "file_exists:src/components/Button.tsx"]
+ },
+ {
+ "id": "ask-nest-module",
+ "tier": "ask",
+ "category": "nestjs",
+ "mode": "ask",
+ "fixture": "nest-api",
+ "prompt": "Describe the NestJS module structure and where AppController is registered.",
+ "verify": ["exit_0", "stdout_not_empty", "file_exists:src/app.module.ts"]
+ },
+ {
+ "id": "ask-next-page",
+ "tier": "ask",
+ "category": "nextjs",
+ "mode": "ask",
+ "fixture": "next-app",
+ "prompt": "What is rendered on the home page and how is the root layout structured?",
+ "verify": ["exit_0", "stdout_not_empty", "file_exists:app/page.tsx"]
+ },
+ {
+ "id": "ask-retrieval-users-route",
+ "tier": "ask",
+ "category": "node",
+ "mode": "ask",
+ "fixture": "node-express",
+ "prompt": "Find the route handler for GET /users and summarize what it returns.",
+ "verify": ["exit_0", "stdout_not_empty", "file_contains:src/routes/users.js:router.get"]
+ }
+]
diff --git a/benchmark/tasks/index.json b/benchmark/tasks/index.json
new file mode 100644
index 00000000..ad3da778
--- /dev/null
+++ b/benchmark/tasks/index.json
@@ -0,0 +1,10 @@
+{
+ "includes": [
+ "smoke.json",
+ "integration.json",
+ "ask.json",
+ "plan.json",
+ "agent.json",
+ "regression.json"
+ ]
+}
diff --git a/benchmark/tasks/integration.json b/benchmark/tasks/integration.json
new file mode 100644
index 00000000..bf23045e
--- /dev/null
+++ b/benchmark/tasks/integration.json
@@ -0,0 +1,12 @@
+[
+ {
+ "id": "integration-real-ask-node",
+ "tier": "integration",
+ "category": "ask",
+ "mode": "ask",
+ "fixture": "node-express",
+ "runtime": "real",
+ "prompt": "Where is the Express app created and what port does it listen on?",
+ "verify": ["exit_0", "stdout_not_empty", "skills_installed:10"]
+ }
+]
diff --git a/benchmark/tasks/plan.json b/benchmark/tasks/plan.json
new file mode 100644
index 00000000..0fb0f8fa
--- /dev/null
+++ b/benchmark/tasks/plan.json
@@ -0,0 +1,38 @@
+[
+ {
+ "id": "plan-react-test-coverage",
+ "tier": "plan",
+ "category": "react",
+ "mode": "plan",
+ "fixture": "react-vite",
+ "prompt": "Plan adding unit tests for the Button component and updating package.json scripts.",
+ "verify": ["exit_0", "json_path:steps", "file_exists:package.json"]
+ },
+ {
+ "id": "plan-nest-health-endpoint",
+ "tier": "plan",
+ "category": "nestjs",
+ "mode": "plan",
+ "fixture": "nest-api",
+ "prompt": "Plan adding a /health endpoint with controller, service, and e2e test.",
+ "verify": ["exit_0", "json_path:steps", "file_exists:src/main.ts"]
+ },
+ {
+ "id": "plan-next-seo-metadata",
+ "tier": "plan",
+ "category": "nextjs",
+ "mode": "plan",
+ "fixture": "next-app",
+ "prompt": "Plan improving SEO metadata in app/layout.tsx and documenting verification steps.",
+ "verify": ["exit_0", "json_path:steps", "file_exists:app/layout.tsx"]
+ },
+ {
+ "id": "plan-node-error-middleware",
+ "tier": "plan",
+ "category": "node",
+ "mode": "plan",
+ "fixture": "node-express",
+ "prompt": "Plan adding centralized error middleware and updating the users route tests.",
+ "verify": ["exit_0", "json_path:steps", "file_contains:src/index.js:express"]
+ }
+]
diff --git a/benchmark/tasks/regression.json b/benchmark/tasks/regression.json
new file mode 100644
index 00000000..a93d1a70
--- /dev/null
+++ b/benchmark/tasks/regression.json
@@ -0,0 +1,74 @@
+[
+ {
+ "id": "regression-verify-command-discovery",
+ "tier": "regression",
+ "category": "verify",
+ "mode": "ask",
+ "fixture": "node-express",
+ "prompt": "What npm scripts exist in this project for test and lint verification?",
+ "verify": ["exit_0", "stdout_not_empty", "file_contains:package.json:test"]
+ },
+ {
+ "id": "regression-skill-routing-plan",
+ "tier": "regression",
+ "category": "skills",
+ "mode": "plan",
+ "fixture": "react-vite",
+ "prompt": "Plan a test-driven development pass to add tests for the Button component.",
+ "verify": ["exit_0", "json_path:steps", "skills_installed:10"]
+ },
+ {
+ "id": "regression-empty-response-handling",
+ "tier": "regression",
+ "category": "orchestrator",
+ "mode": "ask",
+ "fixture": "nest-api",
+ "prompt": "Summarize AppModule providers without making anything up.",
+ "verify": ["exit_0", "stdout_not_empty"]
+ },
+ {
+ "id": "regression-phase-lock-guidance",
+ "tier": "regression",
+ "category": "agent-loop",
+ "mode": "agent",
+ "fixture": "node-express",
+ "prompt": "Review src/index.js and propose safe read-only inspection steps before any edits.",
+ "verify": ["exit_0", "jsonl_event:end"]
+ },
+ {
+ "id": "regression-act-mcp-exclusion-note",
+ "tier": "regression",
+ "category": "mcp",
+ "mode": "agent",
+ "fixture": "next-app",
+ "prompt": "Explain which tools should be used for file edits in Agent mode vs MCP filesystem tools.",
+ "verify": ["exit_0", "jsonl_event:end"]
+ },
+ {
+ "id": "regression-windows-path-safe",
+ "tier": "regression",
+ "category": "paths",
+ "mode": "ask",
+ "fixture": "react-vite",
+ "prompt": "List source files under src/ using workspace-relative paths.",
+ "verify": ["exit_0", "stdout_not_empty", "dir_has_files:src"]
+ },
+ {
+ "id": "regression-microtask-commit-msg-hint",
+ "tier": "regression",
+ "category": "microtasks",
+ "mode": "ask",
+ "fixture": "node-express",
+ "prompt": "Need commit message for staged changes in src/routes/users.js",
+ "verify": ["exit_0", "stdout_not_empty"]
+ },
+ {
+ "id": "regression-sequential-thinking-cap",
+ "tier": "regression",
+ "category": "mcp",
+ "mode": "plan",
+ "fixture": "nest-api",
+ "prompt": "Plan debugging steps for a failing health check endpoint using structured reasoning.",
+ "verify": ["exit_0", "json_path:steps"]
+ }
+]
diff --git a/benchmark/tasks/smoke.json b/benchmark/tasks/smoke.json
new file mode 100644
index 00000000..b8c31e36
--- /dev/null
+++ b/benchmark/tasks/smoke.json
@@ -0,0 +1,26 @@
+[
+ {
+ "id": "smoke-ask-echo",
+ "tier": "smoke",
+ "category": "infrastructure",
+ "mode": "ask",
+ "prompt": "Summarize the project structure and identify the main entry point.",
+ "verify": ["exit_0", "stdout_contains:Echo:"]
+ },
+ {
+ "id": "smoke-plan-echo",
+ "tier": "smoke",
+ "category": "infrastructure",
+ "mode": "plan",
+ "prompt": "Plan a release hygiene pass covering changelog, README, and tests.",
+ "verify": ["exit_0", "json_path:steps"]
+ },
+ {
+ "id": "smoke-agent-echo",
+ "tier": "smoke",
+ "category": "infrastructure",
+ "mode": "agent",
+ "prompt": "Review the working tree and produce implementation guidance.",
+ "verify": ["exit_0", "jsonl_event:end"]
+ }
+]
diff --git a/benchmark/verify.mjs b/benchmark/verify.mjs
new file mode 100644
index 00000000..a3c116fc
--- /dev/null
+++ b/benchmark/verify.mjs
@@ -0,0 +1,134 @@
+import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
+import { join } from 'path';
+import { spawnSync } from 'child_process';
+
+export function verifyTask(rule, ctx) {
+ try {
+ if (typeof rule === 'string') {
+ return verifyRule(rule, ctx);
+ }
+ if (rule.all) {
+ const results = rule.all.map((r) => verifyRule(r, ctx));
+ return {
+ rule: 'all',
+ passed: results.every((r) => r.passed),
+ details: results,
+ };
+ }
+ if (rule.any) {
+ const results = rule.any.map((r) => verifyRule(r, ctx));
+ return {
+ rule: 'any',
+ passed: results.some((r) => r.passed),
+ details: results,
+ };
+ }
+ return { rule: 'unknown', passed: false, details: 'Unsupported verify shape' };
+ } catch (error) {
+ return {
+ rule: String(rule),
+ passed: false,
+ details: error instanceof Error ? error.message : String(error),
+ };
+ }
+}
+
+function verifyRule(rule, ctx) {
+ if (rule === 'exit_0') {
+ return { rule, passed: ctx.exitCode === 0 };
+ }
+ if (rule.startsWith('stdout_contains:')) {
+ const text = rule.slice('stdout_contains:'.length);
+ return { rule, passed: ctx.stdout.includes(text) };
+ }
+ if (rule.startsWith('stdout_not_empty')) {
+ return { rule, passed: ctx.stdout.trim().length > 0 };
+ }
+ if (rule.startsWith('json_path:')) {
+ const key = rule.slice('json_path:'.length);
+ try {
+ return { rule, passed: Boolean(JSON.parse(ctx.stdout)[key]) };
+ } catch {
+ return { rule, passed: false, details: 'Invalid JSON stdout' };
+ }
+ }
+ if (rule.startsWith('jsonl_event:')) {
+ const type = rule.slice('jsonl_event:'.length);
+ const found = ctx.stdout.split(/\r?\n/).some((line) => {
+ try {
+ return JSON.parse(line).type === type;
+ } catch {
+ return false;
+ }
+ });
+ return { rule, passed: found };
+ }
+ if (rule.startsWith('file_exists:')) {
+ const rel = rule.slice('file_exists:'.length);
+ return { rule, passed: existsSync(join(ctx.cwd, rel)) };
+ }
+ if (rule.startsWith('file_contains:')) {
+ const [rel, ...needleParts] = rule.slice('file_contains:'.length).split(':');
+ const needle = needleParts.join(':');
+ const path = join(ctx.cwd, rel);
+ if (!existsSync(path)) return { rule, passed: false, details: `Missing ${rel}` };
+ return { rule, passed: readFileSync(path, 'utf8').includes(needle) };
+ }
+ if (rule.startsWith('dir_has_files:')) {
+ const rel = rule.slice('dir_has_files:'.length);
+ const path = join(ctx.cwd, rel);
+ if (!existsSync(path)) return { rule, passed: false };
+ const count = readdirSync(path).filter((f) => statSync(join(path, f)).isFile()).length;
+ return { rule, passed: count > 0, details: `${count} files` };
+ }
+ if (rule.startsWith('skills_installed:')) {
+ const min = Number(rule.slice('skills_installed:'.length) || '1');
+ const skillsDir = join(ctx.cwd, '.mitii', 'skills');
+ if (!existsSync(skillsDir)) return { rule, passed: false };
+ const count = readdirSync(skillsDir).filter((entry) => existsSync(join(skillsDir, entry, 'SKILL.md'))).length;
+ return { rule, passed: count >= min, details: `${count} skills` };
+ }
+ if (rule.startsWith('command_exit_0:')) {
+ const command = rule.slice('command_exit_0:'.length);
+ const result = spawnSync(command, { cwd: ctx.cwd, shell: true, encoding: 'utf8' });
+ return { rule, passed: result.status === 0, details: (result.stderr || result.stdout || '').slice(0, 500) };
+ }
+ if (rule.startsWith('session_log_has:')) {
+ const eventType = rule.slice('session_log_has:'.length);
+ const logsDir = join(ctx.cwd, '.mitii', 'logs');
+ if (!existsSync(logsDir)) return { rule, passed: false };
+ const files = readdirSync(logsDir).filter((f) => f.endsWith('.jsonl')).sort();
+ const last = files[files.length - 1];
+ if (!last) return { rule, passed: false };
+ const content = readFileSync(join(logsDir, last), 'utf8');
+ const found = content.split('\n').some((line) => {
+ try {
+ return JSON.parse(line).type === eventType;
+ } catch {
+ return false;
+ }
+ });
+ return { rule, passed: found };
+ }
+ if (rule.startsWith('tool_registered:')) {
+ const toolName = rule.slice('tool_registered:'.length);
+ const cliCheck = spawnSync('node', ['-e', `
+ const { HeadlessAgentHost } = require('${join(ctx.packageRoot, 'dist/cli.js').replace(/'/g, "\\'")}');
+ `], { encoding: 'utf8' });
+ return { rule, passed: ctx.stdout.includes(toolName) || cliCheck.status === 0, details: 'tool check deferred to host tests' };
+ }
+ return { rule, passed: false, details: `Unknown rule: ${rule}` };
+}
+
+export function summarizeVerifications(results) {
+ const summary = {};
+ for (const result of results) {
+ for (const verification of result.verifications ?? []) {
+ const key = typeof verification.rule === 'string' ? verification.rule.split(':')[0] : verification.rule;
+ if (!summary[key]) summary[key] = { passed: 0, total: 0 };
+ summary[key].total += 1;
+ if (verification.passed) summary[key].passed += 1;
+ }
+ }
+ return summary;
+}
diff --git a/package-lock.json b/package-lock.json
index 0e8b0a82..0222b366 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "mitii-ai-agent",
- "version": "2.7.19",
+ "version": "2.7.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mitii-ai-agent",
- "version": "2.7.19",
+ "version": "2.7.20",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"dependencies": {
diff --git a/package.json b/package.json
index 46f13c58..6cec0406 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "mitii-ai-agent",
"displayName": "Mitii AI Agent",
"description": "Local-first VS Code AI coding agent with precise repo context and safe Plan/Act workflow",
- "version": "2.7.19",
+ "version": "2.7.20",
"publisher": "mitii",
"icon": "media/mitii-short-logo.png",
"license": "AGPL-3.0-or-later",
@@ -621,9 +621,13 @@
"release:check-assets": "node scripts/check-release-assets.mjs",
"release:prepare": "npm run compile:cli && node dist/cli.js prepare-release",
"benchmark": "node benchmark/run-benchmark.mjs",
+ "benchmark:smoke": "node benchmark/run-benchmark.mjs --tier smoke",
+ "benchmark:all": "node benchmark/run-benchmark.mjs --tier all --runtime real",
+ "benchmark:integration": "node benchmark/run-benchmark.mjs --tier integration --runtime real",
"compile:extension": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --packages=external --platform=node --format=cjs --sourcemap",
"compile:webview": "vite build",
- "compile:cli": "esbuild src/node/cli.ts --bundle --outfile=dist/cli.js --platform=node --format=cjs --packages=external",
+ "compile:skills": "node scripts/copy-bundled-skills.mjs",
+ "compile:cli": "npm run compile:skills && esbuild src/node/cli.ts --bundle --outfile=dist/cli.js --platform=node --format=cjs --packages=external --alias:vscode=./src/node/vscode-shim.ts",
"watch": "concurrently \"npm run watch:extension\" \"npm run watch:webview\"",
"watch:extension": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --packages=external --platform=node --format=cjs --sourcemap --watch",
"watch:webview": "vite build --watch",
diff --git a/scripts/copy-bundled-skills.mjs b/scripts/copy-bundled-skills.mjs
new file mode 100644
index 00000000..e55fcc72
--- /dev/null
+++ b/scripts/copy-bundled-skills.mjs
@@ -0,0 +1,16 @@
+import { cpSync, existsSync, mkdirSync } from 'fs';
+import { dirname, join } from 'path';
+import { fileURLToPath } from 'url';
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..');
+const source = join(root, 'src/core/skills/bundled');
+const dest = join(root, 'dist/core/skills/bundled');
+
+if (!existsSync(source)) {
+ console.error('Missing bundled skills source:', source);
+ process.exit(1);
+}
+
+mkdirSync(dirname(dest), { recursive: true });
+cpSync(source, dest, { recursive: true, force: true });
+console.log('Copied bundled skills to', dest);
diff --git a/scripts/sync-bundled-skills.sh b/scripts/sync-bundled-skills.sh
index 62d03406..94d5fd45 100755
--- a/scripts/sync-bundled-skills.sh
+++ b/scripts/sync-bundled-skills.sh
@@ -1,9 +1,9 @@
#!/usr/bin/env bash
-# Maintainer utility: refresh bundled-skills/ from a local checkout (no runtime git pull in the extension).
+# Maintainer utility: refresh src/core/skills/bundled/ from a local checkout (no runtime git pull in the extension).
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
-DEST_DIR="${MITII_BUNDLED_SKILLS_DIR:-$ROOT_DIR/bundled-skills}"
+DEST_DIR="${MITII_BUNDLED_SKILLS_DIR:-$ROOT_DIR/src/core/skills/bundled}"
SOURCE_DIR="${AGENT_SKILLS_SOURCE_DIR:-}"
usage() {
@@ -14,8 +14,8 @@ Usage:
AGENT_SKILLS_SOURCE_DIR=/path/to/agent-skills/skills bash scripts/sync-bundled-skills.sh
bash scripts/sync-bundled-skills.sh /path/to/agent-skills/skills
-Copies the seven Tier-1 SKILL.md folders from addyosmani/agent-skills into bundled-skills/.
-Mitii-owned skills (e.g. audit-cleanup) live in bundled-skills/ and are not overwritten.
+Copies the seven Tier-1 SKILL.md folders from addyosmani/agent-skills into src/core/skills/bundled/.
+Mitii-owned skills (e.g. audit-cleanup) live in src/core/skills/bundled/ and are not overwritten.
Does not run at extension runtime — commit the result and ship it in the VSIX.
EOF
}
@@ -58,4 +58,4 @@ for skill in "${SKILLS[@]}"; do
echo "Synced $skill"
done
-echo "Done. bundled-skills now contains $(find "$DEST_DIR" -name SKILL.md | wc -l | tr -d ' ') skill(s)."
+echo "Done. src/core/skills/bundled now contains $(find "$DEST_DIR" -name SKILL.md | wc -l | tr -d ' ') skill(s)."
diff --git a/src/core/app/ThunderController.ts b/src/core/app/ThunderController.ts
index 5f504ec8..28066392 100644
--- a/src/core/app/ThunderController.ts
+++ b/src/core/app/ThunderController.ts
@@ -67,6 +67,10 @@ import { isLanceDbAvailable, isMinilmAvailable } from '../indexing/vectorAvailab
import type { EmbeddingProvider } from '../indexing/EmbeddingProvider';
import { McpManager } from '../mcp/McpManager';
import { ProjectRulesContextSource, ProjectRulesService } from '../rules/ProjectRulesService';
+import {
+ ProviderProfilesService,
+ providerSecretRef,
+} from '../providers/ProviderProfilesService';
import { SkillCatalogContextSource, SkillCatalogService } from '../skills/SkillCatalogService';
import { InlineDiffManager } from '../../vscode/inlineDiffManager';
import { testProviderConnection } from '../llm/testConnection';
@@ -148,6 +152,7 @@ export class ThunderController {
private embeddingProvider: EmbeddingProvider | undefined;
private mcpManager = new McpManager();
private projectRulesService: ProjectRulesService | undefined;
+ private providerProfilesService: ProviderProfilesService | undefined;
private skillCatalogService: SkillCatalogService | undefined;
private inlineDiffManager: InlineDiffManager | undefined;
private researchAgentProvider: LlmProvider | undefined;
@@ -184,6 +189,7 @@ export class ThunderController {
breakdown: [] as import('../../vscode/webview/messages').TokenUsageBreakdownItem[],
};
private uiUpdate: UiUpdateCallback | undefined;
+ private preservedUiGetter: (() => Partial) | undefined;
private autoFixCallback: ((message: string) => Promise) | undefined;
private autoFixDepth = 0;
private disposed = false;
@@ -197,6 +203,8 @@ export class ThunderController {
private pendingIndexStatus: IndexingStatus | undefined;
private tokenUsageNotifyTimer: ReturnType | undefined;
private pendingTokenUsage: TokenUsageView | undefined;
+ private settingsSaving = false;
+ private testingConnection = false;
constructor(private readonly context: vscode.ExtensionContext) {
this.configService = new ConfigService(context);
@@ -224,6 +232,22 @@ export class ThunderController {
this.uiUpdate = cb;
}
+ setPreservedUiGetter(getter: () => Partial): void {
+ this.preservedUiGetter = getter;
+ }
+
+ private getPreservedUiBase(): Partial {
+ const preserved = this.preservedUiGetter?.() ?? {};
+ return {
+ tab: preserved.tab,
+ mode: preserved.mode,
+ messages: preserved.messages,
+ currentSessionId: preserved.currentSessionId,
+ chatHistory: preserved.chatHistory,
+ loading: preserved.loading,
+ };
+ }
+
setAutoFixCallback(cb: (message: string) => Promise): void {
this.autoFixCallback = cb;
}
@@ -412,6 +436,7 @@ export class ThunderController {
this.diagnosticsService.setWorkspaceRoot(workspace);
scaffoldMitiiWorkspace(workspace, { extensionRoot: this.context.extensionPath });
this.projectRulesService = new ProjectRulesService(workspace);
+ this.providerProfilesService = new ProviderProfilesService(workspace);
this.skillCatalogService = new SkillCatalogService(workspace);
this.skillCatalogService.refresh();
const retriever = new HybridRetriever(
@@ -484,6 +509,7 @@ export class ThunderController {
this.diagnosticsService.setWorkspaceRoot(workspace);
this.projectRulesService = new ProjectRulesService(workspace);
+ this.providerProfilesService = new ProviderProfilesService(workspace);
this.skillCatalogService = new SkillCatalogService(workspace);
this.skillCatalogService.refresh();
this.memoryService = new MemoryService(db, workspace, {
@@ -932,7 +958,7 @@ export class ThunderController {
...this.tokenUsage,
contextWindow: config.provider.contextWindow,
},
- mode: this.session?.mode ?? 'plan',
+ mode: base.mode ?? this.session?.mode ?? 'plan',
indexing: this.indexingStatus,
approvals,
plan: this.currentPlan,
@@ -1023,6 +1049,8 @@ export class ThunderController {
checkpointStrategy: config.agent.checkpointStrategy,
showReasoning: config.ui.showReasoning,
reasoningPreviewMaxChars: config.ui.reasoningPreviewMaxChars,
+ providerProfiles: this.providerProfilesService?.list() ?? [],
+ activeProviderProfileId: this.providerProfilesService?.getActiveId() ?? null,
},
contextToggles: this.contextToggles,
mcpToggles: this.mcpToggles,
@@ -1035,6 +1063,8 @@ export class ThunderController {
indexDbPath,
workspaceNotice: this.workspaceNotice,
workspaceTrusted: this.isWorkspaceTrusted(),
+ settingsSaving: this.settingsSaving,
+ testingConnection: this.testingConnection,
};
}
@@ -1695,6 +1725,7 @@ export class ThunderController {
this.scanner = undefined;
this.indexQueue = undefined;
this.projectRulesService = undefined;
+ this.providerProfilesService = undefined;
this.indexingStatus = { indexed: 0, queued: 0, running: false, failed: 0, total: 0, activeWorkers: 0, processed: 0, runTotal: 0 };
await this.mcpManager.closeAll();
this.toolRuntime.unregisterByPrefix('mcp__');
@@ -1713,7 +1744,7 @@ export class ThunderController {
}
}
- this.notifyUi(await this.buildUiState());
+ this.notifyUi(await this.buildUiState(this.getPreservedUiBase()));
log.info('Workspace reloaded', { workspace });
if (workspace && options.autoIndex !== false) {
void this.maybeAutoIndex();
@@ -2173,6 +2204,9 @@ export class ThunderController {
}
async testProviderConnection(settings?: ProviderSettingsPayload): Promise {
+ this.testingConnection = true;
+ this.notifyUi({ testingConnection: true });
+ try {
const config = this.configService.getConfig();
const apiKey = await this.configService.getApiKey();
const providerType = settings?.providerType ?? config.provider.type;
@@ -2210,6 +2244,7 @@ export class ThunderController {
connectionOk: false,
connectionStatus: validation.errors.join(' '),
},
+ testingConnection: false,
});
return;
}
@@ -2227,6 +2262,7 @@ export class ThunderController {
connectionOk: true,
connectionStatus: 'Echo mode — no LLM needed. Responses are mirrored for UI testing.',
},
+ testingConnection: false,
});
return;
}
@@ -2252,11 +2288,16 @@ export class ThunderController {
connectionOk: result.ok,
connectionStatus: result.message,
},
+ testingConnection: false,
});
if (!result.ok) {
void vscode.window.showErrorMessage(`${AGENT_NAME}: ${result.message}`);
}
+ } finally {
+ this.testingConnection = false;
+ this.notifyUi({ testingConnection: false });
+ }
}
async saveApiKey(key: string): Promise {
@@ -2332,6 +2373,9 @@ export class ThunderController {
}
async saveAllSettings(settings: ThunderSettingsPayload): Promise {
+ this.settingsSaving = true;
+ this.notifyUi({ settingsSaving: true });
+ try {
const beforeConfig = this.configService.getConfig();
const normalized = normalizeThunderSettings(settings, beforeConfig.provider.contextWindow, this.mcpToggles);
@@ -2389,6 +2433,88 @@ export class ThunderController {
});
void vscode.window.showInformationMessage(brandMessage('Settings saved.'));
}
+ } finally {
+ this.settingsSaving = false;
+ this.notifyUi({
+ ...(await this.buildUiState(this.getPreservedUiBase())),
+ settingsSaving: false,
+ });
+ }
+ }
+
+ async saveProviderProfile(options: {
+ id?: string;
+ name?: string;
+ settings: ProviderSettingsPayload;
+ apiKey?: string;
+ }): Promise {
+ const workspace = this.resolveWorkspacePath();
+ if (!workspace) {
+ throw new Error('Open a workspace to save provider profiles under .mitii/providers.');
+ }
+ const validation = validateProviderSettings(options.settings);
+ if (!validation.ok) {
+ throw new Error(validation.errors.join(' '));
+ }
+
+ if (!this.providerProfilesService) {
+ this.providerProfilesService = new ProviderProfilesService(workspace);
+ }
+
+ const profile = this.providerProfilesService.upsert(options.settings, {
+ id: options.id,
+ name: options.name,
+ apiKey: options.apiKey,
+ });
+
+ if (options.apiKey?.trim()) {
+ await this.configService.setApiKey(options.apiKey.trim(), providerSecretRef(profile.id));
+ }
+
+ await this.applyProviderProfile(profile.id);
+ }
+
+ async selectProviderProfile(id: string): Promise {
+ await this.applyProviderProfile(id);
+ }
+
+ async deleteProviderProfile(id: string): Promise {
+ const workspace = this.resolveWorkspacePath();
+ if (!workspace || !this.providerProfilesService) return;
+ this.providerProfilesService.delete(id);
+ await this.configService.deleteApiKey(providerSecretRef(id));
+ this.notifyUi({ settings: (await this.buildUiState(this.getPreservedUiBase())).settings });
+ }
+
+ private async applyProviderProfile(id: string): Promise {
+ const workspace = this.resolveWorkspacePath();
+ if (!workspace) return;
+
+ if (!this.providerProfilesService) {
+ this.providerProfilesService = new ProviderProfilesService(workspace);
+ }
+
+ const profile = this.providerProfilesService.setActive(id);
+ if (!profile) return;
+
+ await this.configService.updateProviderSettings({
+ providerType: profile.providerType,
+ baseUrl: profile.baseUrl,
+ model: profile.model,
+ apiVersion: profile.apiVersion,
+ region: profile.region,
+ contextWindow: profile.contextWindow,
+ });
+
+ const config = this.configService.getConfig();
+ const apiKey = profile.hasApiKey
+ ? await this.configService.getApiKey(providerSecretRef(profile.id))
+ : await this.configService.getApiKey();
+ await this.providerRegistry.resolveFromConfig(config.provider, apiKey);
+ await this.refreshResearchAgentProvider();
+ this.chatOrchestrator?.configure({ researchAgentProvider: this.researchAgentProvider });
+ this.debouncedRebuildRetriever?.();
+ this.notifyUi({ settings: (await this.buildUiState(this.getPreservedUiBase())).settings });
}
private async reloadMcpServers(): Promise {
@@ -2404,6 +2530,7 @@ export class ThunderController {
filesystem: builtin.filesystem,
memory: builtin.memory,
sequentialThinking: builtin.sequentialThinking,
+ puppeteer: builtin.puppeteer ?? false,
};
}
diff --git a/src/core/config/schema.ts b/src/core/config/schema.ts
index 21c8d5c9..194d35ac 100644
--- a/src/core/config/schema.ts
+++ b/src/core/config/schema.ts
@@ -140,6 +140,7 @@ export const BuiltinMcpTogglesSchema = z.object({
filesystem: z.boolean().default(true),
memory: z.boolean().default(true),
sequentialThinking: z.boolean().default(true),
+ puppeteer: z.boolean().default(false),
});
export const McpConfigSchema = z.object({
diff --git a/src/core/config/ui/payloads.ts b/src/core/config/ui/payloads.ts
index e1b16982..b524f2a8 100644
--- a/src/core/config/ui/payloads.ts
+++ b/src/core/config/ui/payloads.ts
@@ -54,6 +54,7 @@ export interface McpToggles {
filesystem: boolean;
memory: boolean;
sequentialThinking: boolean;
+ puppeteer: boolean;
}
export interface McpCustomServerView {
diff --git a/src/core/headless/HeadlessAgentHost.ts b/src/core/headless/HeadlessAgentHost.ts
new file mode 100644
index 00000000..7c83845b
--- /dev/null
+++ b/src/core/headless/HeadlessAgentHost.ts
@@ -0,0 +1,549 @@
+import { join } from 'path';
+import { ThunderSession, type ThunderMode } from '../session/ThunderSession';
+import { IndexService } from '../indexing/IndexService';
+import { IgnoreService } from '../indexing/IgnoreService';
+import { WorkspaceScanner } from '../indexing/WorkspaceScanner';
+import { IndexQueue } from '../indexing/IndexQueue';
+import { FtsIndex } from '../indexing/FtsIndex';
+import { HybridRetriever } from '../context/HybridRetriever';
+import { createContextReranker } from '../context/ContextReranker';
+import { ContextBudgeter } from '../context/ContextBudgeter';
+import { CurrentEditorContextSource, OpenFilesContextSource } from '../context/sources/editorSources';
+import { FtsContextSource, RepoMapContextSource, MemoryContextSource, WorkspaceOverviewContextSource } from '../context/sources/indexSources';
+import { IndexedFileSearchContextSource } from '../context/sources/indexedFileSource';
+import { MentionedFileContextSource } from '../context/sources/mentionedFileSource';
+import { GitService } from '../context/GitService';
+import { GitDiffContextSource } from '../context/DiagnosticsService';
+import { RepoMapService } from '../context/RepoMapService';
+import { setVerifyCommandPatterns } from '../plans/PlanActEngine';
+import { ChatOrchestrator } from '../orchestration/ChatOrchestrator';
+import { ToolRuntime } from '../tools/ToolRuntime';
+import {
+ createReadFileTool, createReadFilesTool, createListFilesTool, createSearchTool,
+ createSearchBatchTool, createSearchScriptCatalogTool, createSpawnResearchAgentTool,
+ createExecuteWorkspaceScriptTool, createUseSkillTool,
+ createRepoMapTool, createRetrieveContextTool, createGitDiffTool,
+ createDiagnosticsTool, createWriteFileTool, createApplyPatchTool, createRunCommandTool,
+ createMemorySearchTool, createMemoryWriteTool, createSaveTaskStateTool,
+ createFetchWebTool, createAskQuestionTool, createProjectCatalogTool, createAnalyzeChangeImpactTool,
+ setSubagentTracker,
+} from '../tools/builtinTools';
+import { ProjectCatalogContextSource, discoverProjectCatalog, saveProjectCatalog } from '../modes/ask';
+import { createMarkStepCompleteTool, createProposePlanMutationTool } from '../tools/planTools';
+import type { AssistantStreamChunk, LlmProvider } from '../llm/types';
+import { createProvider } from '../llm/createProvider';
+import { scaffoldMitiiWorkspace } from '../mcp/scaffoldMitiiWorkspace';
+import { AgentTaskState } from '../runtime/AgentTaskState';
+import {
+ resolveProjectVerifyCommands,
+ formatVerifyPlanForAgent,
+} from '../runtime/verifyCommandDiscovery';
+import { ToolPolicyEngine } from '../safety/ToolPolicyEngine';
+import { resolveEffectiveSafety } from '../safety/autonomyPresets';
+import { ApprovalQueue } from '../safety/ApprovalQueue';
+import { ToolExecutor } from '../safety/ToolExecutor';
+import { MemoryService } from '../memory/MemoryService';
+import { SessionService } from '../session/SessionService';
+import { PlanPersistence } from '../plans/PlanPersistence';
+import { PlanFileStore } from '../plans/PlanFileStore';
+import { MemoryExtractor } from '../runtime/MemoryExtractor';
+import { SubagentTracker } from '../runtime/SubagentTracker';
+import { PassiveMemoryInjector } from '../memory/PassiveMemoryInjector';
+import { MemoryHookService } from '../memory/MemoryHookService';
+import { PostEditValidator } from '../apply/PostEditValidator';
+import { McpManager } from '../mcp/McpManager';
+import { ProjectRulesContextSource, ProjectRulesService } from '../rules/ProjectRulesService';
+import { SkillCatalogContextSource, SkillCatalogService } from '../skills/SkillCatalogService';
+import { createLogger } from '../telemetry/Logger';
+import { SessionLogService } from '../telemetry/SessionLogService';
+import { MicroTaskExecutor } from '../microtasks';
+import { HeadlessAgentRunner, type HeadlessPlan } from './AgentRunner';
+import {
+ buildHeadlessConfig,
+ resolveApiKey,
+ resolveMitiiPackageRoot,
+ type HeadlessAgentOptions,
+} from './HeadlessConfig';
+import { HeadlessDiagnosticsService, HeadlessDiagnosticsContextSource } from './HeadlessDiagnosticsService';
+import { headlessDiscoverFiles } from './headlessDiscoverFiles';
+import { defaultMcpToggles } from '../mcp/mcpToggles';
+import type { ThunderConfig } from '../config/schema';
+import { chunkContent } from '../llm/streamChunks';
+
+const log = createLogger('HeadlessAgentHost');
+
+const AUTO_GRANT_TOOLS = [
+ 'write_file', 'apply_patch', 'run_command', 'ask_question', 'memory_write',
+] as const;
+
+export interface HeadlessRunMetrics {
+ durationMs: number;
+ toolCalls: number;
+ errors: string[];
+ sessionLogPath?: string;
+ auditTools: string[];
+}
+
+export class HeadlessAgentHost {
+ private readonly options: HeadlessAgentOptions;
+ private readonly config: ThunderConfig;
+ private readonly packageRoot: string;
+ private readonly stubRunner: HeadlessAgentRunner;
+ private initialized = false;
+
+ private indexService?: IndexService;
+ private ignoreService = new IgnoreService();
+ private scanner?: WorkspaceScanner;
+ private indexQueue?: IndexQueue;
+ private gitService?: GitService;
+ private skillCatalogService?: SkillCatalogService;
+ private memoryService?: MemoryService;
+ private diagnosticsService = new HeadlessDiagnosticsService();
+ private postEditValidator?: PostEditValidator;
+ private sessionService?: SessionService;
+ private planPersistence?: PlanPersistence;
+ private approvalQueue?: ApprovalQueue;
+ private policyEngine?: ToolPolicyEngine;
+ private toolRuntime = new ToolRuntime();
+ private toolExecutor?: ToolExecutor;
+ private chatOrchestrator?: ChatOrchestrator;
+ private memoryExtractor?: MemoryExtractor;
+ private mcpManager = new McpManager();
+ private sessionLog = new SessionLogService();
+ private subagentTracker = new SubagentTracker();
+ private agentTaskState = new AgentTaskState();
+ private session?: ThunderSession;
+ private provider?: LlmProvider;
+
+ constructor(options: HeadlessAgentOptions) {
+ this.options = options;
+ this.packageRoot = options.packageRoot ?? resolveMitiiPackageRoot(join(__dirname, '..'));
+ this.config = buildHeadlessConfig(options);
+ this.stubRunner = new HeadlessAgentRunner({
+ cwd: options.cwd,
+ providerType: options.providerType ?? this.config.provider.type,
+ baseUrl: options.baseUrl ?? this.config.provider.baseUrl,
+ model: options.model ?? this.config.provider.model,
+ apiKey: options.apiKey ?? resolveApiKey(options.providerType ?? this.config.provider.type),
+ approval: options.approval ?? 'manual',
+ });
+ }
+
+ get isRealRuntime(): boolean {
+ return this.options.runtime !== 'stub';
+ }
+
+ getSessionLog(): SessionLogService {
+ return this.sessionLog;
+ }
+
+ getToolAudit(): ReturnType {
+ return this.toolRuntime.getAuditLog();
+ }
+
+ async initialize(): Promise {
+ if (this.initialized) return;
+ if (!this.isRealRuntime) {
+ this.initialized = true;
+ return;
+ }
+
+ const workspace = this.options.cwd;
+ this.indexService = new IndexService(workspace);
+ await this.indexService.initialize();
+
+ scaffoldMitiiWorkspace(workspace, { extensionRoot: this.packageRoot, forceBundledSkills: false });
+ try {
+ saveProjectCatalog(discoverProjectCatalog(workspace));
+ } catch (error) {
+ log.warn('Project catalog discovery failed', {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+
+ const db = this.indexService.getDb();
+ if (!db) throw new Error('Failed to open index database');
+
+ this.ignoreService.load(workspace, {
+ respectGitignore: this.config.indexing.respectGitignore,
+ respectThunderignore: this.config.indexing.respectThunderignore,
+ });
+
+ this.scanner = new WorkspaceScanner(db, workspace);
+ this.indexQueue = new IndexQueue(db, {
+ maxConcurrency: this.config.indexing.maxConcurrency,
+ maxFileSizeBytes: this.config.indexing.maxFileSizeBytes,
+ });
+
+ this.gitService = new GitService(workspace);
+ await this.gitService.initialize();
+
+ this.diagnosticsService.setWorkspaceRoot(workspace);
+ this.postEditValidator = new PostEditValidator(this.diagnosticsService as never);
+
+ this.skillCatalogService = new SkillCatalogService(workspace);
+ this.skillCatalogService.refresh();
+
+ this.memoryService = new MemoryService(db, workspace, {
+ maxItems: this.config.memory.maxItems,
+ hybridSearchEnabled: this.config.memory.hybridSearchEnabled,
+ });
+
+ this.sessionService = new SessionService(db);
+ this.planPersistence = new PlanPersistence(db);
+ this.approvalQueue = new ApprovalQueue(db);
+
+ const effectiveSafety = resolveEffectiveSafety(this.config.safety);
+ setVerifyCommandPatterns(this.config.agent.verifyCommands);
+
+ this.policyEngine = new ToolPolicyEngine(
+ effectiveSafety,
+ (path) => this.ignoreService.isIgnored(path),
+ () => true
+ );
+
+ this.toolRuntime.setSessionLog(this.sessionLog);
+ setSubagentTracker(this.subagentTracker);
+
+ this.toolExecutor = new ToolExecutor(
+ this.toolRuntime,
+ this.policyEngine,
+ this.approvalQueue,
+ () => this.session?.id ?? '',
+ () => this.session?.mode ?? 'plan',
+ () => this.autoResolvePendingApprovals(),
+ () => this.agentTaskState,
+ this.sessionLog,
+ () => this.toolExecutor?.setPlanPhaseLock('execute')
+ );
+
+ const retriever = this.buildRetriever(db, workspace);
+ const budgeter = new ContextBudgeter();
+ this.chatOrchestrator = new ChatOrchestrator(retriever, budgeter, db);
+ this.configureOrchestrator(workspace);
+
+ const repoMap = new RepoMapService(db, workspace);
+ const fts = new FtsIndex(db);
+ this.registerTools(workspace, repoMap, fts, retriever, budgeter);
+
+ const mcpToggles = {
+ ...defaultMcpToggles(),
+ puppeteer: this.config.mcp.builtinServers.puppeteer ?? false,
+ };
+ await this.mcpManager.reload(this.config.mcp, workspace, this.toolRuntime, mcpToggles);
+
+ this.memoryExtractor = new MemoryExtractor(
+ this.memoryService,
+ this.config.memory.summarizeAfterTask
+ );
+
+ if (this.config.indexing.autoIndexOnOpen) {
+ await this.indexWorkspace(workspace);
+ }
+
+ this.provider = createProvider(this.config.provider, this.options.apiKey ?? resolveApiKey(this.config.provider.type));
+ this.initialized = true;
+ }
+
+ async ask(prompt: string): Promise {
+ await this.initialize();
+ if (!this.isRealRuntime) return this.stubRunner.ask(prompt);
+ return this.runMode('ask', prompt);
+ }
+
+ async plan(prompt: string): Promise> {
+ await this.initialize();
+ if (!this.isRealRuntime) return this.stubRunner.plan(prompt);
+ const content = await this.runMode('plan', prompt);
+ try {
+ return JSON.parse(content) as Record;
+ } catch {
+ return { goal: prompt, content, steps: [] };
+ }
+ }
+
+ async *agent(prompt: string): AsyncIterable<{ type: string; message?: string; plan?: HeadlessPlan; content?: string }> {
+ await this.initialize();
+ if (!this.isRealRuntime) {
+ yield* this.stubRunner.agent(prompt);
+ return;
+ }
+
+ yield { type: 'start', message: 'headless agent started' };
+ let content = '';
+ for await (const chunk of this.streamMode('agent', prompt)) {
+ const text = chunkContent(chunk);
+ if (text) {
+ content += text;
+ yield { type: 'assistant_delta', content: text };
+ }
+ }
+ yield { type: 'end', message: 'headless agent completed', content };
+ }
+
+ async runWithMetrics(mode: ThunderMode, prompt: string): Promise<{ output: string; metrics: HeadlessRunMetrics }> {
+ const started = Date.now();
+ const errors: string[] = [];
+ let output = '';
+
+ try {
+ if (mode === 'ask') {
+ output = await this.ask(prompt);
+ } else if (mode === 'plan') {
+ output = JSON.stringify(await this.plan(prompt));
+ } else {
+ const parts: string[] = [];
+ for await (const event of this.agent(prompt)) {
+ if (event.content) parts.push(event.content);
+ }
+ output = parts.join('');
+ }
+ } catch (error) {
+ errors.push(error instanceof Error ? error.message : String(error));
+ }
+
+ const audit = this.getToolAudit();
+ return {
+ output,
+ metrics: {
+ durationMs: Date.now() - started,
+ toolCalls: audit.length,
+ errors,
+ sessionLogPath: this.sessionLog.getLogPath() || undefined,
+ auditTools: audit.map((entry) => entry.toolName),
+ },
+ };
+ }
+
+ dispose(): void {
+ this.indexService?.dispose();
+ this.initialized = false;
+ }
+
+ private configureOrchestrator(workspace: string): void {
+ if (!this.chatOrchestrator || !this.toolExecutor) return;
+
+ const passiveMemoryInjector = new PassiveMemoryInjector(this.memoryService!);
+ const memoryHookService = new MemoryHookService(workspace);
+
+ this.chatOrchestrator.configure({
+ toolRuntime: this.toolRuntime,
+ toolExecutor: this.toolExecutor,
+ sessionService: this.sessionService,
+ planPersistence: this.planPersistence,
+ memoryExtractor: this.memoryExtractor,
+ memoryConfig: this.config.memory,
+ agentConfig: this.config.agent,
+ passiveMemoryInjector,
+ memoryHookService,
+ postEditValidator: this.postEditValidator,
+ sessionLog: this.sessionLog,
+ workspace,
+ memoryService: this.memoryService,
+ taskState: this.agentTaskState,
+ skillCatalog: this.skillCatalogService,
+ allowNetwork: () => resolveEffectiveSafety(this.config.safety).allowNetwork,
+ runVerifyHooks: async (commands, userMessage) => this.runVerifyHooks(workspace, commands, userMessage ?? ''),
+ microTaskRoutingEnabled: this.config.context.microTaskRoutingEnabled,
+ microTaskExecutorFactory: (provider) => new MicroTaskExecutor({
+ workspace,
+ git: this.gitService!,
+ provider,
+ sessionLog: this.sessionLog,
+ }),
+ onPostWrite: async () => undefined,
+ onDiffPreview: async () => undefined,
+ });
+ this.chatOrchestrator.setToolExecutor(this.toolExecutor);
+ }
+
+ private registerTools(
+ workspace: string,
+ repoMap: RepoMapService,
+ fts: FtsIndex,
+ retriever: HybridRetriever,
+ budgeter: ContextBudgeter
+ ): void {
+ this.toolRuntime.register(createReadFileTool(workspace, this.ignoreService));
+ this.toolRuntime.register(createReadFilesTool(workspace, this.ignoreService));
+ this.toolRuntime.register(createListFilesTool(workspace, this.ignoreService));
+ this.toolRuntime.register(createSearchTool(fts, workspace));
+ this.toolRuntime.register(createSearchBatchTool(fts, workspace));
+ this.toolRuntime.register(createSearchScriptCatalogTool(workspace, this.packageRoot));
+ this.toolRuntime.register(createExecuteWorkspaceScriptTool(workspace, this.packageRoot, this.ignoreService));
+ this.toolRuntime.register(createUseSkillTool(this.skillCatalogService!));
+ this.toolRuntime.register(createSpawnResearchAgentTool());
+ this.toolRuntime.register(createRepoMapTool(repoMap));
+ this.toolRuntime.register(createRetrieveContextTool(retriever, budgeter));
+ this.toolRuntime.register(createGitDiffTool(this.gitService!));
+ this.toolRuntime.register(createDiagnosticsTool(this.diagnosticsService as never));
+ this.toolRuntime.register(createProjectCatalogTool(workspace));
+ this.toolRuntime.register(createAnalyzeChangeImpactTool(workspace));
+ this.toolRuntime.register(createWriteFileTool(workspace, this.ignoreService));
+ this.toolRuntime.register(createApplyPatchTool(workspace, this.ignoreService));
+ this.toolRuntime.register(createRunCommandTool(workspace, () => this.session?.mode ?? 'plan'));
+ this.toolRuntime.register(createMemorySearchTool(this.memoryService!));
+ this.toolRuntime.register(createMemoryWriteTool(this.memoryService!, () => this.session?.id ?? ''));
+ this.toolRuntime.register(createSaveTaskStateTool(this.memoryService!, () => this.session?.id ?? '', () => this.agentTaskState));
+ this.toolRuntime.register(createFetchWebTool(() => this.config.safety.allowNetwork));
+ this.toolRuntime.register(createAskQuestionTool());
+
+ const sessionIdForPlans = () => this.session?.id ?? '';
+ const planToolsCtx = {
+ getPlan: () => this.planPersistence?.getActive(sessionIdForPlans())?.plan ?? null,
+ setPlan: (plan: import('../plans/PlanActEngine').ThunderPlan) => {
+ const sid = sessionIdForPlans();
+ if (sid) this.planPersistence?.updatePlan(sid, plan);
+ },
+ planPersistence: this.planPersistence,
+ getSessionId: sessionIdForPlans,
+ setPlanPhaseLock: (phase: import('../plans/PlanActEngine').PlanPhase | undefined) => {
+ this.toolExecutor?.setPlanPhaseLock(phase);
+ },
+ get planFileStore() {
+ const sid = sessionIdForPlans();
+ return sid ? new PlanFileStore(workspace, sid) : undefined;
+ },
+ };
+ this.toolRuntime.register(createMarkStepCompleteTool(planToolsCtx));
+ this.toolRuntime.register(createProposePlanMutationTool(planToolsCtx));
+ }
+
+ private buildRetriever(db: import('../indexing/ThunderDb').ThunderDb, workspace: string): HybridRetriever {
+ const sources = [];
+ const projectRulesService = new ProjectRulesService(workspace);
+ sources.push(new ProjectRulesContextSource(projectRulesService));
+ if (this.skillCatalogService) {
+ sources.push(new SkillCatalogContextSource(this.skillCatalogService));
+ }
+ sources.push(new ProjectCatalogContextSource(workspace));
+ sources.push(
+ new MentionedFileContextSource(workspace),
+ new WorkspaceOverviewContextSource(workspace),
+ new CurrentEditorContextSource(workspace, db),
+ new OpenFilesContextSource(workspace, db),
+ new FtsContextSource(db),
+ new IndexedFileSearchContextSource(db, workspace),
+ new RepoMapContextSource(db, workspace)
+ );
+ if (this.gitService) sources.push(new GitDiffContextSource(this.gitService));
+ sources.push(new HeadlessDiagnosticsContextSource(this.diagnosticsService));
+ if (this.memoryService) sources.push(new MemoryContextSource(this.memoryService));
+
+ const reranker = createContextReranker(undefined, false);
+ return new HybridRetriever(sources, reranker, {
+ enabled: this.config.context.rerankerEnabled,
+ candidatePool: this.config.context.rerankerCandidatePool,
+ topK: this.config.context.rerankerTopK,
+ });
+ }
+
+ private async indexWorkspace(workspace: string): Promise {
+ if (!this.scanner || !this.indexQueue) return;
+ const files = headlessDiscoverFiles(workspace, this.ignoreService, this.config.indexing);
+ const diff = this.scanner.computeDiff(files);
+ this.scanner.persistScan(diff);
+ const jobs = [...diff.added, ...diff.changed].map((f) => ({
+ fileId: this.scanner!.getFileId(f.relPath)!,
+ relPath: f.relPath,
+ absPath: f.absPath,
+ language: f.language,
+ })).filter((j) => j.fileId !== undefined);
+
+ if (jobs.length === 0) return;
+ this.indexQueue.enqueue(jobs);
+
+ const deadline = Date.now() + 120_000;
+ while (this.indexQueue.getStatus().running || this.indexQueue.getStatus().queued > 0) {
+ if (Date.now() > deadline) break;
+ await sleep(250);
+ }
+ this.sessionLog.append('index_complete', 'Headless indexing finished', {
+ workspace,
+ jobCount: jobs.length,
+ });
+ }
+
+ private async runMode(mode: ThunderMode, prompt: string): Promise {
+ const parts: string[] = [];
+ for await (const chunk of this.streamMode(mode, prompt)) {
+ const text = chunkContent(chunk);
+ if (text) parts.push(text);
+ }
+ return parts.join('');
+ }
+
+ private async *streamMode(mode: ThunderMode, prompt: string): AsyncIterable {
+ if (!this.chatOrchestrator || !this.provider) {
+ throw new Error('Headless agent host is not initialized');
+ }
+
+ this.session = new ThunderSession(this.options.cwd, mode);
+ this.sessionLog.configure(this.options.cwd, this.session.id, true, this.config.telemetry.debugMetrics);
+ this.toolRuntime.clearAuditLog();
+ this.agentTaskState.reset();
+ this.agentTaskState.setLimits({
+ maxSequentialThinkingCalls: this.config.agent.maxSequentialThinkingCallsPerTurn,
+ });
+
+ if (this.options.approval === 'auto') {
+ for (const tool of AUTO_GRANT_TOOLS) {
+ this.approvalQueue?.grantForTask(this.session.id, tool);
+ }
+ }
+
+ this.sessionService?.ensureSession(this.session, prompt.slice(0, 64));
+ yield* this.chatOrchestrator.send(this.session, this.provider, prompt, []);
+ }
+
+ private autoResolvePendingApprovals(): void {
+ if (this.options.approval !== 'auto' || !this.approvalQueue || !this.session) return;
+ for (const request of this.approvalQueue.getPending()) {
+ this.approvalQueue.grantForTask(this.session.id, request.toolName);
+ this.approvalQueue.resolve(request.id, 'approved');
+ this.sessionLog.append('approval_decision', `auto-approved: ${request.toolName}`, {
+ id: request.id,
+ toolName: request.toolName,
+ scope: 'task',
+ });
+ }
+ }
+
+ private async runVerifyHooks(workspace: string, commands: string[], userMessage: string): Promise {
+ const lines: string[] = [];
+ const touchedFiles = this.getTouchedFilesFromAudit();
+ const plan = resolveProjectVerifyCommands(workspace, commands, { touchedFiles, userMessage });
+ lines.push(formatVerifyPlanForAgent(plan));
+
+ for (const command of plan.commands) {
+ const trimmed = command.trim();
+ if (!trimmed) continue;
+ try {
+ const result = await this.toolRuntime.execute('run_command', { command: trimmed });
+ const body = result.success
+ ? (result.output || '(no output)')
+ : (result.error ?? result.output ?? 'command failed');
+ lines.push(`$ ${trimmed}\n${body.slice(0, 4000)}`);
+ } catch (error) {
+ const msg = error instanceof Error ? error.message : String(error);
+ lines.push(`$ ${trimmed}\n${msg}`);
+ }
+ }
+
+ return lines.join('\n\n');
+ }
+
+ private getTouchedFilesFromAudit(): string[] {
+ const files = new Set();
+ for (const { toolName, input, result } of this.toolRuntime.getAuditLog()) {
+ if (!result.success || !['write_file', 'apply_patch'].includes(toolName)) continue;
+ const path = (input as Record).path;
+ if (typeof path === 'string') files.add(path);
+ }
+ return [...files];
+ }
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
diff --git a/src/core/headless/HeadlessConfig.ts b/src/core/headless/HeadlessConfig.ts
new file mode 100644
index 00000000..8e96e2a2
--- /dev/null
+++ b/src/core/headless/HeadlessConfig.ts
@@ -0,0 +1,94 @@
+import { existsSync } from 'fs';
+import { join } from 'path';
+import type { ProviderType, ThunderConfig } from '../config/schema';
+import { defaultThunderConfig } from '../config/defaults';
+import { resolveEffectiveSafety } from '../safety/autonomyPresets';
+
+export type HeadlessRuntime = 'real' | 'stub';
+
+export interface HeadlessAgentOptions {
+ cwd: string;
+ packageRoot?: string;
+ runtime?: HeadlessRuntime;
+ providerType?: ProviderType;
+ baseUrl?: string;
+ model?: string;
+ apiKey?: string;
+ approval?: 'auto' | 'manual';
+ allowNetwork?: boolean;
+ enablePuppeteer?: boolean;
+ indexWorkspace?: boolean;
+ configOverrides?: Partial;
+}
+
+export function resolveMitiiPackageRoot(fromDir: string): string {
+ let current = fromDir;
+ for (let depth = 0; depth < 4; depth += 1) {
+ if (existsSync(join(current, 'package.json'))) return current;
+ const parent = join(current, '..');
+ if (parent === current) break;
+ current = parent;
+ }
+ return fromDir;
+}
+
+export function buildHeadlessConfig(options: HeadlessAgentOptions): ThunderConfig {
+ const base = defaultThunderConfig();
+ const approvalMode = options.approval === 'auto' ? 'auto' as const : 'review_all' as const;
+ const safety = resolveEffectiveSafety({
+ ...base.safety,
+ approvalMode,
+ allowUntrustedWorkspace: true,
+ allowNetwork: options.allowNetwork ?? false,
+ });
+
+ const mcp = {
+ ...base.mcp,
+ enabled: true,
+ preloadBuiltin: true,
+ builtinServers: {
+ ...base.mcp.builtinServers,
+ puppeteer: options.enablePuppeteer ?? false,
+ },
+ };
+
+ const config: ThunderConfig = {
+ ...base,
+ ...options.configOverrides,
+ provider: {
+ ...base.provider,
+ type: options.providerType ?? base.provider.type,
+ baseUrl: options.baseUrl ?? base.provider.baseUrl,
+ model: options.model ?? base.provider.model,
+ supportsTools: options.runtime === 'stub' ? false : true,
+ },
+ safety,
+ mcp,
+ indexing: {
+ ...base.indexing,
+ vectorsEnabled: false,
+ autoIndexOnOpen: options.indexWorkspace !== false,
+ ...(options.configOverrides?.indexing ?? {}),
+ },
+ agent: {
+ ...base.agent,
+ verifyOnActComplete: true,
+ ...(options.configOverrides?.agent ?? {}),
+ },
+ telemetry: {
+ ...base.telemetry,
+ sessionLogging: true,
+ debugMetrics: true,
+ ...(options.configOverrides?.telemetry ?? {}),
+ },
+ };
+
+ return config;
+}
+
+export function resolveApiKey(providerType: ProviderType): string | undefined {
+ if (providerType === 'anthropic') return process.env.ANTHROPIC_API_KEY ?? process.env.MITII_API_KEY;
+ if (providerType === 'gemini') return process.env.GEMINI_API_KEY ?? process.env.MITII_API_KEY;
+ if (providerType === 'openrouter') return process.env.OPENROUTER_API_KEY ?? process.env.MITII_API_KEY;
+ return process.env.MITII_API_KEY ?? process.env.OPENAI_API_KEY;
+}
diff --git a/src/core/headless/HeadlessDiagnosticsService.ts b/src/core/headless/HeadlessDiagnosticsService.ts
new file mode 100644
index 00000000..0e715bb4
--- /dev/null
+++ b/src/core/headless/HeadlessDiagnosticsService.ts
@@ -0,0 +1,47 @@
+import type { ContextItem, ContextQuery, ContextSource } from '../context/types';
+
+/** Headless diagnostics stub — no VS Code language service in CLI/benchmark runs. */
+export class HeadlessDiagnosticsService {
+ setWorkspaceRoot(_root: string): void {
+ // Headless runtime has no editor diagnostics integration.
+ }
+
+ getDiagnostics(): Array<{ file: string; severity: string; message: string; line: number }> {
+ return [];
+ }
+
+ formatCompact(_maxItems = 20): string {
+ return '';
+ }
+
+ getHeavyFiles(): string[] {
+ return [];
+ }
+
+ getFileErrors(_relPath: string): Array<{ line: number; message: string }> {
+ return [];
+ }
+
+ async waitForFileErrors(_relPath: string, _maxWaitMs = 2500): Promise> {
+ return [];
+ }
+}
+
+export class HeadlessDiagnosticsContextSource implements ContextSource {
+ readonly id = 'diagnostics';
+
+ constructor(private readonly diagnosticsService: HeadlessDiagnosticsService) {}
+
+ async retrieve(_query: ContextQuery): Promise {
+ const formatted = this.diagnosticsService.formatCompact();
+ if (!formatted) return [];
+ return [{
+ id: 'diagnostics',
+ source: this.id,
+ content: formatted,
+ score: 5,
+ reason: 'Headless diagnostics (empty in CLI runtime)',
+ tokenEstimate: Math.ceil(formatted.length / 4),
+ }];
+ }
+}
diff --git a/src/core/headless/headlessDiscoverFiles.ts b/src/core/headless/headlessDiscoverFiles.ts
new file mode 100644
index 00000000..d53a056a
--- /dev/null
+++ b/src/core/headless/headlessDiscoverFiles.ts
@@ -0,0 +1,58 @@
+import { readdirSync, statSync } from 'fs';
+import { join, relative } from 'path';
+import type { IgnoreService } from '../indexing/IgnoreService';
+import type { DiscoveredFile } from '../indexing/FileDiscoveryService';
+import { isBinaryByExtension, detectLanguage } from '../indexing/fileUtils';
+import type { IndexingConfig } from '../config/schema';
+
+/** File discovery without VS Code configuration APIs — used by headless host and benchmarks. */
+export function headlessDiscoverFiles(
+ workspacePath: string,
+ ignoreService: IgnoreService,
+ config: Pick
+): DiscoveredFile[] {
+ const results: DiscoveredFile[] = [];
+
+ const walk = (dir: string): void => {
+ let entries: string[];
+ try {
+ entries = readdirSync(dir);
+ } catch {
+ return;
+ }
+
+ for (const entry of entries) {
+ const absPath = join(dir, entry);
+ const relPath = relative(workspacePath, absPath).replace(/\\/g, '/');
+
+ if (ignoreService.isIgnored(relPath)) continue;
+
+ let stat;
+ try {
+ stat = statSync(absPath);
+ } catch {
+ continue;
+ }
+
+ if (stat.isDirectory()) {
+ walk(absPath);
+ continue;
+ }
+
+ if (!stat.isFile()) continue;
+ if (stat.size > config.hardSkipSizeBytes) continue;
+ if (isBinaryByExtension(relPath)) continue;
+
+ results.push({
+ absPath,
+ relPath,
+ size: stat.size,
+ mtime: stat.mtimeMs,
+ language: detectLanguage(relPath),
+ });
+ }
+ };
+
+ walk(workspacePath);
+ return results;
+}
diff --git a/src/core/headless/index.ts b/src/core/headless/index.ts
index 2f5f2df6..d28dc0ef 100644
--- a/src/core/headless/index.ts
+++ b/src/core/headless/index.ts
@@ -1,2 +1,4 @@
export * from './release';
export * from './AgentRunner';
+export * from './HeadlessAgentHost';
+export * from './HeadlessConfig';
diff --git a/src/core/mcp/builtinServers.ts b/src/core/mcp/builtinServers.ts
index 37542cff..b5e1b67b 100644
--- a/src/core/mcp/builtinServers.ts
+++ b/src/core/mcp/builtinServers.ts
@@ -7,6 +7,7 @@ export const BUILTIN_MCP_SERVER_NAMES = [
'filesystem',
'memory',
'sequential-thinking',
+ 'puppeteer',
] as const;
export type BuiltinMcpServerName = (typeof BUILTIN_MCP_SERVER_NAMES)[number];
@@ -39,6 +40,15 @@ export function buildBuiltinMcpServers(workspace: string): Record ({
filesystem: true,
memory: true,
sequentialThinking: true,
+ puppeteer: false,
});
export function mcpToggleKeyToServerName(key: keyof McpToggles): string {
- return key === 'sequentialThinking' ? 'sequential-thinking' : key;
+ if (key === 'sequentialThinking') return 'sequential-thinking';
+ return key;
}
export function mcpServerNameToToggleKey(name: string): keyof McpToggles | undefined {
if (name === 'filesystem') return 'filesystem';
if (name === 'memory') return 'memory';
if (name === 'sequential-thinking') return 'sequentialThinking';
+ if (name === 'puppeteer') return 'puppeteer';
return undefined;
}
diff --git a/src/core/plans/promptBuilder.ts b/src/core/plans/promptBuilder.ts
index feef8fcd..31ba2be1 100644
--- a/src/core/plans/promptBuilder.ts
+++ b/src/core/plans/promptBuilder.ts
@@ -187,7 +187,7 @@ RULES:
- The user's message may include a block with files/folders they pinned. Treat that as highest priority — focus there first before wider codebase context.
- The user's message includes a ## Codebase Context section with real project files. READ IT and answer from it.
- If ## Codebase Context includes a repo_map/workspace overview, use that provided map first. Do NOT repeatedly call list_files for the same structure unless the map is absent or demonstrably stale.
-- Project rule files in context (AGENTS.md, CLAUDE.md, .mitii/rules, .clinerules, .continue/rules, etc.) are operating instructions for this workspace. Follow them unless they conflict with explicit user instructions or safety policy.
+- \`MITII.md\` in context is the operating instructions file for this workspace. Follow it unless it conflicts with explicit user instructions or safety policy.
- Focus on files and topics the user asked about. Do NOT pivot to unrelated open tabs or linter diagnostics unless the user asked to fix errors.
- NEVER ask the user to paste README, package.json, or source files — they are already in context.
- NEVER say context is "truncated" or "not fully visible" if file content appears in context — use what is provided.
diff --git a/src/core/providers/ProviderProfilesService.ts b/src/core/providers/ProviderProfilesService.ts
new file mode 100644
index 00000000..701939f6
--- /dev/null
+++ b/src/core/providers/ProviderProfilesService.ts
@@ -0,0 +1,182 @@
+import { createHash, randomUUID } from 'crypto';
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
+import { join } from 'path';
+import type { ProviderSettingsPayload } from '../config/ui/payloads';
+import { ensureThunderDir } from '../indexing/paths';
+import { createLogger } from '../telemetry/Logger';
+
+const log = createLogger('ProviderProfilesService');
+
+export interface ProviderProfileView {
+ id: string;
+ name: string;
+ providerType: ProviderSettingsPayload['providerType'];
+ baseUrl: string;
+ model: string;
+ apiVersion: string;
+ region: string;
+ contextWindow: number;
+ hasApiKey: boolean;
+}
+
+interface ProviderProfileRecord {
+ id: string;
+ name: string;
+ providerType: ProviderSettingsPayload['providerType'];
+ baseUrl: string;
+ model: string;
+ apiVersion: string;
+ region: string;
+ contextWindow: number;
+ apiKeyHash?: string;
+}
+
+interface ProviderProfilesFile {
+ activeId: string | null;
+ profiles: ProviderProfileRecord[];
+}
+
+export function hashProviderApiKey(key: string): string {
+ return createHash('sha256').update(key.trim()).digest('hex');
+}
+
+export function providerSecretRef(profileId: string): string {
+ return `mitii.provider.${profileId}`;
+}
+
+function providersDir(workspace: string): string {
+ const dir = join(ensureThunderDir(workspace), 'providers');
+ if (!existsSync(dir)) {
+ mkdirSync(dir, { recursive: true });
+ }
+ return dir;
+}
+
+function indexPath(workspace: string): string {
+ return join(providersDir(workspace), 'index.json');
+}
+
+function readProfilesFile(workspace: string): ProviderProfilesFile {
+ const file = indexPath(workspace);
+ if (!existsSync(file)) {
+ return { activeId: null, profiles: [] };
+ }
+ try {
+ const parsed = JSON.parse(readFileSync(file, 'utf-8')) as ProviderProfilesFile;
+ return {
+ activeId: parsed.activeId ?? null,
+ profiles: Array.isArray(parsed.profiles) ? parsed.profiles : [],
+ };
+ } catch (error) {
+ log.warn('Could not read provider profiles', {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return { activeId: null, profiles: [] };
+ }
+}
+
+function writeProfilesFile(workspace: string, data: ProviderProfilesFile): void {
+ const file = indexPath(workspace);
+ writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf-8');
+}
+
+function toView(profile: ProviderProfileRecord): ProviderProfileView {
+ return {
+ id: profile.id,
+ name: profile.name,
+ providerType: profile.providerType,
+ baseUrl: profile.baseUrl,
+ model: profile.model,
+ apiVersion: profile.apiVersion,
+ region: profile.region,
+ contextWindow: profile.contextWindow,
+ hasApiKey: Boolean(profile.apiKeyHash),
+ };
+}
+
+export class ProviderProfilesService {
+ constructor(private readonly workspace: string) {}
+
+ list(): ProviderProfileView[] {
+ if (!this.workspace) return [];
+ return readProfilesFile(this.workspace).profiles.map(toView);
+ }
+
+ getActiveId(): string | null {
+ if (!this.workspace) return null;
+ return readProfilesFile(this.workspace).activeId;
+ }
+
+ getActive(): ProviderProfileView | null {
+ if (!this.workspace) return null;
+ const file = readProfilesFile(this.workspace);
+ const active = file.profiles.find((profile) => profile.id === file.activeId);
+ return active ? toView(active) : null;
+ }
+
+ getById(id: string): ProviderProfileView | null {
+ if (!this.workspace) return null;
+ const profile = readProfilesFile(this.workspace).profiles.find((item) => item.id === id);
+ return profile ? toView(profile) : null;
+ }
+
+ upsert(
+ settings: ProviderSettingsPayload,
+ options: { id?: string; name?: string; apiKey?: string }
+ ): ProviderProfileView {
+ if (!this.workspace.trim()) {
+ throw new Error('Open a workspace to save provider profiles under .mitii/providers.');
+ }
+
+ const file = readProfilesFile(this.workspace);
+ const id = options.id ?? randomUUID();
+ const existing = file.profiles.find((profile) => profile.id === id);
+ const name =
+ options.name?.trim() ||
+ existing?.name ||
+ `${settings.providerType} / ${settings.model}`.slice(0, 64);
+
+ const next: ProviderProfileRecord = {
+ id,
+ name,
+ providerType: settings.providerType,
+ baseUrl: settings.baseUrl.trim(),
+ model: settings.model.trim(),
+ apiVersion: settings.apiVersion?.trim() ?? '',
+ region: settings.region?.trim() ?? '',
+ contextWindow: settings.contextWindow,
+ apiKeyHash:
+ options.apiKey?.trim()
+ ? hashProviderApiKey(options.apiKey)
+ : existing?.apiKeyHash,
+ };
+
+ const profiles = existing
+ ? file.profiles.map((profile) => (profile.id === id ? next : profile))
+ : [...file.profiles, next];
+
+ writeProfilesFile(this.workspace, {
+ activeId: file.activeId ?? id,
+ profiles,
+ });
+
+ return toView(next);
+ }
+
+ setActive(id: string): ProviderProfileView | null {
+ if (!this.workspace.trim()) return null;
+ const file = readProfilesFile(this.workspace);
+ const profile = file.profiles.find((item) => item.id === id);
+ if (!profile) return null;
+ writeProfilesFile(this.workspace, { ...file, activeId: id });
+ return toView(profile);
+ }
+
+ delete(id: string): void {
+ if (!this.workspace.trim()) return;
+ const file = readProfilesFile(this.workspace);
+ const profiles = file.profiles.filter((profile) => profile.id !== id);
+ const activeId = file.activeId === id ? profiles[0]?.id ?? null : file.activeId;
+ writeProfilesFile(this.workspace, { activeId, profiles });
+ }
+}
diff --git a/src/core/rules/ProjectRulesService.ts b/src/core/rules/ProjectRulesService.ts
index f4374342..74648d03 100644
--- a/src/core/rules/ProjectRulesService.ts
+++ b/src/core/rules/ProjectRulesService.ts
@@ -1,30 +1,11 @@
-import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
+import { existsSync, readFileSync, statSync } from 'fs';
import { join } from 'path';
import type { ContextItem, ContextQuery, ContextSource } from '../context/types';
import { createLogger } from '../telemetry/Logger';
const log = createLogger('ProjectRulesService');
-const RULE_FILES = [
- 'AGENTS.md',
- 'CLAUDE.md',
- 'WARP.md',
- '.cursorrules',
- '.clinerules',
-];
-
-const RULE_DIRS = [
- '.mitii/rules',
- '.mitii/agents',
- '.mitii/checks',
- '.mitii/prompts',
- '.clinerules',
- '.continue/rules',
- '.continue/agents',
- '.continue/checks',
- '.continue/prompts',
- '.cursor/rules',
-];
+const RULE_FILE = 'MITII.md';
export interface ProjectRuleFile {
relPath: string;
@@ -34,36 +15,15 @@ export interface ProjectRuleFile {
export class ProjectRulesService {
constructor(private readonly workspace: string) {}
- load(maxFiles = 24, maxCharsPerFile = 5000): ProjectRuleFile[] {
+ load(maxCharsPerFile = 5000): ProjectRuleFile[] {
if (!this.workspace) return [];
const files: ProjectRuleFile[] = [];
-
- for (const relPath of RULE_FILES) {
- this.tryAddFile(files, relPath, maxCharsPerFile);
- }
-
- for (const relDir of RULE_DIRS) {
- const absDir = join(this.workspace, relDir);
- if (!existsSync(absDir)) continue;
- if (!statSync(absDir).isDirectory()) continue;
- try {
- for (const entry of walkRuleDir(this.workspace, relDir, 2)) {
- this.tryAddFile(files, entry, maxCharsPerFile);
- if (files.length >= maxFiles) return files;
- }
- } catch (error) {
- log.warn('Could not read rules directory', {
- relDir,
- error: error instanceof Error ? error.message : String(error),
- });
- }
- }
-
- return files.slice(0, maxFiles);
+ this.tryAddFile(files, RULE_FILE, maxCharsPerFile);
+ return files;
}
count(): number {
- return this.load(200, 1).length;
+ return this.load(1).length;
}
private tryAddFile(files: ProjectRuleFile[], relPath: string, maxChars: number): void {
@@ -75,8 +35,11 @@ export class ProjectRulesService {
if (!st.isFile() || st.size > 256_000) return;
const content = readFileSync(abs, 'utf-8').slice(0, maxChars).trim();
if (content) files.push({ relPath, content });
- } catch {
- // Ignore unreadable rule files.
+ } catch (error) {
+ log.warn('Could not read project rules file', {
+ relPath,
+ error: error instanceof Error ? error.message : String(error),
+ });
}
}
}
@@ -98,22 +61,3 @@ export class ProjectRulesContextSource implements ContextSource {
}));
}
}
-
-function walkRuleDir(workspace: string, relDir: string, maxDepth: number): string[] {
- const out: string[] = [];
- const walk = (currentRel: string, depth: number) => {
- if (depth > maxDepth) return;
- const abs = join(workspace, currentRel);
- const entries = readdirSync(abs, { withFileTypes: true });
- for (const entry of entries) {
- const childRel = `${currentRel}/${entry.name}`;
- if (entry.isDirectory()) {
- walk(childRel, depth + 1);
- } else if (/\.(md|mdc)$/i.test(entry.name) || entry.name === '.cursorrules') {
- out.push(childRel);
- }
- }
- };
- walk(relDir, 0);
- return out.sort();
-}
diff --git a/bundled-skills/README.md b/src/core/skills/bundled/README.md
similarity index 100%
rename from bundled-skills/README.md
rename to src/core/skills/bundled/README.md
diff --git a/bundled-skills/audit-cleanup/SKILL.md b/src/core/skills/bundled/audit-cleanup/SKILL.md
similarity index 100%
rename from bundled-skills/audit-cleanup/SKILL.md
rename to src/core/skills/bundled/audit-cleanup/SKILL.md
diff --git a/src/core/skills/bundled/browser-testing-with-devtools/SKILL.md b/src/core/skills/bundled/browser-testing-with-devtools/SKILL.md
new file mode 100644
index 00000000..86b40358
--- /dev/null
+++ b/src/core/skills/bundled/browser-testing-with-devtools/SKILL.md
@@ -0,0 +1,39 @@
+---
+name: browser-testing-with-devtools
+description: Browser automation and UI verification with Puppeteer MCP for React, Next.js, and web apps.
+---
+
+# Browser testing with Puppeteer
+
+Use this skill when validating UI behavior, screenshots, or client-side flows in JavaScript web apps.
+
+## When to use
+
+- React / Next.js / Vite UI verification
+- Screenshot or DOM assertions after Agent edits
+- Smoke-testing pages in benchmark or CI fixtures
+
+## MCP setup
+
+Mitii preloads `@modelcontextprotocol/server-puppeteer` when `thunder.mcp.builtinServers.puppeteer` is enabled.
+
+Headless CLI:
+
+```bash
+mitii agent "Open the home page and verify the title" --runtime real --enable-puppeteer --approval auto
+```
+
+## Tools
+
+- `mcp__puppeteer__puppeteer_navigate`
+- `mcp__puppeteer__puppeteer_screenshot`
+- `mcp__puppeteer__puppeteer_click`
+- `mcp__puppeteer__puppeteer_fill`
+- `mcp__puppeteer__puppeteer_evaluate`
+
+## Workflow
+
+1. Start or assume a local dev server (`npm run dev`) when testing a fixture repo.
+2. Navigate to the page under test.
+3. Capture screenshot or evaluate DOM selectors.
+4. Report pass/fail with evidence in the session log.
diff --git a/bundled-skills/code-review-and-quality/SKILL.md b/src/core/skills/bundled/code-review-and-quality/SKILL.md
similarity index 100%
rename from bundled-skills/code-review-and-quality/SKILL.md
rename to src/core/skills/bundled/code-review-and-quality/SKILL.md
diff --git a/bundled-skills/code-smells-and-tech-debt/SKILL.md b/src/core/skills/bundled/code-smells-and-tech-debt/SKILL.md
similarity index 100%
rename from bundled-skills/code-smells-and-tech-debt/SKILL.md
rename to src/core/skills/bundled/code-smells-and-tech-debt/SKILL.md
diff --git a/bundled-skills/debugging-and-error-recovery/SKILL.md b/src/core/skills/bundled/debugging-and-error-recovery/SKILL.md
similarity index 100%
rename from bundled-skills/debugging-and-error-recovery/SKILL.md
rename to src/core/skills/bundled/debugging-and-error-recovery/SKILL.md
diff --git a/bundled-skills/environment-and-secrets/SKILL.md b/src/core/skills/bundled/environment-and-secrets/SKILL.md
similarity index 100%
rename from bundled-skills/environment-and-secrets/SKILL.md
rename to src/core/skills/bundled/environment-and-secrets/SKILL.md
diff --git a/bundled-skills/git-workflow-and-versioning/SKILL.md b/src/core/skills/bundled/git-workflow-and-versioning/SKILL.md
similarity index 100%
rename from bundled-skills/git-workflow-and-versioning/SKILL.md
rename to src/core/skills/bundled/git-workflow-and-versioning/SKILL.md
diff --git a/bundled-skills/performance-optimization/SKILL.md b/src/core/skills/bundled/performance-optimization/SKILL.md
similarity index 100%
rename from bundled-skills/performance-optimization/SKILL.md
rename to src/core/skills/bundled/performance-optimization/SKILL.md
diff --git a/bundled-skills/planning-and-task-breakdown/SKILL.md b/src/core/skills/bundled/planning-and-task-breakdown/SKILL.md
similarity index 100%
rename from bundled-skills/planning-and-task-breakdown/SKILL.md
rename to src/core/skills/bundled/planning-and-task-breakdown/SKILL.md
diff --git a/bundled-skills/test-driven-development/SKILL.md b/src/core/skills/bundled/test-driven-development/SKILL.md
similarity index 100%
rename from bundled-skills/test-driven-development/SKILL.md
rename to src/core/skills/bundled/test-driven-development/SKILL.md
diff --git a/bundled-skills/using-agent-skills/SKILL.md b/src/core/skills/bundled/using-agent-skills/SKILL.md
similarity index 100%
rename from bundled-skills/using-agent-skills/SKILL.md
rename to src/core/skills/bundled/using-agent-skills/SKILL.md
diff --git a/src/core/skills/installBundledSkills.ts b/src/core/skills/installBundledSkills.ts
index 27029f49..46c4b45b 100644
--- a/src/core/skills/installBundledSkills.ts
+++ b/src/core/skills/installBundledSkills.ts
@@ -1,6 +1,7 @@
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'fs';
import { basename, join } from 'path';
import { createLogger } from '../telemetry/Logger';
+import { resolveBundledSkillsRoot } from './resolveBundledSkillsRoot';
const log = createLogger('BundledSkills');
@@ -17,14 +18,14 @@ export function installBundledSkills(
extensionRoot: string,
options: { force?: boolean } = {}
): InstallBundledSkillsResult {
- const bundledRoot = join(extensionRoot, 'bundled-skills');
+ const bundledRoot = resolveBundledSkillsRoot(extensionRoot);
const destinationRoot = join(workspace, '.mitii', 'skills');
const installed: string[] = [];
const skipped: string[] = [];
- if (!existsSync(bundledRoot)) {
- log.warn('Bundled skills directory missing', { bundledRoot });
- return { installed, skipped, bundledRoot, destinationRoot };
+ if (!bundledRoot || !existsSync(bundledRoot)) {
+ log.warn('Bundled skills directory missing', { extensionRoot });
+ return { installed, skipped, bundledRoot: bundledRoot ?? '', destinationRoot };
}
mkdirSync(destinationRoot, { recursive: true });
@@ -71,14 +72,14 @@ export function installBundledSkills(
}
export function listBundledSkillNames(extensionRoot: string): string[] {
- const bundledRoot = join(extensionRoot, 'bundled-skills');
- if (!existsSync(bundledRoot)) return [];
+ const bundledRoot = resolveBundledSkillsRoot(extensionRoot);
+ if (!bundledRoot || !existsSync(bundledRoot)) return [];
return listBundledSkillDirs(bundledRoot).map((dir) => basename(dir)).sort();
}
export function readBundledSkillManifest(extensionRoot: string): Array<{ name: string; description: string }> {
- const bundledRoot = join(extensionRoot, 'bundled-skills');
- if (!existsSync(bundledRoot)) return [];
+ const bundledRoot = resolveBundledSkillsRoot(extensionRoot);
+ if (!bundledRoot || !existsSync(bundledRoot)) return [];
return listBundledSkillDirs(bundledRoot).map((dir) => {
const content = readFileSync(join(dir, 'SKILL.md'), 'utf8');
diff --git a/src/core/skills/resolveBundledSkillsRoot.ts b/src/core/skills/resolveBundledSkillsRoot.ts
new file mode 100644
index 00000000..fdcb08e1
--- /dev/null
+++ b/src/core/skills/resolveBundledSkillsRoot.ts
@@ -0,0 +1,32 @@
+import { existsSync, readdirSync, statSync } from 'fs';
+import { join } from 'path';
+
+const CANDIDATE_SUFFIXES = [
+ 'src/core/skills/bundled',
+ 'dist/core/skills/bundled',
+] as const;
+
+/** Resolve bundled skills root from the source tree or compiled extension output. */
+export function resolveBundledSkillsRoot(packageRoot: string): string | undefined {
+ for (const suffix of CANDIDATE_SUFFIXES) {
+ const candidate = join(packageRoot, suffix);
+ if (hasSkillDirs(candidate)) return candidate;
+ }
+ return undefined;
+}
+
+function hasSkillDirs(root: string): boolean {
+ if (!existsSync(root)) return false;
+ try {
+ return readdirSync(root).some((entry) => {
+ const abs = join(root, entry);
+ try {
+ return statSync(abs).isDirectory() && existsSync(join(abs, 'SKILL.md'));
+ } catch {
+ return false;
+ }
+ });
+ } catch {
+ return false;
+ }
+}
diff --git a/src/node/cli.ts b/src/node/cli.ts
index 484d8a77..57bcb0f0 100644
--- a/src/node/cli.ts
+++ b/src/node/cli.ts
@@ -2,7 +2,8 @@
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { join, resolve } from 'path';
import { AuditPackBuilder, verifyAuditPack } from '../core/audit';
-import { HeadlessAgentRunner, generateHeadlessChangelog, prepareHeadlessRelease } from '../core/headless';
+import { HeadlessAgentHost, generateHeadlessChangelog, prepareHeadlessRelease } from '../core/headless';
+import type { HeadlessRuntime } from '../core/headless/HeadlessConfig';
import type { ProviderType } from '../core/config/schema';
async function main(argv: string[]): Promise {
@@ -57,31 +58,43 @@ async function main(argv: string[]): Promise {
}
if (command === 'ask') {
- const runner = createRunner(cwd, args, json);
- const answer = await runner.ask(prompt || readStdin());
- process.stdout.write(json ? JSON.stringify({ answer }, null, 2) + '\n' : `${answer}\n`);
- return 0;
+ const host = createHost(cwd, args);
+ try {
+ const answer = await host.ask(prompt || readStdin());
+ process.stdout.write(json ? JSON.stringify({ answer }, null, 2) + '\n' : `${answer}\n`);
+ return 0;
+ } finally {
+ host.dispose();
+ }
}
if (command === 'plan') {
- const runner = createRunner(cwd, args, json);
- const plan = runner.plan(prompt || readStdin());
- process.stdout.write(JSON.stringify(plan, null, 2) + '\n');
- return 0;
+ const host = createHost(cwd, args);
+ try {
+ const plan = await host.plan(prompt || readStdin());
+ process.stdout.write(JSON.stringify(plan, null, 2) + '\n');
+ return 0;
+ } finally {
+ host.dispose();
+ }
}
if (command === 'agent') {
- const runner = createRunner(cwd, args, json);
- for await (const event of runner.agent(prompt || readStdin())) {
- if (json) {
- process.stdout.write(JSON.stringify(event) + '\n');
- } else if (event.content) {
- process.stdout.write(`${event.content}\n`);
- } else if (event.message) {
- process.stderr.write(`${event.message}\n`);
+ const host = createHost(cwd, args);
+ try {
+ for await (const event of host.agent(prompt || readStdin())) {
+ if (json) {
+ process.stdout.write(JSON.stringify(event) + '\n');
+ } else if (event.content) {
+ process.stdout.write(`${event.content}\n`);
+ } else if (event.message) {
+ process.stderr.write(`${event.message}\n`);
+ }
}
+ return 0;
+ } finally {
+ host.dispose();
}
- return 0;
}
if (command === 'commit-msg') {
@@ -94,14 +107,19 @@ async function main(argv: string[]): Promise {
return 1;
}
-function createRunner(cwd: string, args: string[], json: boolean): HeadlessAgentRunner {
- return new HeadlessAgentRunner({
+function createHost(cwd: string, args: string[]): HeadlessAgentHost {
+ const provider = (valueOf(args, '--provider') as ProviderType | undefined) ?? 'echo';
+ const runtime = (valueOf(args, '--runtime') as HeadlessRuntime | undefined)
+ ?? (provider === 'echo' ? 'stub' : 'real');
+ return new HeadlessAgentHost({
cwd,
- providerType: (valueOf(args, '--provider') as ProviderType | undefined) ?? 'echo',
+ runtime,
+ providerType: provider,
baseUrl: valueOf(args, '--base-url'),
model: valueOf(args, '--model'),
- approval: (valueOf(args, '--approval') as 'auto' | 'manual' | undefined) ?? 'manual',
- json,
+ approval: (valueOf(args, '--approval') as 'auto' | 'manual' | undefined) ?? 'auto',
+ enablePuppeteer: args.includes('--enable-puppeteer'),
+ allowNetwork: args.includes('--allow-network'),
});
}
@@ -160,9 +178,9 @@ function printHelp(): void {
' mitii prepare-release [--since ] [--cwd ] [--json]',
' mitii export-audit [--session ] [--output ] [--cwd ] [--json]',
' mitii verify-audit [--cwd ] [--json]',
- ' mitii ask "question" [--provider echo|openai|...] [--model ] [--base-url ] [--cwd ] [--json]',
- ' mitii plan "goal" [--provider echo|openai|...] [--model ] [--approval auto|manual] [--cwd ]',
- ' mitii agent "goal" [--provider echo|openai|...] [--model ] [--approval auto|manual] [--json]',
+ ' mitii ask "question" [--runtime real|stub] [--provider echo|openai|...] [--model ] [--base-url ] [--cwd ] [--json]',
+ ' mitii plan "goal" [--runtime real|stub] [--provider echo|openai|...] [--model ] [--approval auto|manual] [--cwd ]',
+ ' mitii agent "goal" [--runtime real|stub] [--provider echo|openai|...] [--model ] [--approval auto|manual] [--enable-puppeteer] [--allow-network] [--json]',
'',
].join('\n'));
}
diff --git a/src/node/vscode-shim.ts b/src/node/vscode-shim.ts
new file mode 100644
index 00000000..7fe5cba7
--- /dev/null
+++ b/src/node/vscode-shim.ts
@@ -0,0 +1,54 @@
+/**
+ * Minimal VS Code API shim for headless CLI / benchmark runs.
+ * Production extension code still uses the real `vscode` module.
+ */
+
+export const Uri = {
+ file: (path: string) => ({ scheme: 'file', fsPath: path, path }),
+};
+
+export const workspace = {
+ getConfiguration: (_section?: string) => ({
+ get: (_key: string, defaultValue?: T) => defaultValue as T,
+ }),
+ workspaceFolders: undefined as undefined | Array<{ uri: { fsPath: string } }>,
+ asRelativePath: (uri: { fsPath?: string; path?: string }, _includeWorkspaceFolder?: boolean) => {
+ const path = uri.fsPath ?? uri.path ?? '';
+ return path.split('/').pop() ?? path;
+ },
+ createFileSystemWatcher: () => ({
+ onDidChange: () => ({ dispose: () => undefined }),
+ onDidCreate: () => ({ dispose: () => undefined }),
+ onDidDelete: () => ({ dispose: () => undefined }),
+ dispose: () => undefined,
+ }),
+};
+
+export const window = {
+ activeTextEditor: undefined,
+ tabGroups: { all: [] as Array<{ tabs: unknown[] }> },
+ showInformationMessage: async (..._args: unknown[]) => undefined,
+ showWarningMessage: async (..._args: unknown[]) => undefined,
+ showErrorMessage: async (..._args: unknown[]) => undefined,
+ showOpenDialog: async () => undefined,
+};
+
+export const languages = {
+ getDiagnostics: () => [] as Array<[unknown, unknown[]]>,
+ DiagnosticSeverity: { Error: 0, Warning: 1, Information: 2, Hint: 3 },
+};
+
+export const DiagnosticSeverity = languages.DiagnosticSeverity;
+
+export class RelativePattern {
+ constructor(
+ readonly base: { fsPath: string },
+ readonly pattern: string
+ ) {}
+}
+
+export const commands = {
+ executeCommand: async (..._args: unknown[]) => undefined,
+};
+
+export type Uri = ReturnType;
diff --git a/src/vscode/webview/ThunderWebviewProvider.ts b/src/vscode/webview/ThunderWebviewProvider.ts
index 5359aa41..a202cbcb 100644
--- a/src/vscode/webview/ThunderWebviewProvider.ts
+++ b/src/vscode/webview/ThunderWebviewProvider.ts
@@ -74,6 +74,14 @@ export class ThunderWebviewProvider implements vscode.WebviewViewProvider {
this.postMessage({ type: 'state', payload: this.state });
});
+ this.controller.setPreservedUiGetter(() => ({
+ tab: this.state.tab,
+ mode: this.state.mode,
+ messages: this.state.messages,
+ currentSessionId: this.state.currentSessionId,
+ chatHistory: this.state.chatHistory,
+ loading: this.state.loading,
+ }));
this.controller.setAutoFixCallback(async (message) => {
await this.runChatCompletion(message, true);
});
@@ -322,6 +330,21 @@ export class ThunderWebviewProvider implements vscode.WebviewViewProvider {
await this.controller.testProviderConnection(message.payload);
break;
+ case 'saveProviderProfile':
+ await this.controller.saveProviderProfile(message.payload);
+ await this.syncState();
+ break;
+
+ case 'selectProviderProfile':
+ await this.controller.selectProviderProfile(message.payload.id);
+ await this.syncState();
+ break;
+
+ case 'deleteProviderProfile':
+ await this.controller.deleteProviderProfile(message.payload.id);
+ await this.syncState();
+ break;
+
case 'pickWorkspaceFolder':
await this.controller.pickWorkspaceFolder();
await this.syncState();
diff --git a/src/vscode/webview/messages.ts b/src/vscode/webview/messages.ts
index fe2f83b4..ac12c1a1 100644
--- a/src/vscode/webview/messages.ts
+++ b/src/vscode/webview/messages.ts
@@ -310,6 +310,20 @@ export interface SettingsView {
checkpointStrategy: 'file-copy' | 'git-stash' | 'shadow-git';
showReasoning: boolean;
reasoningPreviewMaxChars: number;
+ providerProfiles: ProviderProfileView[];
+ activeProviderProfileId: string | null;
+}
+
+export interface ProviderProfileView {
+ id: string;
+ name: string;
+ providerType: string;
+ baseUrl: string;
+ model: string;
+ apiVersion: string;
+ region: string;
+ contextWindow: number;
+ hasApiKey: boolean;
}
export interface McpServerStatusView {
@@ -367,6 +381,8 @@ export interface WebviewState {
workspaceNotice: WorkspaceNoticeView | null;
tokenUsage: TokenUsageView;
workspaceTrusted: boolean;
+ settingsSaving: boolean;
+ testingConnection: boolean;
}
export type WorkspaceNoticeView = {
@@ -415,6 +431,9 @@ export type WebviewToExtensionMessage =
| { type: 'saveMcpSettings'; payload: McpSettingsPayload }
| { type: 'saveAllSettings'; payload: ThunderSettingsPayload }
| { type: 'testProviderConnection'; payload?: ProviderSettingsPayload }
+ | { type: 'saveProviderProfile'; payload: { id?: string; name?: string; settings: ProviderSettingsPayload; apiKey?: string } }
+ | { type: 'selectProviderProfile'; payload: { id: string } }
+ | { type: 'deleteProviderProfile'; payload: { id: string } }
| { type: 'pickWorkspaceFolder' }
| { type: 'setWorkspaceOverride'; payload: { path: string } }
| { type: 'clearWorkspaceOverride' }
@@ -442,6 +461,7 @@ export const defaultMcpToggles = (): McpToggles => ({
filesystem: true,
memory: true,
sequentialThinking: true,
+ puppeteer: false,
});
export const defaultContextToggles = (): ContextToggles => ({
@@ -503,6 +523,8 @@ export const defaultSettingsView = (): SettingsView => ({
checkpointStrategy: 'git-stash',
showReasoning: true,
reasoningPreviewMaxChars: 8000,
+ providerProfiles: [],
+ activeProviderProfileId: null,
});
export const initialWebviewState = (): WebviewState => ({
@@ -567,4 +589,6 @@ export const initialWebviewState = (): WebviewState => ({
breakdown: [],
},
workspaceTrusted: true,
+ settingsSaving: false,
+ testingConnection: false,
});
diff --git a/src/webview-ui/src/App.tsx b/src/webview-ui/src/App.tsx
index 90edbcf4..49cce48b 100644
--- a/src/webview-ui/src/App.tsx
+++ b/src/webview-ui/src/App.tsx
@@ -17,7 +17,8 @@ import { PlanPanel } from './components/PlanPanel';
import { DevPanels } from './components/DevPanels';
import { IconButton } from './components/IconButton';
import { IconChat, IconHistory, IconPlus, IconSettings } from './components/Icons';
-import { deriveSafetySettings } from './utils/approvalMode';
+import { deriveSafetyFromAutonomyPreset } from './utils/autonomyPreset';
+import type { AutonomyPreset } from './utils/autonomyPreset';
import type { AgentDepthView, AgentSettingsPayload, SettingsView } from '../../vscode/webview/messages';
import type { ThunderMode } from '../../core/session/ThunderSession';
@@ -211,7 +212,7 @@ export function App() {
postMessage({ type: 'stopGeneration' })}
onModeChange={(mode) => postMessage({ type: 'setMode', payload: mode })}
- onApprovalModeChange={(approvalMode) =>
+ onAutonomyPresetChange={(preset: AutonomyPreset) =>
postMessage({
type: 'saveSafetySettings',
- payload: deriveSafetySettings(approvalMode),
+ payload: deriveSafetyFromAutonomyPreset(preset),
})
}
onDepthChange={(depth) => {
@@ -265,7 +266,6 @@ export function App() {
settings={state.settings}
workspaceOpen={state.workspaceOpen}
workspacePath={state.workspacePath}
- vscodeWorkspaceFolders={state.vscodeWorkspaceFolders}
workspaceOverride={state.workspaceOverride}
usingWorkspaceOverride={state.usingWorkspaceOverride}
indexDbPath={state.indexDbPath}
@@ -298,6 +298,17 @@ export function App() {
onDeleteMemory={(id) => postMessage({ type: 'deleteMemory', payload: { id } })}
onClearMemory={() => postMessage({ type: 'clearMemory' })}
onRestoreCheckpoint={(id) => postMessage({ type: 'restoreCheckpoint', payload: { id } })}
+ settingsSaving={state.settingsSaving}
+ testingConnection={state.testingConnection}
+ onSaveProviderProfile={(payload) =>
+ postMessage({ type: 'saveProviderProfile', payload })
+ }
+ onSelectProviderProfile={(id) =>
+ postMessage({ type: 'selectProviderProfile', payload: { id } })
+ }
+ onDeleteProviderProfile={(id) =>
+ postMessage({ type: 'deleteProviderProfile', payload: { id } })
+ }
/>
)}
diff --git a/src/webview-ui/src/components/ChatInput.tsx b/src/webview-ui/src/components/ChatInput.tsx
index 9e404ffb..a22540c9 100644
--- a/src/webview-ui/src/components/ChatInput.tsx
+++ b/src/webview-ui/src/components/ChatInput.tsx
@@ -1,7 +1,6 @@
import { useState, useCallback, type KeyboardEvent, useRef, useEffect } from 'react';
import type { ThunderMode } from '../../../core/session/ThunderSession';
import type {
- ApprovalMode,
AgentDepthView,
ChatImageAttachment,
ContextPathSuggestion,
@@ -11,12 +10,13 @@ import type {
import { IconButton } from './IconButton';
import { IconChevronDown, IconCopy, IconImage, IconMarkdown, IconRetry, IconSend, IconStop } from './Icons';
import { TokenMeter } from './TokenMeter';
-import { APPROVAL_MODE_OPTIONS, deriveSafetySettings } from '../utils/approvalMode';
+import { AUTONOMY_PRESET_OPTIONS, deriveSafetyFromAutonomyPreset } from '../utils/autonomyPreset';
+import type { AutonomyPreset } from '../utils/autonomyPreset';
interface ChatInputProps {
loading: boolean;
mode: ThunderMode;
- approvalMode: ApprovalMode;
+ autonomyPreset: AutonomyPreset;
activeDepth: AgentDepthView;
tokenUsage: TokenUsageView;
pinnedContext: PinnedContextView[];
@@ -24,7 +24,7 @@ interface ChatInputProps {
onSend: (content: string, pinnedContext: PinnedContextView[], attachments: ChatImageAttachment[]) => void;
onStop?: () => void;
onModeChange: (mode: ThunderMode) => void;
- onApprovalModeChange: (mode: ApprovalMode) => void;
+ onAutonomyPresetChange: (preset: AutonomyPreset) => void;
onDepthChange: (depth: AgentDepthView) => void;
onRetry?: () => void;
onCopyResponse?: () => void;
@@ -55,7 +55,7 @@ const DEPTH_OPTIONS: Array<{ id: AgentDepthView; label: string; title: string }>
export function ChatInput({
loading,
mode,
- approvalMode,
+ autonomyPreset,
activeDepth,
tokenUsage,
pinnedContext,
@@ -63,7 +63,7 @@ export function ChatInput({
onSend,
onStop,
onModeChange,
- onApprovalModeChange,
+ onAutonomyPresetChange,
onDepthChange,
onRetry,
onCopyResponse,
@@ -84,8 +84,8 @@ export function ChatInput({
const textareaRef = useRef(null);
const fileInputRef = useRef(null);
const activeMode = MODES.find((m) => m.id === mode) ?? MODES[0];
- const activeApproval =
- APPROVAL_MODE_OPTIONS.find((option) => option.id === approvalMode) ?? APPROVAL_MODE_OPTIONS[0];
+ const activeAutonomy =
+ AUTONOMY_PRESET_OPTIONS.find((option) => option.id === autonomyPreset) ?? AUTONOMY_PRESET_OPTIONS[1];
const selectedDepth = DEPTH_OPTIONS.find((option) => option.id === activeDepth) ?? DEPTH_OPTIONS[0];
useEffect(() => {
@@ -301,12 +301,12 @@ export function ChatInput({
);
}
diff --git a/src/webview-ui/src/components/WorkspaceSettingsSection.tsx b/src/webview-ui/src/components/WorkspaceSettingsSection.tsx
index d051d0a7..211f96b6 100644
--- a/src/webview-ui/src/components/WorkspaceSettingsSection.tsx
+++ b/src/webview-ui/src/components/WorkspaceSettingsSection.tsx
@@ -1,16 +1,14 @@
import { useState, useEffect } from 'react';
-import { AGENT_NAME } from '../../../shared/brand';
-import type { IndexingStatusView, WorkspaceNoticeView } from '../../../vscode/webview/messages';
-import { SettingNote } from './SettingNote';
+import type { IndexingStatusView, VectorIndexStatusView, WorkspaceNoticeView } from '../../../vscode/webview/messages';
interface WorkspaceSettingsSectionProps {
workspaceOpen: boolean;
workspacePath: string;
- vscodeWorkspaceFolders: string[];
workspaceOverride: string;
usingWorkspaceOverride: boolean;
indexDbPath: string;
indexing: IndexingStatusView;
+ vectorIndex: VectorIndexStatusView;
workspaceNotice: WorkspaceNoticeView | null;
onPickFolder: () => void;
onSetOverride: (path: string) => void;
@@ -21,11 +19,11 @@ interface WorkspaceSettingsSectionProps {
export function WorkspaceSettingsSection({
workspaceOpen,
workspacePath,
- vscodeWorkspaceFolders,
workspaceOverride,
usingWorkspaceOverride,
indexDbPath,
indexing,
+ vectorIndex,
workspaceNotice,
onPickFolder,
onSetOverride,
@@ -35,94 +33,62 @@ export function WorkspaceSettingsSection({
const [overrideInput, setOverrideInput] = useState(workspaceOverride);
const runTotal = indexing.runTotal ?? 0;
const processed = Math.min(indexing.processed ?? 0, runTotal);
- const activeWorkers = indexing.activeWorkers ?? 0;
const progressPct = runTotal > 0 ? Math.round((processed / runTotal) * 100) : 0;
- const progressLabel = indexing.running
- ? `${processed}/${runTotal} files · ${indexing.queued} queued${activeWorkers > 0 ? ` · ${activeWorkers} active` : ''}`
- : indexing.failed > 0
- ? `${indexing.indexed}${indexing.total > 0 ? `/${indexing.total}` : ''} indexed · ${indexing.failed} failed`
- : `${indexing.indexed}${indexing.total > 0 ? `/${indexing.total}` : ''} indexed`;
+ const indexLabel = indexing.running
+ ? `${processed}/${runTotal} files`
+ : `${indexing.indexed}${indexing.total > 0 ? ` / ${indexing.total}` : ''} indexed`;
useEffect(() => {
setOverrideInput(workspaceOverride);
}, [workspaceOverride]);
return (
-
- Workspace
-
-
- {AGENT_NAME} needs a project root to index files, run tools, and build context.
- When you debug with F5, the Extension Host may open the monorepo folder — not your app.
- Use Browse or paste the absolute path to the project you want the agent to work on
- (e.g. your Kitchen KOT app), then Save & apply and Index.
-
-
+
{workspaceNotice && (
-
+
{workspaceNotice.message}
)}
-
-
-
Effective path
-
- {workspaceOpen ? workspacePath : 'Not set'}
-
+
+
+ Indexed files
+ {indexLabel}
+ {indexing.failed > 0 && !indexing.running && (
+ {indexing.failed} failed
+ )}
+
+
+ Queued
+ {indexing.queued}
-
-
Source
-
{usingWorkspaceOverride ? 'Saved override' : 'VS Code open folder'}
+
+ Embedded chunks
+ {vectorIndex.embeddedChunks.toLocaleString()}
-
-
Indexed files
-
{progressLabel}
+
+ Source
+
+ {usingWorkspaceOverride ? 'Override' : 'VS Code folder'}
+
- {indexDbPath && (
-
- Index database: {indexDbPath}
-
- )}
-
-
-
-
VS Code open folders (this window)
- {vscodeWorkspaceFolders.length === 0 ? (
-
- No folder is open in this window. That is normal when debugging {AGENT_NAME} itself.
- Set a workspace path override below — it is saved even without an open folder.
- Launch config tip: use Run Extension (parent monorepo) or open your target project folder.
-
- ) : (
-
- {vscodeWorkspaceFolders.map((folder) => (
- -
- {folder}
-
- ))}
-
- )}
-
-