diff --git a/.github/workflows/mitii-agent.yml b/.github/workflows/mitii-agent.yml
new file mode 100644
index 00000000..e2ed1261
--- /dev/null
+++ b/.github/workflows/mitii-agent.yml
@@ -0,0 +1,28 @@
+name: mitii agent
+
+on:
+ issue_comment:
+ types: [created]
+
+jobs:
+ mitii:
+ if: contains(github.event.comment.body, '/mitii')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+ issues: write
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm run compile:cli
+ - run: node dist/cli.js agent "${{ github.event.comment.body }}" --provider echo --approval auto --json > mitii-events.jsonl
+ - uses: actions/upload-artifact@v4
+ with:
+ name: mitii-audit
+ path: mitii-events.jsonl
diff --git a/.github/workflows/native-binaries.yml b/.github/workflows/native-binaries.yml
new file mode 100644
index 00000000..e760a735
--- /dev/null
+++ b/.github/workflows/native-binaries.yml
@@ -0,0 +1,38 @@
+name: native binaries
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+
+jobs:
+ build:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: macos-14
+ target: darwin-arm64
+ - os: macos-13
+ target: darwin-x64
+ - os: ubuntu-latest
+ target: linux-x64
+ - os: windows-latest
+ target: win32-x64
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm --filter mitii build:platform
+ env:
+ MITII_PLATFORM_TARGET: ${{ matrix.target }}
+ - uses: actions/upload-artifact@v4
+ with:
+ name: mitii-${{ matrix.target }}
+ path: dist-native/${{ matrix.target }}
diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml
new file mode 100644
index 00000000..8ba33b61
--- /dev/null
+++ b/.github/workflows/npm-publish.yml
@@ -0,0 +1,32 @@
+name: npm publish
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ registry-url: https://registry.npmjs.org
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm sync:versions
+ - run: pnpm -r build
+ - run: pnpm test
+ - run: npm publish --workspace packages/sdk --access public
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ - run: npm publish --workspace packages/daemon --access public
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9df92d3f..f995d8a5 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,7 +19,7 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run compile
- - run: pnpm exec vsce package
+ - run: pnpm run package
- name: Generate release notes
run: node dist/cli.js changelog > release-notes.md
- uses: softprops/action-gh-release@v2
diff --git a/.gitignore b/.gitignore
index 4c331ae6..fa703b86 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,8 +7,17 @@ dist/
.thunder/
.thunder V2/
.mitii
+.mitii/
+.mitti/
project-goals/
mitii-debug-logs/
+vendor/
+tmp/
+logs/
+build/
+coverage/
+*.lock
+*.map
# Benchmark / eval runtime (local generation and reports)
tools/benchmark/tasks/eval/generated/
diff --git a/.mitiiignore b/.mitiiignore
new file mode 100644
index 00000000..448e81ac
--- /dev/null
+++ b/.mitiiignore
@@ -0,0 +1,11 @@
+# Mitii workspace indexing ignores
+node_modules/
+dist/
+build/
+.next/
+coverage/
+vendor/
+tmp/
+logs/
+*.lock
+*.map
diff --git a/README.md b/README.md
index b5e186d0..1b4c2d65 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
-
+
@@ -33,6 +33,49 @@ Mitii is built for developers who want an AI agent that understands the repo bef
Use it with Ollama, LM Studio, OpenAI-compatible endpoints, native OpenRouter, Azure OpenAI, AWS Bedrock, OpenAI, Anthropic, Gemini, DeepSeek, Cursor-compatible APIs, Codex-compatible APIs, or the Echo provider for UI testing.
+## Phase 2 Runtime
+
+Mitii can run as a shared HTTP/SSE daemon:
+
+```bash
+mitii serve --cwd /path/to/project
+curl http://127.0.0.1:4310/health
+```
+
+External clients can use `@mitii/sdk/daemon` to create sessions, stream events with replay, respond to approvals, and cancel turns.
+
+Phase 2 also adds typed subagents through `spawn_subagent` while preserving `spawn_research_agent` as a compatibility alias. Built-ins are `research`, `implementer`, `reviewer`, and `verifier`; workspace agents can be added under `.mitii/agents/`.
+
+Parallel worktree tasks are available from the CLI:
+
+```bash
+mitii task add "Implement feature" --prompt "Implement the scoped feature and verify it"
+mitii task run --parallel 2
+mitii task worktrees
+```
+
+See `docs/users/mitii-serve.md`, `docs/developers/mitii-serve-protocol.md`, and `docs/users/parallel-agents.md`.
+
+## Phase 3 Platform Maturity
+
+Phase 3 adds distribution, scale, external workflow, and enterprise surfaces while keeping legacy `thunder.*` compatibility.
+
+| Area | What is available |
+|---|---|
+| Native distribution | `packages/cli`, platform optional package manifests, release matrix workflow, `scripts/install.sh`, `scripts/install.ps1`, and `pnpm run sync:versions` |
+| npm publishing | `@mitii/sdk`, `@mitii/daemon`, board/channels package metadata, and `.github/workflows/npm-publish.yml` |
+| Brand migration | `mitii.*` settings and command IDs are primary; `thunder.*` reads still fall back and `Mitii: Migrate Settings from thunder.* to mitii.*` copies old values |
+| Indexing at scale | Daemon-backed index worker API: `GET /index/status`, `POST /index/enqueue`, `POST /index/delete`, `POST /index/repair` |
+| CLI index admin | `mitii index status --json`, `mitii index repair`, `mitii index enqueue [paths...]`, `mitii index watch` |
+| GitHub PR flow | `GitHubPullRequestService` and `mitii pr create --title "..." --body-file .mitii/pr-body.md` |
+| Async jobs | Durable `.mitii/jobs/queue.json`, `mitii job enqueue`, `mitii job list`, and `mitii worker --once` |
+| Channels | Shared channel runtime plus Telegram polling connector: `mitii connect telegram --token "$TELEGRAM_BOT_TOKEN"` |
+| Persistent teams | `~/.mitii/teams//manifest.json`, task board, mailbox, mission log, and `mitii team ...` commands |
+| Marketplace | Open VSX publish script, native binary workflow, GitHub Actions example, Cursor env rename, and JetBrains companion workflow docs |
+| Enterprise scale | Managed policy docs, channel security docs, and air-gapped bundle script |
+
+Default external-write surfaces remain opt-in: auto PR creation, persistent teams, and channel connectors must be explicitly enabled or started.
+
**Docs:** [docs.mitii.dev](https://docs.mitii.dev)
**Website:** [mitii.dev](https://mitii.dev)
**Discord:** [discord.gg/sa8rubf6HH](https://discord.gg/sa8rubf6HH)
@@ -88,6 +131,7 @@ flowchart LR
Context --> Index[SQLite + FTS5 + Symbols]
Context --> Vectors[MiniLM or Hash Vectors]
Context --> RepoMap[PageRank Repo Map]
+ Context --> CallGraph[Call Graph]
Context --> Git[Git Diff + SCM]
Context --> Issues[GitHub Issues]
Context --> LSP[Diagnostics]
@@ -114,8 +158,9 @@ Mitii creates a useful working map of your repository before it asks the model t
| Full-text search | SQLite FTS5 with ripgrep fallback for paths not yet indexed |
| Symbol extraction | TypeScript, JavaScript, Python, Java, Go; tree-sitter with regex fallback |
| Repo map | PageRank-style scoring to surface structurally important files |
+| Call graph | `CallGraphContextSource` resolves symbols and callers through the VS Code language service, with unsaved-editor content sync |
| Vector search | MiniLM embeddings through `@xenova/transformers`, with hash fallback |
-| Vector backend | SQLite by default, LanceDB optional |
+| Vector backend | LanceDB by default, SQLite optional; embedding/vector components report runtime health and surface a "degraded" state in Settings if native modules fail to load |
| Context reranking | Trims noisy candidates, for example top 20 down to top 8 |
| Token budgeting | Keeps context useful without blindly flooding the model |
@@ -152,7 +197,7 @@ Fix https://github.com/owner/repo/issues/123
Mitii detects `github.com/{owner}/{repo}/issues/{number}`, fetches the issue through the GitHub REST API when network access is allowed, and injects a structured context block containing the title, body, state, labels, assignees, milestone, and newest comments. When the fetch succeeds, the Act router treats the issue signal as a verified bugfix workflow, so the agent investigates the open workspace, makes scoped edits, and runs relevant verification.
-If the active safety preset disables network access or GitHub cannot be reached, Mitii still injects a lightweight reference block with the repository and issue number instead of scraping GitHub HTML. Private repository support uses a GitHub token stored in VS Code SecretStorage under `thunder.github.token` by default; enter or replace the token from **Settings → Integrations → GitHub issues**. The VS Code setting stores only the secret key name, not the token value.
+If the active safety preset disables network access or GitHub cannot be reached, Mitii still injects a lightweight reference block with the repository and issue number instead of scraping GitHub HTML. Private repository support uses a GitHub token stored in VS Code SecretStorage under `mitii.github.token` by default; enter or replace the token from **Settings → Integrations → GitHub issues**. The VS Code setting stores only the secret key name, not the token value.
### 4. Safer Tool Execution
@@ -196,13 +241,14 @@ Mitii stores useful state locally so every serious task does not start from zero
| System | What it stores |
|---|---|
-| Memory | Decisions, facts, observations, touched files |
+| Memory | Decisions, facts, observations, touched files in SQLite |
+| Markdown auto-memory | Human-readable summaries in `~/.mitii/projects//memory/` or `.mitii/auto-memory/` |
| Session history | `agent_sessions` and `agent_turns` in SQLite |
| Plans | `task_plans` in SQLite and `.mitii/tasks/` files |
| Logs | JSONL session logs in `.mitii/logs/` |
| Checkpoints | File-copy, git-stash, or shadow-git strategies before writes |
-Post-task memory extraction can capture useful observations after completed work, so future sessions can reuse decisions without asking you to repeat context.
+Post-task memory extraction can capture useful observations after completed work, so future sessions can reuse decisions without asking you to repeat context. Markdown auto-memory is optional, secret-filtered, and controlled by `mitii.memory.autoMemoryEnabled` plus `mitii.memory.autoMemoryScope`.
Audit review is available through `Mitii: Export Audit Pack`. The zip contains sanitized `session.jsonl`, `summary.md`, `manifest.json`, `tool-audit.json`, `approvals.json`, `redaction-report.json`, and `signature.json` with SHA-256 hashes for tamper detection. Set `MITII_AUDIT_SIGNING_KEY` to add HMAC signing, then verify archives with `mitii verify-audit `.
@@ -241,8 +287,10 @@ Mitii can preload keyless MCP servers:
| `filesystem` | Scoped file access for the open workspace |
| `memory` | Cross-session knowledge graph |
| `sequential-thinking` | Structured reasoning helper |
+| `puppeteer` | Optional browser automation |
+| `agentmemory` | Optional enterprise memory backend at `http://localhost:3111/mcp` |
-You can add custom servers with `thunder.mcp.servers`, `.mitii/mcp.json`, or `.mcp.json`. MCP tools appear as `mcp__server__tool` and still pass through the same approval policy.
+You can add custom servers with `mitii.mcp.servers`, `.mitii/mcp.json`, or `.mcp.json`. MCP tools appear as `mcp__server__tool` and still pass through the same approval policy. See [docs/integrations/agentmemory.md](docs/integrations/agentmemory.md) for the optional agentmemory setup.
### 9. Developer UI
@@ -260,10 +308,11 @@ The sidebar is a React webview with:
| Token meter | Understand context usage |
| Indexing status | Know when the workspace brain is ready |
| Context warnings | See when context may be thin or over budget |
+| Code block copy | One-click clipboard copy on rendered code blocks in chat |
-Reasoning deltas from supported providers stream live in the chat UI. Use `thunder.ui.showReasoning` and `thunder.ui.reasoningPreviewMaxChars` to control visibility and inline preview size.
+Reasoning deltas from supported providers stream live in the chat UI. Use `mitii.ui.showReasoning` and `mitii.ui.reasoningPreviewMaxChars` to control visibility and inline preview size.
-Mitii also detects common model capabilities from the provider/model name, including vision and reasoning support. Enterprise teams can override detection with `thunder.provider.supportsVision` and `thunder.provider.supportsReasoning` when routing through private or custom OpenAI-compatible gateways.
+Mitii also detects common model capabilities from the provider/model name, including vision and reasoning support. Enterprise teams can override detection with `mitii.provider.supportsVision` and `mitii.provider.supportsReasoning` when routing through private or custom OpenAI-compatible gateways.
## Enterprise Readiness
@@ -271,13 +320,13 @@ Enterprise review materials live in [docs/enterprise](docs/enterprise/README.md)
| Control | Setting or command |
|---|---|
-| Route narrow Git/release tasks through minimal context | `thunder.context.microTaskRoutingEnabled` |
-| Require local model providers | `thunder.enterprise.localProvidersOnly` |
-| Strip file contents from exported audit packs | `thunder.enterprise.stripFileContentsFromAuditPacks` |
-| Auto-export audit packs after agent turns | `thunder.enterprise.autoExportAuditPackOnSessionEnd` |
+| Route narrow Git/release tasks through minimal context | `mitii.context.microTaskRoutingEnabled` |
+| Require local model providers | `mitii.enterprise.localProvidersOnly` |
+| Strip file contents from exported audit packs | `mitii.enterprise.stripFileContentsFromAuditPacks` |
+| Auto-export audit packs after agent turns | `mitii.enterprise.autoExportAuditPackOnSessionEnd` |
| Verify audit pack integrity | `mitii verify-audit ` |
-| Disable session logging | `thunder.telemetry.sessionLogging` |
-| Stream sanitized SIEM events | `thunder.telemetry.webhookUrl` and optional `thunder.telemetry.webhookSecret` |
+| Disable session logging | `mitii.telemetry.sessionLogging` |
+| Stream sanitized SIEM events | `mitii.telemetry.webhookUrl` and optional `mitii.telemetry.webhookSecret` |
| Export audit evidence | `Mitii: Export Audit Pack` |
| Windows smoke checklist | [docs/qa/WINDOWS_SMOKE.md](docs/qa/WINDOWS_SMOKE.md) |
@@ -396,6 +445,26 @@ This is why Mitii focuses on visible plans, local logs, checkpoints, and verific
## Quick Start
+### Install
+
+Release channels:
+
+```bash
+curl -fsSL https://mitii.dev/install.sh | bash
+npm i -g mitii
+brew install mitii/tap/mitii
+```
+
+Editor marketplaces:
+
+```bash
+pnpm run package # local VSIX
+pnpm run publish:vsce # VS Code Marketplace
+pnpm run publish:ovsx # Open VSX
+```
+
+Native release assets are produced by `.github/workflows/native-binaries.yml`; npm package publishing is handled by `.github/workflows/npm-publish.yml`.
+
**Requirements**
| Tool | Version |
@@ -415,9 +484,9 @@ pnpm run setup
### Connect A Model
1. Open **Settings** in the Mitii sidebar, or VS Code settings under `Mitii AI Agent`.
-2. Set `thunder.provider.type` to `openai-compatible`, `openrouter`, `azure-openai`, or another supported provider.
-3. Point `thunder.provider.baseUrl` at your endpoint. The default is `http://localhost:11434/v1` for Ollama.
-4. Set `thunder.provider.model`. The default is `qwen3-coder:30b`.
+2. Set `mitii.provider.type` to `openai-compatible`, `openrouter`, `azure-openai`, or another supported provider.
+3. Point `mitii.provider.baseUrl` at your endpoint. The default is `http://localhost:11434/v1` for Ollama.
+4. Set `mitii.provider.model`. The default is `qwen3-coder:30b`.
Use the Echo provider for UI testing without an LLM. API keys are stored through VS Code SecretStorage.
@@ -428,8 +497,8 @@ Use the Echo provider for UI testing without an LLM. API keys are stored through
| OpenAI-compatible | `qwen3-coder:30b` | Ollama, LM Studio, vLLM, local gateways |
| OpenRouter | `anthropic/claude-sonnet-4` | Native headers and reasoning deltas |
| OpenAI | `gpt-4.1` | API key required |
-| Azure OpenAI | `your-deployment-name` | API key required; model field is the deployment name; uses `thunder.provider.apiVersion` |
-| AWS Bedrock | `anthropic.claude-3-5-sonnet-20240620-v1:0` | Uses AWS default credential chain and `thunder.provider.region`; tool calls disabled by default |
+| Azure OpenAI | `your-deployment-name` | API key required; model field is the deployment name; uses `mitii.provider.apiVersion` |
+| AWS Bedrock | `anthropic.claude-3-5-sonnet-20240620-v1:0` | Uses AWS default credential chain and `mitii.provider.region`; tool calls disabled by default |
| Anthropic | `claude-sonnet-4-20250514` | API key required |
| Gemini | `gemini-2.0-flash` | API key required |
| DeepSeek | `deepseek-chat` | API key required |
@@ -452,6 +521,20 @@ Use the Echo provider for UI testing without an LLM. API keys are stored through
| `Mitii: Accept Inline Diff` | Accept a pending inline diff |
| `Mitii: Reject Inline Diff` | Reject a pending inline diff |
| `Mitii: Generate Commit Message` | Generate a commit message from Source Control |
+| `Mitii: Migrate Settings from thunder.* to mitii.*` | Copy legacy settings into the new namespace without deleting old values |
+
+CLI additions:
+
+```bash
+mitii index status --json
+mitii index repair
+mitii pr create --title "Fix issue" --body-file .mitii/pr-body.md
+mitii job enqueue "Run this later" --mode agent
+mitii worker --once
+mitii connect telegram --token "$TELEGRAM_BOT_TOKEN"
+mitii team create sprint
+mitii team status sprint
+```
---
@@ -459,27 +542,37 @@ Use the Echo provider for UI testing without an LLM. API keys are stored through
```json
{
- "thunder.provider.type": "openai-compatible",
- "thunder.provider.baseUrl": "http://localhost:11434/v1",
- "thunder.provider.model": "qwen3-coder:30b",
- "thunder.provider.apiVersion": "2024-10-21",
- "thunder.provider.region": "us-east-1",
- "thunder.provider.contextWindow": 8192,
- "thunder.safety.autonomyPreset": "guided",
- "thunder.safety.approvalMode": "review_all",
- "thunder.indexing.autoIndexOnOpen": true,
- "thunder.indexing.vectorsEnabled": true,
- "thunder.indexing.vectorBackend": "sqlite",
- "thunder.context.rerankerEnabled": true,
- "thunder.memory.enabled": true,
- "thunder.mcp.enabled": true,
- "thunder.github.issueFetchEnabled": true,
- "thunder.github.issueCommentLimit": 8,
- "thunder.github.tokenRef": "thunder.github.token",
- "thunder.agent.verifyCommands": ["npm run lint", "npm test"],
- "thunder.telemetry.sessionLogging": true,
- "thunder.telemetry.webhookUrl": "",
- "thunder.enterprise.autoExportAuditPackOnSessionEnd": false
+ "mitii.provider.type": "openai-compatible",
+ "mitii.provider.baseUrl": "http://localhost:11434/v1",
+ "mitii.provider.model": "qwen3-coder:30b",
+ "mitii.provider.apiVersion": "2024-10-21",
+ "mitii.provider.region": "us-east-1",
+ "mitii.provider.contextWindow": 8192,
+ "mitii.safety.autonomyPreset": "guided",
+ "mitii.safety.approvalMode": "review_all",
+ "mitii.indexing.autoIndexOnOpen": true,
+ "mitii.indexing.vectorsEnabled": true,
+ "mitii.indexing.vectorBackend": "lancedb",
+ "mitii.indexing.watchDebounceMs": 500,
+ "mitii.indexing.priorityPaths": [],
+ "mitii.context.rerankerEnabled": true,
+ "mitii.memory.enabled": true,
+ "mitii.memory.summarizeAfterTask": true,
+ "mitii.memory.autoMemoryEnabled": true,
+ "mitii.memory.autoMemoryScope": "user",
+ "mitii.mcp.enabled": true,
+ "mitii.github.issueFetchEnabled": true,
+ "mitii.github.issueCommentLimit": 8,
+ "mitii.github.tokenRef": "mitii.github.token",
+ "mitii.github.autoPrEnabled": false,
+ "mitii.github.defaultBaseBranch": "",
+ "mitii.agent.verifyCommands": ["npm run lint", "npm test"],
+ "mitii.agent.teamsEnabled": false,
+ "mitii.telemetry.sessionLogging": true,
+ "mitii.telemetry.webhookUrl": "",
+ "mitii.enterprise.autoExportAuditPackOnSessionEnd": false,
+ "mitii.enterprise.channelsDisabled": false,
+ "mitii.enterprise.maxParallel": 10
}
```
@@ -493,19 +586,29 @@ Mitii automatically picks up common project instruction files:
| File or folder | Purpose |
|---|---|
-| `AGENTS.md` | Agent instructions |
-| `CLAUDE.md` | Claude-style project guidance |
-| `WARP.md` | Warp-style workflow guidance |
-| `.cursorrules` | Cursor rules |
-| `.cursor/rules` | Cursor rule directory |
-| `.clinerules` | Cline rules |
-| `.continue/rules` | Continue rules |
-| `.mitii/rules` | Mitii project rules |
-| `.mitii/agents` | Agent-specific instructions |
-| `.mitii/checks` | Verification guidance |
-| `.mitii/prompts` | Reusable prompts |
-
-Commit these files to your repo when you want every Mitii session to start with the same engineering conventions.
+| `~/.mitii/MITTII.md` | Personal global instructions |
+| `MITII.md` | Shared workspace instructions |
+| `AGENTS.md` | Compatibility instructions |
+| `CLAUDE.md` | Compatibility instructions |
+| `.mitii/rules/**/*.md` | Shared Mitii rule directory |
+| `.cursor/rules/**/*.md` | Cursor rule compatibility |
+| `.mitii/MITTII.local.md` | Personal workspace override, loaded last |
+
+Commit shared files to your repo when you want every Mitii session to start with the same engineering conventions. `@path/to/file` references inside rule files are resolved relative to the rule file and inlined within the rules budget.
+
+## SDK And CLI
+
+The publishable SDK lives in `packages/sdk` and exposes `createClient()` plus `query()` for Node 20+ projects:
+
+```ts
+import { query } from '@mitii/sdk';
+
+for await (const event of query({ cwd: process.cwd(), prompt: 'Summarize the repo', mode: 'agent' })) {
+ if (event.type === 'assistant_delta') process.stdout.write(event.content);
+}
+```
+
+The CLI uses the same SDK contract for one-shot commands. `mitii agent "..." --json` emits one typed event per line. Running `mitii` with no args opens an interactive terminal session; `mitii init` creates a starter `MITII.md`; `mitii auth` stores headless provider credentials in `~/.mitii/credentials.json`; `mitii memory connect agentmemory` wires the optional agentmemory MCP entry.
---
@@ -564,7 +667,8 @@ mitii-ai-agent/ # VS Code extension (ships as .vsix)
│ ├── fixtures/ # Pinned sample repos
│ ├── tasks/enterprise/ # ~26 fixed benchmark tasks
│ └── tasks/eval/ # Generated 500–1000 task shards
-├── pnpm-workspace.yaml # tools/* workspace packages
+├── packages/sdk/ # @mitii/sdk headless runtime package
+├── pnpm-workspace.yaml # tools/* and packages/* workspace packages
└── package.json
```
@@ -592,8 +696,11 @@ pnpm run eval:generate # generate 500 standard eval tasks
pnpm run eval:smoke # eval wiring check (CI)
pnpm run eval:standard -- --provider openai-compatible \
--base-url http://localhost:11434/v1 --model qwen3-coder:30b --limit 50
+pnpm run eval:retrieval # Recall@5/10, nDCG@10, MRR for HybridRetriever against a hand-labeled query set
```
+`eval:retrieval` runs `test/benchmark/retrieval-eval.test.ts` against the `saas-api` fixture (112 files, 17 domain modules) and 68 hand-labeled queries in `tools/benchmark/datasets/retrieval-eval.json`, writing `.mitii/benchmark/retrieval-report.{json,md}`. It is additive instrumentation only — HybridRetriever's actual ranking logic is unchanged.
+
### Native Rebuilds
VS Code and Cursor ship their own Electron runtime, so native modules may need a rebuild.
@@ -601,14 +708,14 @@ VS Code and Cursor ship their own Electron runtime, so native modules may need a
| Scenario | Command |
|---|---|
| VS Code Extension Development Host | `pnpm run rebuild:native` |
-| Cursor Extension Development Host | `THUNDER_EDITOR=cursor pnpm run rebuild:native` |
+| Cursor Extension Development Host | `MITII_EDITOR=cursor pnpm run rebuild:native` |
| Local Vitest runs | `pnpm run rebuild:node` |
| Everything | `pnpm run rebuild:all` |
On Linux and Windows, Electron version auto-detection is not available. Set the version explicitly:
```bash
-THUNDER_ELECTRON_VERSION= pnpm run rebuild:native
+MITII_ELECTRON_VERSION= pnpm run rebuild:native
```
For example, use the Electron version shipped by your VS Code or Cursor build.
@@ -634,7 +741,7 @@ Bundled skills orchestrate these scripts instead of replacing them. `audit-clean
| Problem | Fix |
|---|---|
-| `better-sqlite3` fails to load | Run `pnpm run rebuild:native` for VS Code or `THUNDER_EDITOR=cursor pnpm run rebuild:native` for Cursor |
+| `better-sqlite3` fails to load | Run `pnpm run rebuild:native` for VS Code or `MITII_EDITOR=cursor pnpm run rebuild:native` for Cursor |
| Provider errors | Check base URL, model name, and API key. Try Echo provider to isolate UI issues |
| Indexing feels empty | Check `.gitignore`, `.mitiiignore`, workspace write access, then run `Mitii: Index Workspace` |
| Context feels thin | Wait for indexing, enable vectors, check context warnings, and mention important files directly |
diff --git a/docs/deploy/launchd.plist b/docs/deploy/launchd.plist
new file mode 100644
index 00000000..27e941ad
--- /dev/null
+++ b/docs/deploy/launchd.plist
@@ -0,0 +1,22 @@
+
+
+
+
+ Label
+ dev.mitii.serve
+ ProgramArguments
+
+ /usr/bin/env
+ mitii
+ serve
+ --hostname
+ 127.0.0.1
+ --port
+ 4310
+
+ RunAtLoad
+
+ KeepAlive
+
+
+
diff --git a/docs/deploy/systemd-user.service b/docs/deploy/systemd-user.service
new file mode 100644
index 00000000..6ddfaec9
--- /dev/null
+++ b/docs/deploy/systemd-user.service
@@ -0,0 +1,11 @@
+[Unit]
+Description=Mitii daemon
+
+[Service]
+Type=simple
+EnvironmentFile=%h/.mitii-serve-env
+ExecStart=/usr/bin/env mitii serve --cwd %h/project --hostname 127.0.0.1 --port 4310
+Restart=on-failure
+
+[Install]
+WantedBy=default.target
diff --git a/docs/developers/mitii-serve-protocol.md b/docs/developers/mitii-serve-protocol.md
new file mode 100644
index 00000000..8951bc64
--- /dev/null
+++ b/docs/developers/mitii-serve-protocol.md
@@ -0,0 +1,75 @@
+# Mitii Serve Protocol
+
+`mitii serve` runs one daemon per bound workspace. It exposes a loopback-first HTTP API plus per-session SSE event streams.
+
+Default bind: `127.0.0.1:4310`.
+
+## Security
+
+- Loopback is the default.
+- Binding to `0.0.0.0` or another non-loopback host requires `--insecure-bind`.
+- Non-loopback binds also require `--token` or `MITII_SERVER_TOKEN`.
+- Clients authenticate with `Authorization: Bearer `.
+- CORS is denied unless `--allow-origin` is set.
+- Daemon actions are appended to `.mitii/daemon/audit.jsonl`.
+
+## Routes
+
+`GET /health`
+
+```json
+{ "ok": true, "version": "2.7.31", "cwd": "/repo", "sessions": 1 }
+```
+
+`GET /capabilities`
+
+```json
+{
+ "features": ["sessions", "sse", "permissions", "cancel", "subagents", "worktrees"],
+ "maxSessions": 5,
+ "supportedModes": ["ask", "plan", "agent", "review"],
+ "eventReplay": true
+}
+```
+
+`POST /session`
+
+```json
+{ "cwd": "/repo", "mode": "agent", "approval": "manual", "runtime": "real" }
+```
+
+`GET /sessions`, `GET /session/:id`, and `DELETE /session/:id` list, inspect, and close sessions.
+
+`POST /session/:id/prompt`
+
+```json
+{ "mode": "agent", "message": "Fix the failing test", "attachments": [] }
+```
+
+Returns `202` once the prompt is accepted. Results stream through SSE.
+
+`GET /session/:id/events`
+
+SSE frames use `id`, `event`, and JSON `data` fields. Reconnect with `Last-Event-ID` to replay buffered missed events.
+
+```text
+id: 7
+event: assistant_delta
+data: {"type":"assistant_delta","content":"I found the issue"}
+```
+
+`POST /session/:id/permissions/:approvalId/respond`
+
+```json
+{ "decision": "approved" }
+```
+
+`POST /session/:id/cancel` aborts an in-flight turn.
+
+## Error Shape
+
+```json
+{ "error": { "code": "workspace_mismatch", "message": "Daemon is bound to ..." } }
+```
+
+Common statuses: `400` workspace mismatch, `401` auth failure, `404` missing session, `409` concurrent prompt, `503` session limit.
diff --git a/docs/enterprise/PROCUREMENT_FAQ.md b/docs/enterprise/PROCUREMENT_FAQ.md
index 1822b3aa..c19a2511 100644
--- a/docs/enterprise/PROCUREMENT_FAQ.md
+++ b/docs/enterprise/PROCUREMENT_FAQ.md
@@ -10,18 +10,18 @@ Mitii targets macOS, Linux, and Windows. CI runs `npm test` on `ubuntu-latest`,
## Offline And Local Models
-Mitii supports OpenAI-compatible localhost providers such as Ollama and LM Studio. Set `thunder.enterprise.localProvidersOnly` to enforce local-only usage.
+Mitii supports OpenAI-compatible localhost providers such as Ollama and LM Studio. Set `mitii.enterprise.localProvidersOnly` to enforce local-only usage.
## Controls Procurement Teams Ask For
| Control | Setting or command |
|---|---|
-| Disable session logging | `thunder.telemetry.sessionLogging` |
-| Disable verbose diagnostics | `thunder.telemetry.debugMetrics` |
-| Require approval for writes | `thunder.safety.requireApprovalForWrites` |
-| Require approval for shell | `thunder.safety.requireApprovalForShell` |
-| Local providers only | `thunder.enterprise.localProvidersOnly` |
-| Strip file contents from audit packs | `thunder.enterprise.stripFileContentsFromAuditPacks` |
+| Disable session logging | `mitii.telemetry.sessionLogging` |
+| Disable verbose diagnostics | `mitii.telemetry.debugMetrics` |
+| Require approval for writes | `mitii.safety.requireApprovalForWrites` |
+| Require approval for shell | `mitii.safety.requireApprovalForShell` |
+| Local providers only | `mitii.enterprise.localProvidersOnly` |
+| Strip file contents from audit packs | `mitii.enterprise.stripFileContentsFromAuditPacks` |
| Export review evidence | `Mitii: Export Audit Pack` |
## License
diff --git a/docs/enterprise/README.md b/docs/enterprise/README.md
index d45ea43f..a52633ed 100644
--- a/docs/enterprise/README.md
+++ b/docs/enterprise/README.md
@@ -7,5 +7,5 @@ This folder is the reviewer-facing security, compliance, and procurement packet
- [Procurement FAQ](PROCUREMENT_FAQ.md)
- [Enterprise overview](MITII_ENTERPRISE_OVERVIEW.md)
-Key controls are implemented as VS Code settings under `thunder.telemetry.*`, `thunder.safety.*`, `thunder.enterprise.*`, and the `Mitii: Export Audit Pack` command.
+Key controls are implemented as VS Code settings under `mitii.telemetry.*`, `mitii.safety.*`, `mitii.enterprise.*`, and the `Mitii: Export Audit Pack` command.
diff --git a/docs/enterprise/SECURITY.md b/docs/enterprise/SECURITY.md
index 810fe3f5..01a727e6 100644
--- a/docs/enterprise/SECURITY.md
+++ b/docs/enterprise/SECURITY.md
@@ -4,15 +4,15 @@ Mitii is local-first. Workspace indexes, memory, plans, checkpoints, and JSONL s
## What Leaves The Machine
-Only prompt data sent to the configured LLM provider leaves the machine. The provider boundary is controlled by `thunder.provider.*`. Enterprises can enable `thunder.enterprise.localProvidersOnly` to require Echo or a localhost OpenAI-compatible endpoint.
+Only prompt data sent to the configured LLM provider leaves the machine. The provider boundary is controlled by `mitii.provider.*`. Enterprises can enable `mitii.enterprise.localProvidersOnly` to require Echo or a localhost OpenAI-compatible endpoint.
## Secrets
-API keys are stored in VS Code SecretStorage. Logs and audit packs redact keys named like API keys, tokens, passwords, and secrets. Audit packs can additionally strip tool output and file content with `thunder.enterprise.stripFileContentsFromAuditPacks`.
+API keys are stored in VS Code SecretStorage. Logs and audit packs redact keys named like API keys, tokens, passwords, and secrets. Audit packs can additionally strip tool output and file content with `mitii.enterprise.stripFileContentsFromAuditPacks`.
## MCP Risk Model
-MCP tools are routed through Mitii tool policy. File writes and mutating shell commands require approval according to `thunder.safety.approvalMode` and `thunder.safety.requireApprovalForWrites`.
+MCP tools are routed through Mitii tool policy. File writes and mutating shell commands require approval according to `mitii.safety.approvalMode` and `mitii.safety.requireApprovalForWrites`.
## Auditability
diff --git a/docs/enterprise/channel-security.md b/docs/enterprise/channel-security.md
new file mode 100644
index 00000000..b4b8ba94
--- /dev/null
+++ b/docs/enterprise/channel-security.md
@@ -0,0 +1,15 @@
+# Channel Security
+
+Channel connectors route Slack, Telegram, Discord, and similar messages into the daemon. Treat them as external ingress.
+
+Baseline controls:
+
+| Control | Mitii behavior |
+| --- | --- |
+| User allowlist | Connectors can reject users not present in `allowedUsers`. |
+| Thread allowlist | Connectors can restrict use to approved channel/thread IDs. |
+| Read-only mode | Channel policy can downgrade `agent` requests to `plan`. |
+| Secret redaction | Long token-like values are redacted from outbound replies. |
+| Auditability | Channel prompts flow through daemon sessions and session logs. |
+
+Enterprise deployments should keep connectors bound to loopback daemons or require `MITII_SERVER_TOKEN` for non-loopback hosts.
diff --git a/docs/enterprise/daemon-security.md b/docs/enterprise/daemon-security.md
new file mode 100644
index 00000000..50af45ca
--- /dev/null
+++ b/docs/enterprise/daemon-security.md
@@ -0,0 +1,12 @@
+# Daemon Security
+
+`mitii serve` is designed for local development first.
+
+- It binds to `127.0.0.1` by default.
+- Non-loopback binds require both `--insecure-bind` and a bearer token.
+- Bearer tokens are compared with constant-time comparison.
+- CORS is opt-in through `--allow-origin`.
+- Session `cwd` values are canonicalized and must match the daemon-bound workspace.
+- Session lifecycle, prompts, cancellation, and permission responses are logged to `.mitii/daemon/audit.jsonl`.
+
+For enterprise-managed installs, prefer a per-user daemon, managed token provisioning, local provider policy enforcement, and workspace-level audit pack export.
diff --git a/docs/enterprise/managed-policy.md b/docs/enterprise/managed-policy.md
new file mode 100644
index 00000000..1878f90d
--- /dev/null
+++ b/docs/enterprise/managed-policy.md
@@ -0,0 +1,27 @@
+# Managed Enterprise Policy
+
+Mitii can be governed with a managed `.mitii/mitii.policy.json` file distributed by MDM, base images, or repository templates. Policy values are intentionally coarse and override user-facing convenience features before a connector, worker, or team runtime starts.
+
+```json
+{
+ "localProvidersOnly": true,
+ "channelsDisabled": false,
+ "maxParallel": 10,
+ "autoPrEnabled": false,
+ "stripFileContentsFromAuditPacks": true,
+ "allowedChannelUsers": [],
+ "allowedChannelThreads": []
+}
+```
+
+Recommended controls:
+
+| Policy | Purpose |
+| --- | --- |
+| `localProvidersOnly` | Require local or approved private model providers. |
+| `channelsDisabled` | Disable Slack, Telegram, Discord, and similar external surfaces. |
+| `maxParallel` | Cap parallel sessions, workers, and teammates. |
+| `autoPrEnabled` | Keep PR creation explicit unless centrally approved. |
+| `stripFileContentsFromAuditPacks` | Preserve audit metadata while reducing data exposure. |
+
+Phase 3 treats this as the stable policy contract. Future SSO/OIDC and HA daemon work should extend this file instead of introducing a second policy surface.
diff --git a/docs/integrations/agentmemory.md b/docs/integrations/agentmemory.md
new file mode 100644
index 00000000..537e7c92
--- /dev/null
+++ b/docs/integrations/agentmemory.md
@@ -0,0 +1,42 @@
+# agentmemory Integration
+
+Mitii can use agentmemory as an optional MCP backend for cross-agent or team memory. The built-in Mitii memory remains the simplest option for one workspace because it uses local SQLite plus markdown auto-memory files.
+
+Use agentmemory when you need shared recall across tools, a long-running memory server, or knowledge-graph style memory. Mitii connects through MCP:
+
+```text
+Mitii -> MCP streamable HTTP -> agentmemory -> iii-engine
+```
+
+## Enable
+
+Start agentmemory on port `3111`, then run:
+
+```bash
+mitii memory connect agentmemory
+```
+
+This writes `.mitii/mcp.json`:
+
+```json
+{
+ "mcpServers": {
+ "agentmemory": {
+ "type": "streamable-http",
+ "url": "http://localhost:3111/mcp",
+ "disabled": false
+ }
+ }
+}
+```
+
+You can also enable the built-in `agentmemory` toggle in Mitii settings. It is off by default and does not conflict with the built-in `memory` MCP server.
+
+## Verify
+
+```bash
+curl http://localhost:3111/agentmemory/livez
+mitii agent "remember this project uses pnpm" --json --approval auto
+```
+
+If the server is not running, Mitii shows the MCP startup error in Settings. Data sent to agentmemory leaves Mitii's local SQLite store and enters the agentmemory service, so review enterprise retention and audit policies before enabling it for sensitive workspaces.
diff --git a/docs/integrations/github-actions.md b/docs/integrations/github-actions.md
new file mode 100644
index 00000000..25f34674
--- /dev/null
+++ b/docs/integrations/github-actions.md
@@ -0,0 +1,25 @@
+# GitHub Actions
+
+Mitii can run headlessly in CI for comment-triggered tasks.
+
+```yaml
+on:
+ issue_comment:
+ types: [created]
+
+jobs:
+ mitii:
+ if: contains(github.event.comment.body, '/mitii')
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm run compile:cli
+ - run: node dist/cli.js agent "${{ github.event.comment.body }}" --provider echo --approval auto --json
+```
+
+For real providers, pass secrets through environment variables and keep approval mode conservative on public repositories.
diff --git a/docs/logging-system.md b/docs/logging-system.md
new file mode 100644
index 00000000..99208872
--- /dev/null
+++ b/docs/logging-system.md
@@ -0,0 +1,187 @@
+# Mitii AI Agent Logging System
+
+## Overview
+
+The Mitii AI agent uses a structured logging system to record session activities for debugging, analysis, and post-hoc evaluation. All logs are stored in JSONL (JSON Lines) format in the workspace-specific `.mitii/logs/` directory.
+
+## File Structure
+
+Logs are stored in the following location:
+```
+/.mitii/logs/-.jsonl
+```
+
+Where:
+- `` is the current working directory of the agent
+- `` is a timestamp in format `YYYY-MM-DD_HH-MM-SS`
+- `` is a unique identifier for the session
+
+## JSONL Format
+
+Each log entry is a single JSON object on its own line in the file. The structure includes:
+
+```json
+{
+ "ts": 1700000000000,
+ "time": "2023-11-15T10:00:00.000Z",
+ "sessionId": "session-12345",
+ "type": "user_message",
+ "message": "Hello there",
+ "data": {
+ "content": "Hello there"
+ }
+}
+```
+
+### Required Fields
+- `ts`: Unix timestamp of when the event occurred
+- `time`: ISO formatted timestamp
+- `sessionId`: Unique identifier for the session
+- `type`: Type of event (see below)
+- `message`: Human-readable description of the event
+
+### Optional Fields
+- `data`: Additional structured data related to the event
+
+## SessionLogEvent Types
+
+The logging system supports 21+ event types:
+
+```typescript
+export type SessionLogEventType =
+ | 'session_start'
+ | 'session_end'
+ | 'user_message'
+ | 'assistant_message'
+ | 'tool_start'
+ | 'tool_end'
+ | 'subagent_start'
+ | 'subagent_end'
+ | 'approval_request'
+ | 'approval_decision'
+ | 'plan_created'
+ | 'plan_step'
+ | 'context_pack'
+ | 'token_usage'
+ | 'process_start'
+ | 'process_end'
+ | 'timing'
+ | 'error'
+ | 'info'
+ | 'workspace_resolved'
+ | 'index_start'
+ | 'index_complete'
+ | 'turn_complete'
+ | 'ui_trace'
+ | 'microtask_context'
+ | 'audit_export';
+```
+
+### Event Type Descriptions
+
+- **session_start**: Agent session begins
+- **session_end**: Agent session ends
+- **user_message**: User sends a message to the agent
+- **assistant_message**: Agent responds to user
+- **tool_start**: Tool execution begins
+- **tool_end**: Tool execution completes
+- **subagent_start**: Sub-agent execution begins
+- **subagent_end**: Sub-agent execution completes
+- **approval_request**: Approval requested from user
+- **approval_decision**: User makes approval decision
+- **plan_created**: Execution plan created
+- **plan_step**: Plan step executed
+- **context_pack**: Context pack loaded
+- **token_usage**: Token usage statistics
+- **process_start**: Process execution begins
+- **process_end**: Process execution completes
+- **timing**: Timing information for operations
+- **error**: Error occurred during execution
+- **info**: General informational message
+- **workspace_resolved**: Workspace resolved and configured
+- **index_start**: Indexing operation begins
+- **index_complete**: Indexing operation completes
+- **turn_complete**: Conversation turn completed
+- **ui_trace**: UI trace information
+- **microtask_context**: Microtask context information
+- **audit_export**: Audit export operation
+
+## Security and Privacy Measures
+
+### Append-Only Mode
+The logging system uses append-only file mode (`appendFileSync`) to prevent accidental overwrites.
+
+### Directory Creation
+Directories are automatically created recursively using `{ recursive: true }` to ensure the log path exists before writing.
+
+### Secret Redaction
+Sensitive information is automatically redacted from logs:
+- API keys, passwords, tokens are removed or masked
+- Token usage metrics are preserved for analysis
+- No sensitive data is written to log files
+
+### Workspace Isolation
+Each workspace maintains its own isolated logging directory structure, preventing cross-contamination between different projects.
+
+## Access Control and Security
+
+### Why Specific Log Files Cannot Be Accessed
+
+Several factors can prevent access to specific JSONL log files:
+
+1. **Missing Directory Structure**: The `.mitii` directory may not exist in the workspace
+2. **File Permissions**: Process may lack write permissions to the workspace directory
+3. **Workspace Path Issues**: Invalid or non-existent workspace paths passed to logging system
+4. **Process Isolation**: In sandboxed environments (VS Code extensions), file access may be restricted by security policies
+5. **Session Context**: Log files are only created when a session is active
+
+### Error Handling
+
+The logging system implements graceful error handling:
+- File operations that fail do not crash the execution
+- Errors are logged internally but execution continues
+- The system attempts to create directories recursively before writing
+- Path validation ensures safe construction using `join()`
+
+## Troubleshooting
+
+### Common Issues and Solutions
+
+1. **"No such file or directory" errors**:
+ - Ensure the workspace directory exists and is accessible
+ - Check that the process has write permissions to the workspace
+
+2. **Permission denied errors**:
+ - Verify file system permissions on the workspace directory
+ - Run with appropriate user privileges if needed
+
+3. **Log files not appearing**:
+ - Confirm that logging is enabled in the configuration
+ - Ensure a valid session ID is being used
+ - Check that the agent has started and is actively running
+
+### Verification Commands
+
+To verify the logging system is working:
+
+```bash
+# Check if logs directory exists
+ls -la /.mitii/logs/
+
+# View recent log files
+find /.mitii/logs/ -name "*.jsonl" -type f -mtime -1 | head -5
+
+# Check file permissions
+ls -la /.mitii/
+```
+
+## Implementation Details
+
+The logging system is implemented in `SessionLogService.ts` and uses:
+- Node.js `fs` operations (`appendFileSync`, `mkdirSync`)
+- Path joining with `path.join()` for safe path construction
+- Recursive directory creation with `{ recursive: true }`
+- Automatic timestamp generation for unique filenames
+- Session ID tracking for log file identification
+
+The system is designed to be lightweight and non-intrusive, with minimal performance impact on agent execution.
\ No newline at end of file
diff --git a/docs/users/channels/slack.md b/docs/users/channels/slack.md
new file mode 100644
index 00000000..51074541
--- /dev/null
+++ b/docs/users/channels/slack.md
@@ -0,0 +1,5 @@
+# Slack Connector
+
+Slack support uses the same channel runtime contract as Telegram: channel/thread IDs map to daemon sessions, user IDs can be allowlisted, and outbound messages are redacted before posting.
+
+Phase 3 ships the channel architecture and Telegram polling connector first. Slack Socket Mode should use the same `ChannelRuntimeAdapter` with thread timestamp to session mapping and approval buttons for tool calls.
diff --git a/docs/users/channels/telegram.md b/docs/users/channels/telegram.md
new file mode 100644
index 00000000..c25c0417
--- /dev/null
+++ b/docs/users/channels/telegram.md
@@ -0,0 +1,25 @@
+# Telegram Connector
+
+Start the daemon:
+
+```bash
+mitii serve --token "$MITII_SERVER_TOKEN"
+```
+
+Start the connector:
+
+```bash
+mitii connect telegram --token "$TELEGRAM_BOT_TOKEN" --daemon-url http://127.0.0.1:4310
+```
+
+Commands:
+
+| Command | Mode |
+| --- | --- |
+| `/ask` | Ask mode |
+| `/plan` | Plan mode |
+| `/agent` | Agent mode |
+| `/status` | Session status prompt |
+| `/cancel` | Reserved for cancel support |
+
+Session mapping is persisted under `~/.mitii/connectors/telegram-sessions.json`.
diff --git a/docs/users/jetbrains-workflow.md b/docs/users/jetbrains-workflow.md
new file mode 100644
index 00000000..6fd68b99
--- /dev/null
+++ b/docs/users/jetbrains-workflow.md
@@ -0,0 +1,9 @@
+# JetBrains Workflow
+
+The Phase 3 JetBrains path is a thin companion workflow backed by `mitii serve`.
+
+1. Run `mitii serve` from the project root.
+2. Use the JetBrains terminal or HTTP client to call the daemon API.
+3. Keep VS Code-specific commands disabled; daemon sessions, CLI tasks, jobs, PR creation, and indexing are editor-independent.
+
+A future Kotlin/JCEF companion plugin should bind the open project root to the daemon and render the existing Mitii web UI in a tool window.
diff --git a/docs/users/mitii-serve.md b/docs/users/mitii-serve.md
new file mode 100644
index 00000000..d371b656
--- /dev/null
+++ b/docs/users/mitii-serve.md
@@ -0,0 +1,36 @@
+# Running `mitii serve`
+
+Start a local daemon:
+
+```bash
+mitii serve --cwd /path/to/repo
+```
+
+With auth:
+
+```bash
+openssl rand -hex 32 > ~/.mitii-serve-token
+MITII_SERVER_TOKEN="$(cat ~/.mitii-serve-token)" mitii serve --cwd /path/to/repo
+```
+
+Health check:
+
+```bash
+curl http://127.0.0.1:4310/health
+```
+
+Create a session:
+
+```bash
+curl -X POST http://127.0.0.1:4310/session \
+ -H "content-type: application/json" \
+ -d '{"cwd":"'"$PWD"'","mode":"agent","approval":"manual"}'
+```
+
+Stream events:
+
+```bash
+curl -N http://127.0.0.1:4310/session//events
+```
+
+The daemon is useful when VS Code, terminal clients, SDK scripts, and future board UIs need to share one long-running runtime.
diff --git a/docs/users/parallel-agents.md b/docs/users/parallel-agents.md
new file mode 100644
index 00000000..b0c4e442
--- /dev/null
+++ b/docs/users/parallel-agents.md
@@ -0,0 +1,31 @@
+# Parallel Agents
+
+Phase 2 adds two delegation patterns.
+
+| Pattern | Use when |
+| --- | --- |
+| Subagents | Research, review, verification, or tightly scoped implementation in the current branch |
+| Worktrees | Independent tasks that should run in isolated branches |
+| Daemon + board clients | Multiple clients need to attach to the same runtime |
+
+Create tasks:
+
+```bash
+mitii task add "Fix auth tests" --prompt "Fix the failing auth tests and verify them"
+mitii task list
+```
+
+Run runnable tasks in isolated worktrees:
+
+```bash
+mitii task run --parallel 2
+mitii task worktrees
+```
+
+Each worktree is registered in `.mitii/worktrees.json`, and task state is stored in `.mitii/tasks/board.json`.
+
+Custom subagents live in `.mitii/agents/`:
+
+```bash
+mitii agents init
+```
diff --git a/media/mitii-activitybar.svg b/media/mitii-activitybar.svg
index f212731a..00eb33c7 100644
--- a/media/mitii-activitybar.svg
+++ b/media/mitii-activitybar.svg
@@ -1,5 +1 @@
-
+
\ No newline at end of file
diff --git a/package.json b/package.json
index 7131002c..6cc20bd7 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.30",
+ "version": "2.7.51",
"publisher": "mitii",
"icon": "media/mitii-short-logo.png",
"license": "AGPL-3.0-or-later",
@@ -48,7 +48,15 @@
"onCommand:thunder.generateCommitMessage",
"onCommand:thunder.generateChangelog",
"onCommand:thunder.prepareRelease",
- "onCommand:thunder.exportAuditPack"
+ "onCommand:thunder.exportAuditPack",
+ "onCommand:mitii.openChat",
+ "onCommand:mitii.indexWorkspace",
+ "onCommand:mitii.showSettings",
+ "onCommand:mitii.generateCommitMessage",
+ "onCommand:mitii.generateChangelog",
+ "onCommand:mitii.prepareRelease",
+ "onCommand:mitii.exportAuditPack",
+ "onCommand:mitii.migrateThunderSettings"
],
"main": "./dist/extension.js",
"bin": {
@@ -116,6 +124,72 @@
"command": "thunder.prepareRelease",
"title": "Mitii: Prepare Release",
"category": "Mitii"
+ },
+ {
+ "command": "mitii.openChat",
+ "title": "Mitii: Open Chat",
+ "category": "Mitii"
+ },
+ {
+ "command": "mitii.indexWorkspace",
+ "title": "Mitii: Index Workspace",
+ "category": "Mitii"
+ },
+ {
+ "command": "mitii.showSettings",
+ "title": "Mitii: Show Settings",
+ "category": "Mitii"
+ },
+ {
+ "command": "mitii.exportSessionLog",
+ "title": "Mitii: Export Session Log",
+ "category": "Mitii"
+ },
+ {
+ "command": "mitii.exportAuditPack",
+ "title": "Mitii: Export Audit Pack",
+ "category": "Mitii"
+ },
+ {
+ "command": "mitii.openSessionLog",
+ "title": "Mitii: Open Session Log File",
+ "category": "Mitii"
+ },
+ {
+ "command": "mitii.acceptInlineDiff",
+ "title": "Mitii: Accept Inline Diff",
+ "category": "Mitii"
+ },
+ {
+ "command": "mitii.rejectInlineDiff",
+ "title": "Mitii: Reject Inline Diff",
+ "category": "Mitii"
+ },
+ {
+ "command": "mitii.showInlineDiff",
+ "title": "Mitii: Show Inline Diff",
+ "category": "Mitii"
+ },
+ {
+ "command": "mitii.generateCommitMessage",
+ "title": "Mitii: Generate Commit Message",
+ "category": "Mitii",
+ "icon": "media/mitii-activitybar.svg"
+ },
+ {
+ "command": "mitii.generateChangelog",
+ "title": "Mitii: Generate Changelog Entry",
+ "category": "Mitii"
+ },
+ {
+ "command": "mitii.prepareRelease",
+ "title": "Mitii: Prepare Release",
+ "category": "Mitii"
+ },
+ {
+ "command": "mitii.migrateThunderSettings",
+ "title": "Mitii: Migrate Settings from thunder.* to mitii.*",
+ "category": "Mitii"
}
],
"menus": {
@@ -124,6 +198,11 @@
"command": "thunder.generateCommitMessage",
"when": "scmProvider == git && gitOpenRepositoryCount != 0",
"group": "navigation"
+ },
+ {
+ "command": "mitii.generateCommitMessage",
+ "when": "scmProvider == git && gitOpenRepositoryCount != 0",
+ "group": "navigation"
}
]
},
@@ -151,61 +230,100 @@
"thunder.debug": {
"type": "boolean",
"default": false,
- "description": "Enable debug logging and show stack traces in UI"
+ "description": "Enable debug logging and show stack traces in UI",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.telemetry.sessionLogging": {
"type": "boolean",
"default": true,
- "description": "Write structured JSONL session logs to .mitii/logs/ for debugging and analysis."
+ "description": "Write structured JSONL session logs to .mitii/logs/ for debugging and analysis.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.telemetry.debugMetrics": {
"type": "boolean",
"default": false,
- "description": "Capture extra session diagnostics (tool inputs, context sources, LLM step metadata). Process timing is always logged when session logging is on."
+ "description": "Capture extra session diagnostics (tool inputs, context sources, LLM step metadata). Process timing is always logged when session logging is on.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.telemetry.webhookUrl": {
"type": "string",
"default": "",
- "description": "Optional SIEM/webhook endpoint. When set, sanitized session events are POSTed as JSON."
+ "description": "Optional SIEM/webhook endpoint. When set, sanitized session events are POSTed as JSON.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.telemetry.webhookSecret": {
"type": "string",
"default": "",
- "description": "Optional HMAC signing secret for webhook events. Prefer workspace or managed settings for enterprise deployments."
+ "description": "Optional HMAC signing secret for webhook events. Prefer workspace or managed settings for enterprise deployments.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.telemetry.webhookTimeoutMs": {
"type": "number",
"default": 5000,
"minimum": 1000,
"maximum": 60000,
- "description": "Timeout in milliseconds for telemetry webhook delivery attempts."
+ "description": "Timeout in milliseconds for telemetry webhook delivery attempts.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.ui.showReasoning": {
"type": "boolean",
"default": true,
- "description": "Show streamed model reasoning when the provider returns reasoning deltas."
+ "description": "Show streamed model reasoning when the provider returns reasoning deltas.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.ui.reasoningPreviewMaxChars": {
"type": "number",
"default": 8000,
"minimum": 0,
"maximum": 100000,
- "description": "Maximum reasoning characters shown inline. Set 0 to show all reasoning."
+ "description": "Maximum reasoning characters shown inline. Set 0 to show all reasoning.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.enterprise.localProvidersOnly": {
"type": "boolean",
"default": false,
- "description": "Enterprise policy flag documenting that only local providers should be used for this workspace."
+ "description": "Enterprise policy flag documenting that only local providers should be used for this workspace.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.enterprise.stripFileContentsFromAuditPacks": {
"type": "boolean",
"default": false,
- "description": "Strip file contents and large tool outputs from exported audit packs while preserving metadata."
+ "description": "Strip file contents and large tool outputs from exported audit packs while preserving metadata.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.enterprise.autoExportAuditPackOnSessionEnd": {
"type": "boolean",
"default": false,
- "description": "Export a sanitized audit pack automatically to .mitii/audit/ when an agent turn completes."
+ "description": "Export a sanitized audit pack automatically to .mitii/audit/ when an agent turn completes.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
+ },
+ "thunder.runtime.mode": {
+ "type": "string",
+ "enum": [
+ "embedded",
+ "daemon"
+ ],
+ "default": "embedded",
+ "description": "Run the VS Code chat in-process or attach to a running mitii serve daemon.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
+ },
+ "thunder.runtime.daemonUrl": {
+ "type": "string",
+ "default": "http://127.0.0.1:4310",
+ "description": "Mitii daemon URL used when thunder.runtime.mode is daemon.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
+ },
+ "thunder.runtime.daemonToken": {
+ "type": "string",
+ "default": "",
+ "description": "Optional bearer token for mitii serve. Prefer SecretStorage or managed settings for enterprise use.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
+ },
+ "thunder.runtime.autoStartDaemon": {
+ "type": "boolean",
+ "default": false,
+ "description": "Automatically start mitii serve when a workspace opens and daemon runtime mode is selected.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.provider.type": {
"type": "string",
@@ -223,77 +341,92 @@
"echo"
],
"default": "echo",
- "description": "LLM provider type"
+ "description": "LLM provider type",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.provider.baseUrl": {
"type": "string",
"default": "http://localhost:11434/v1",
- "description": "OpenAI-compatible API base URL"
+ "description": "OpenAI-compatible API base URL",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.provider.model": {
"type": "string",
"default": "qwen3-coder:30b",
- "description": "Model name"
+ "description": "Model name",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.provider.apiVersion": {
"type": "string",
"default": "2024-10-21",
- "description": "Provider API version. Used by Azure OpenAI when building deployment chat completion URLs."
+ "description": "Provider API version. Used by Azure OpenAI when building deployment chat completion URLs.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.provider.region": {
"type": "string",
"default": "us-east-1",
- "description": "Cloud provider region. Used by AWS Bedrock."
+ "description": "Cloud provider region. Used by AWS Bedrock.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.provider.contextWindow": {
"type": "number",
"default": 8192,
- "description": "Model context window size. Thunder trims prompts automatically to stay within this limit."
+ "description": "Model context window size. Thunder trims prompts automatically to stay within this limit.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.provider.supportsVision": {
"type": "boolean",
- "description": "Optional override for model vision support. Leave unset to use automatic provider/model detection."
+ "description": "Optional override for model vision support. Leave unset to use automatic provider/model detection.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.provider.supportsReasoning": {
"type": "boolean",
- "description": "Optional override for streamed reasoning support. Leave unset to use automatic provider/model detection."
+ "description": "Optional override for streamed reasoning support. Leave unset to use automatic provider/model detection.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.indexing.enabled": {
"type": "boolean",
"default": true,
- "description": "Enable workspace indexing"
+ "description": "Enable workspace indexing",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.indexing.autoIndexOnOpen": {
"type": "boolean",
"default": true,
- "description": "Automatically index the workspace when a folder is opened or switched."
+ "description": "Automatically index the workspace when a folder is opened or switched.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.indexing.maxFileSizeBytes": {
"type": "number",
"default": 512000,
- "description": "Max file size for full indexing in bytes. Larger files get signature-only indexing."
+ "description": "Max file size for full indexing in bytes. Larger files get signature-only indexing.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.indexing.hardSkipSizeBytes": {
"type": "number",
"default": 2000000,
- "description": "Skip files larger than this size entirely (e.g. lockfiles)."
+ "description": "Skip files larger than this size entirely (e.g. lockfiles).",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.indexing.maxConcurrency": {
"type": "number",
"default": 2,
"minimum": 1,
"maximum": 8,
- "description": "Number of files to index in parallel."
+ "description": "Number of files to index in parallel.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.indexing.vectorsEnabled": {
"type": "boolean",
"default": true,
- "description": "Enable semantic vector search. Uses MiniLM when @xenova/transformers is available, hash fallback otherwise."
+ "description": "Enable semantic vector search. Uses MiniLM when @xenova/transformers is available, hash fallback otherwise.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.indexing.treeSitterEnabled": {
"type": "boolean",
"default": true,
- "description": "Use tree-sitter WASM for symbol extraction (regex fallback when unavailable)"
+ "description": "Use tree-sitter WASM for symbol extraction (regex fallback when unavailable)",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.indexing.embeddingProvider": {
"type": "string",
@@ -302,7 +435,8 @@
"minilm"
],
"default": "minilm",
- "description": "Embedding provider when vectors are enabled. minilm uses local @xenova/transformers (all-MiniLM-L6-v2); falls back to hash if unavailable."
+ "description": "Embedding provider when vectors are enabled. minilm uses local @xenova/transformers (all-MiniLM-L6-v2); falls back to hash if unavailable.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.indexing.vectorBackend": {
"type": "string",
@@ -310,42 +444,49 @@
"sqlite",
"lancedb"
],
- "default": "sqlite",
- "description": "Vector index storage: sqlite (.mitii/mitii.sqlite) or lancedb (.mitii/lance/) via @lancedb/lancedb"
+ "default": "lancedb",
+ "description": "Vector index storage: sqlite (.mitii/mitii.sqlite) or lancedb (.mitii/lance/) via @lancedb/lancedb",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.context.rerankerEnabled": {
"type": "boolean",
"default": true,
- "description": "Rerank top retrieval candidates for better context quality (top-20 → top-8)"
+ "description": "Rerank top retrieval candidates for better context quality (top-20 → top-8)",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.context.rerankerCandidatePool": {
"type": "number",
"default": 20,
"minimum": 5,
"maximum": 50,
- "description": "Number of candidates to pass to the reranker"
+ "description": "Number of candidates to pass to the reranker",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.context.rerankerTopK": {
"type": "number",
"default": 8,
"minimum": 3,
"maximum": 30,
- "description": "Number of items to keep after reranking"
+ "description": "Number of items to keep after reranking",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.context.microTaskRoutingEnabled": {
"type": "boolean",
"default": true,
- "description": "Route narrow chat requests such as commit messages, changelog entries, and release notes through minimal Git context instead of the full agent loop."
+ "description": "Route narrow chat requests such as commit messages, changelog entries, and release notes through minimal Git context instead of the full agent loop.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.safety.requireApprovalForWrites": {
"type": "boolean",
"default": true,
- "description": "Require approval for file writes"
+ "description": "Require approval for file writes",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.safety.requireApprovalForShell": {
"type": "boolean",
"default": true,
- "description": "Require approval for shell commands"
+ "description": "Require approval for shell commands",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.safety.approvalMode": {
"type": "string",
@@ -364,7 +505,8 @@
"Auto-approve allowed tools and commands. Dangerous commands are still blocked."
],
"default": "review_all",
- "description": "Approval mode for agent operations"
+ "description": "Approval mode for agent operations",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.safety.autonomyPreset": {
"type": "string",
@@ -376,58 +518,123 @@
"enterprise"
],
"default": "guided",
- "description": "Autonomy preset"
+ "description": "Autonomy preset",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.memory.enabled": {
"type": "boolean",
"default": true,
- "description": "Enable local memory"
+ "description": "Enable local memory",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.memory.hybridSearchEnabled": {
"type": "boolean",
"default": true,
- "description": "Use FTS5 + optional vector hybrid search for observations"
+ "description": "Use FTS5 + optional vector hybrid search for observations",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
+ },
+ "thunder.memory.summarizeAfterTask": {
+ "type": "boolean",
+ "default": true,
+ "description": "Summarize completed tasks into durable local memory.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
+ },
+ "thunder.memory.autoMemoryEnabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Write post-task memories to human-readable markdown files in addition to SQLite observations.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
+ },
+ "thunder.memory.autoMemoryScope": {
+ "type": "string",
+ "enum": [
+ "user",
+ "workspace",
+ "both"
+ ],
+ "default": "user",
+ "description": "Where markdown auto-memory files are written: user profile, workspace .mitii/auto-memory, or both.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.scm.commitMessageEnabled": {
"type": "boolean",
"default": true,
- "description": "Show Mitii commit-message generation in VS Code Source Control."
+ "description": "Show Mitii commit-message generation in VS Code Source Control.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.github.issueFetchEnabled": {
"type": "boolean",
"default": true,
- "description": "Fetch structured GitHub issue context when a chat message contains a github.com owner/repo/issues URL and network access is allowed."
+ "description": "Fetch structured GitHub issue context when a chat message contains a github.com owner/repo/issues URL and network access is allowed.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.github.issueCommentLimit": {
"type": "number",
"default": 8,
"minimum": 0,
"maximum": 25,
- "description": "Maximum number of GitHub issue comments to include in injected issue context."
+ "description": "Maximum number of GitHub issue comments to include in injected issue context.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.github.tokenRef": {
"type": "string",
"default": "thunder.github.token",
- "description": "VS Code SecretStorage key for an optional GitHub token used to fetch private issues. The token itself is not stored in settings."
+ "description": "VS Code SecretStorage key for an optional GitHub token used to fetch private issues. The token itself is not stored in settings.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.agent.subagentsEnabled": {
"type": "boolean",
"default": true,
- "description": "Enable the spawn_research_agent tool for parallel read-only research."
+ "description": "Enable subagent tools for typed delegation.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
+ },
+ "thunder.agent.subagentTypesEnabled": {
+ "type": "array",
+ "default": [
+ "research"
+ ],
+ "items": {
+ "type": "string"
+ },
+ "description": "Allowed subagent types. Built-ins are research, implementer, reviewer, and verifier; custom .mitii/agents ids may also be listed.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
+ },
+ "thunder.agent.maxConcurrentSubagents": {
+ "type": "number",
+ "default": 2,
+ "minimum": 1,
+ "maximum": 10,
+ "description": "Maximum subagents allowed to run concurrently in one session.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
+ },
+ "thunder.agent.implementerRequiresScope": {
+ "type": "boolean",
+ "default": true,
+ "description": "Require implementer subagents to receive targetFiles or scopeRoot before write tools are available.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
+ },
+ "thunder.agent.subagentDailyBudget": {
+ "type": "number",
+ "default": 0,
+ "minimum": 0,
+ "description": "Maximum subagent invocations per session day. 0 means unlimited.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.agent.maxSteps": {
"type": "number",
"default": 15,
"minimum": 1,
"maximum": 100,
- "description": "Maximum tool-use steps before the main agent stops or auto-continues."
+ "description": "Maximum tool-use steps before the main agent stops or auto-continues.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.agent.askMaxSteps": {
"type": "number",
"default": 18,
"minimum": 1,
"maximum": 50,
- "description": "Maximum tool-use steps per Ask-mode turn (read-only exploration). Use 16-20 for deep codebase explanations."
+ "description": "Maximum tool-use steps per Ask-mode turn (read-only exploration). Use 16-20 for deep codebase explanations.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.agent.askDepth": {
"type": "string",
@@ -440,7 +647,8 @@
"enterprise"
],
"default": "auto",
- "description": "Ask-mode exploration depth. Auto chooses by intent; quick is concise; deep explores more before answering."
+ "description": "Ask-mode exploration depth. Auto chooses by intent; quick is concise; deep explores more before answering.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.agent.planDepth": {
"type": "string",
@@ -453,7 +661,8 @@
"enterprise"
],
"default": "auto",
- "description": "Plan-mode discovery depth before structured plan compilation. Auto chooses by intent; deep explores more of the codebase."
+ "description": "Plan-mode discovery depth before structured plan compilation. Auto chooses by intent; deep explores more of the codebase.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.agent.actDepth": {
"type": "string",
@@ -466,58 +675,68 @@
"enterprise"
],
"default": "auto",
- "description": "Agent/Act execution depth. Auto chooses by task route; quick caps direct work, deep allows up to 16 tool steps."
+ "description": "Agent/Act execution depth. Auto chooses by task route; quick caps direct work, deep allows up to 16 tool steps.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.agent.askAutoContinue": {
"type": "boolean",
"default": true,
- "description": "Allow Ask mode to continue when deep exploration reaches its per-round limit."
+ "description": "Allow Ask mode to continue when deep exploration reaches its per-round limit.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.agent.askMaxAutoContinues": {
"type": "number",
"default": 1,
"minimum": 0,
"maximum": 10,
- "description": "Maximum automatic continuation rounds for Ask mode."
+ "description": "Maximum automatic continuation rounds for Ask mode.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.agent.autoContinue": {
"type": "boolean",
"default": true,
- "description": "Allow the agent to keep working after it reaches the per-round step limit."
+ "description": "Allow the agent to keep working after it reaches the per-round step limit.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.agent.maxAutoContinues": {
"type": "number",
"default": 2,
"minimum": 0,
"maximum": 10,
- "description": "Maximum number of automatic continuation rounds after the main step limit."
+ "description": "Maximum number of automatic continuation rounds after the main step limit.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.agent.researchAgentMaxSteps": {
"type": "number",
"default": 10,
"minimum": 1,
"maximum": 50,
- "description": "Maximum tool-use steps for each read-only research subagent."
+ "description": "Maximum tool-use steps for each read-only research subagent.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.agent.researchAgentModel": {
"type": "string",
"default": "",
- "description": "Optional faster model for read-only research subagents. Empty uses the main provider model."
+ "description": "Optional faster model for read-only research subagents. Empty uses the main provider model.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.agent.researchAgentBaseUrl": {
"type": "string",
"default": "",
- "description": "Optional OpenAI-compatible base URL for the research subagent model. Empty uses thunder.provider.baseUrl."
+ "description": "Optional OpenAI-compatible base URL for the research subagent model. Empty uses thunder.provider.baseUrl.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.agent.orchestrationEnabled": {
"type": "boolean",
"default": true,
- "description": "Enable the multi-step planner in Plan mode. Act mode uses a faster direct agent path by default."
+ "description": "Enable the multi-step planner in Plan mode. Act mode uses a faster direct agent path by default.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.agent.showDiffPreview": {
"type": "boolean",
"default": false,
- "description": "Open VS Code diff preview tabs before write_file/apply_patch tool calls. Disabled by default to avoid editor focus changes."
+ "description": "Open VS Code diff preview tabs before write_file/apply_patch tool calls. Disabled by default to avoid editor focus changes.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.agent.verifyCommands": {
"type": "array",
@@ -525,32 +744,38 @@
"items": {
"type": "string"
},
- "description": "Optional shell commands to run after Act-mode completes. Leave empty to auto-discover from package.json scripts (typecheck, lint, test, build) based on touched files."
+ "description": "Optional shell commands to run after Act-mode completes. Leave empty to auto-discover from package.json scripts (typecheck, lint, test, build) based on touched files.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.agent.verifyOnActComplete": {
"type": "boolean",
"default": true,
- "description": "After Act-mode runs, discover and run project-appropriate verification (package.json scripts). Uses verifyCommands when set; otherwise auto-discovers."
+ "description": "After Act-mode runs, discover and run project-appropriate verification (package.json scripts). Uses verifyCommands when set; otherwise auto-discovers.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.agent.planModel": {
"type": "string",
"default": "",
- "description": "Optional model override for Plan mode. Empty uses thunder.provider.model."
+ "description": "Optional model override for Plan mode. Empty uses thunder.provider.model.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.agent.planBaseUrl": {
"type": "string",
"default": "",
- "description": "Optional base URL override for Plan mode."
+ "description": "Optional base URL override for Plan mode.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.agent.actModel": {
"type": "string",
"default": "",
- "description": "Optional model override for Agent (Act) mode. Empty uses thunder.provider.model."
+ "description": "Optional model override for Agent (Act) mode. Empty uses thunder.provider.model.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.agent.actBaseUrl": {
"type": "string",
"default": "",
- "description": "Optional base URL override for Agent (Act) mode."
+ "description": "Optional base URL override for Agent (Act) mode.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.agent.checkpointStrategy": {
"type": "string",
@@ -560,48 +785,687 @@
"shadow-git"
],
"default": "git-stash",
- "description": "Checkpoint strategy before file writes. Git stash when repo available, else file copy."
+ "description": "Checkpoint strategy before file writes. Git stash when repo available, else file copy.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.safety.allowUntrustedWorkspace": {
"type": "boolean",
"default": false,
- "description": "Allow writes and shell in untrusted workspaces (not recommended)."
+ "description": "Allow writes and shell in untrusted workspaces (not recommended).",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.mcp.enabled": {
"type": "boolean",
"default": true,
- "description": "Enable Model Context Protocol servers. Built-in free servers start automatically unless thunder.mcp.preloadBuiltin is false."
+ "description": "Enable Model Context Protocol servers. Built-in free servers start automatically unless thunder.mcp.preloadBuiltin is false.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.mcp.preloadBuiltin": {
"type": "boolean",
"default": true,
- "description": "Preload official free MCP servers (filesystem, memory, sequential-thinking) on extension startup. Toggle individual servers in the Mitii settings UI."
+ "description": "Preload official free MCP servers (filesystem, memory, sequential-thinking) on extension startup. Toggle individual servers in the Mitii settings UI.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.mcp.builtinServers": {
"type": "object",
"default": {
"filesystem": true,
"memory": true,
- "sequentialThinking": true
+ "sequentialThinking": true,
+ "agentmemory": false
},
- "description": "Default on/off state for built-in MCP servers. Per-session toggles are available in the Mitii Integrations settings tab."
+ "description": "Default on/off state for built-in MCP servers. Per-session toggles are available in the Mitii Integrations settings tab.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.mcp.maxConcurrentStartup": {
"type": "number",
"default": 4,
"minimum": 1,
"maximum": 20,
- "description": "Maximum MCP stdio servers Mitii starts at the same time."
+ "description": "Maximum MCP stdio servers Mitii starts at the same time.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.mcp.servers": {
"type": "object",
"default": {},
- "description": "MCP server definitions keyed by server name. Supports stdio servers: { \"my-server\": { \"command\": \"npx\", \"args\": [\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"] } }"
+ "description": "MCP server definitions keyed by server name. Supports stdio servers: { \"my-server\": { \"command\": \"npx\", \"args\": [\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"] } }",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
},
"thunder.workspace.rootPathOverride": {
"type": "string",
"default": "",
- "description": "Fallback workspace root when no VS Code folder is open. When a folder is open, Mitii always uses that folder and ignores this setting."
+ "description": "Fallback workspace root when no VS Code folder is open. When a folder is open, Mitii always uses that folder and ignores this setting.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
+ },
+ "mitii.debug": {
+ "type": "boolean",
+ "default": false,
+ "description": "Enable debug logging and show stack traces in UI"
+ },
+ "mitii.telemetry.sessionLogging": {
+ "type": "boolean",
+ "default": true,
+ "description": "Write structured JSONL session logs to .mitii/logs/ for debugging and analysis."
+ },
+ "mitii.telemetry.debugMetrics": {
+ "type": "boolean",
+ "default": false,
+ "description": "Capture extra session diagnostics (tool inputs, context sources, LLM step metadata). Process timing is always logged when session logging is on."
+ },
+ "mitii.telemetry.webhookUrl": {
+ "type": "string",
+ "default": "",
+ "description": "Optional SIEM/webhook endpoint. When set, sanitized session events are POSTed as JSON."
+ },
+ "mitii.telemetry.webhookSecret": {
+ "type": "string",
+ "default": "",
+ "description": "Optional HMAC signing secret for webhook events. Prefer workspace or managed settings for enterprise deployments."
+ },
+ "mitii.telemetry.webhookTimeoutMs": {
+ "type": "number",
+ "default": 5000,
+ "minimum": 1000,
+ "maximum": 60000,
+ "description": "Timeout in milliseconds for telemetry webhook delivery attempts."
+ },
+ "mitii.ui.showReasoning": {
+ "type": "boolean",
+ "default": true,
+ "description": "Show streamed model reasoning when the provider returns reasoning deltas."
+ },
+ "mitii.ui.reasoningPreviewMaxChars": {
+ "type": "number",
+ "default": 8000,
+ "minimum": 0,
+ "maximum": 100000,
+ "description": "Maximum reasoning characters shown inline. Set 0 to show all reasoning."
+ },
+ "mitii.enterprise.localProvidersOnly": {
+ "type": "boolean",
+ "default": false,
+ "description": "Enterprise policy flag documenting that only local providers should be used for this workspace."
+ },
+ "mitii.enterprise.stripFileContentsFromAuditPacks": {
+ "type": "boolean",
+ "default": false,
+ "description": "Strip file contents and large tool outputs from exported audit packs while preserving metadata."
+ },
+ "mitii.enterprise.autoExportAuditPackOnSessionEnd": {
+ "type": "boolean",
+ "default": false,
+ "description": "Export a sanitized audit pack automatically to .mitii/audit/ when an agent turn completes."
+ },
+ "mitii.runtime.mode": {
+ "type": "string",
+ "enum": [
+ "embedded",
+ "daemon"
+ ],
+ "default": "embedded",
+ "description": "Run the VS Code chat in-process or attach to a running mitii serve daemon."
+ },
+ "mitii.runtime.daemonUrl": {
+ "type": "string",
+ "default": "http://127.0.0.1:4310",
+ "description": "Mitii daemon URL used when mitii.runtime.mode is daemon."
+ },
+ "mitii.runtime.daemonToken": {
+ "type": "string",
+ "default": "",
+ "description": "Optional bearer token for mitii serve. Prefer SecretStorage or managed settings for enterprise use."
+ },
+ "mitii.runtime.autoStartDaemon": {
+ "type": "boolean",
+ "default": false,
+ "description": "Automatically start mitii serve when a workspace opens and daemon runtime mode is selected."
+ },
+ "mitii.provider.type": {
+ "type": "string",
+ "enum": [
+ "openai-compatible",
+ "openrouter",
+ "openai",
+ "azure-openai",
+ "bedrock",
+ "anthropic",
+ "gemini",
+ "deepseek",
+ "cursor",
+ "codex",
+ "echo"
+ ],
+ "default": "echo",
+ "description": "LLM provider type"
+ },
+ "mitii.provider.baseUrl": {
+ "type": "string",
+ "default": "http://localhost:11434/v1",
+ "description": "OpenAI-compatible API base URL"
+ },
+ "mitii.provider.model": {
+ "type": "string",
+ "default": "qwen3-coder:30b",
+ "description": "Model name"
+ },
+ "mitii.provider.apiVersion": {
+ "type": "string",
+ "default": "2024-10-21",
+ "description": "Provider API version. Used by Azure OpenAI when building deployment chat completion URLs."
+ },
+ "mitii.provider.region": {
+ "type": "string",
+ "default": "us-east-1",
+ "description": "Cloud provider region. Used by AWS Bedrock."
+ },
+ "mitii.provider.contextWindow": {
+ "type": "number",
+ "default": 8192,
+ "description": "Model context window size. Mitii trims prompts automatically to stay within this limit."
+ },
+ "mitii.provider.supportsVision": {
+ "type": "boolean",
+ "description": "Optional override for model vision support. Leave unset to use automatic provider/model detection."
+ },
+ "mitii.provider.supportsReasoning": {
+ "type": "boolean",
+ "description": "Optional override for streamed reasoning support. Leave unset to use automatic provider/model detection."
+ },
+ "mitii.indexing.enabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Enable workspace indexing"
+ },
+ "mitii.indexing.autoIndexOnOpen": {
+ "type": "boolean",
+ "default": true,
+ "description": "Automatically index the workspace when a folder is opened or switched."
+ },
+ "mitii.indexing.maxFileSizeBytes": {
+ "type": "number",
+ "default": 512000,
+ "description": "Max file size for full indexing in bytes. Larger files get signature-only indexing."
+ },
+ "mitii.indexing.hardSkipSizeBytes": {
+ "type": "number",
+ "default": 2000000,
+ "description": "Skip files larger than this size entirely (e.g. lockfiles)."
+ },
+ "mitii.indexing.maxConcurrency": {
+ "type": "number",
+ "default": 2,
+ "minimum": 1,
+ "maximum": 8,
+ "description": "Number of files to index in parallel."
+ },
+ "mitii.indexing.vectorsEnabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Enable semantic vector search. Uses MiniLM when @xenova/transformers is available, hash fallback otherwise."
+ },
+ "mitii.indexing.treeSitterEnabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Use tree-sitter WASM for symbol extraction (regex fallback when unavailable)"
+ },
+ "mitii.indexing.embeddingProvider": {
+ "type": "string",
+ "enum": [
+ "hash",
+ "minilm"
+ ],
+ "default": "minilm",
+ "description": "Embedding provider when vectors are enabled. minilm uses local @xenova/transformers (all-MiniLM-L6-v2); falls back to hash if unavailable."
+ },
+ "mitii.indexing.vectorBackend": {
+ "type": "string",
+ "enum": [
+ "sqlite",
+ "lancedb"
+ ],
+ "default": "lancedb",
+ "description": "Vector index storage: sqlite (.mitii/mitii.sqlite) or lancedb (.mitii/lance/) via @lancedb/lancedb"
+ },
+ "mitii.context.rerankerEnabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Rerank top retrieval candidates for better context quality (top-20 → top-8)"
+ },
+ "mitii.context.rerankerCandidatePool": {
+ "type": "number",
+ "default": 20,
+ "minimum": 5,
+ "maximum": 50,
+ "description": "Number of candidates to pass to the reranker"
+ },
+ "mitii.context.rerankerTopK": {
+ "type": "number",
+ "default": 8,
+ "minimum": 3,
+ "maximum": 30,
+ "description": "Number of items to keep after reranking"
+ },
+ "mitii.context.microTaskRoutingEnabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Route narrow chat requests such as commit messages, changelog entries, and release notes through minimal Git context instead of the full agent loop."
+ },
+ "mitii.safety.requireApprovalForWrites": {
+ "type": "boolean",
+ "default": true,
+ "description": "Require approval for file writes"
+ },
+ "mitii.safety.requireApprovalForShell": {
+ "type": "boolean",
+ "default": true,
+ "description": "Require approval for shell commands"
+ },
+ "mitii.safety.approvalMode": {
+ "type": "string",
+ "enum": [
+ "review_all",
+ "ask_edits",
+ "ask_deletes",
+ "ask_commands",
+ "auto"
+ ],
+ "enumDescriptions": [
+ "Ask before file edits and mutating shell commands.",
+ "Ask before file edits and delete-like shell commands; allow other mutating shell commands.",
+ "Ask only before delete-like shell commands; allow file edits and other commands.",
+ "Ask before mutating shell commands; allow file edits.",
+ "Auto-approve allowed tools and commands. Dangerous commands are still blocked."
+ ],
+ "default": "review_all",
+ "description": "Approval mode for agent operations"
+ },
+ "mitii.safety.autonomyPreset": {
+ "type": "string",
+ "enum": [
+ "safe",
+ "guided",
+ "builder",
+ "pilot",
+ "enterprise"
+ ],
+ "default": "guided",
+ "description": "Autonomy preset"
+ },
+ "mitii.memory.enabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Enable local memory"
+ },
+ "mitii.memory.hybridSearchEnabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Use FTS5 + optional vector hybrid search for observations"
+ },
+ "mitii.memory.summarizeAfterTask": {
+ "type": "boolean",
+ "default": true,
+ "description": "Summarize completed tasks into durable local memory."
+ },
+ "mitii.memory.autoMemoryEnabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Write post-task memories to human-readable markdown files in addition to SQLite observations."
+ },
+ "mitii.memory.autoMemoryScope": {
+ "type": "string",
+ "enum": [
+ "user",
+ "workspace",
+ "both"
+ ],
+ "default": "user",
+ "description": "Where markdown auto-memory files are written: user profile, workspace .mitii/auto-memory, or both."
+ },
+ "mitii.scm.commitMessageEnabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Show Mitii commit-message generation in VS Code Source Control."
+ },
+ "mitii.github.issueFetchEnabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Fetch structured GitHub issue context when a chat message contains a github.com owner/repo/issues URL and network access is allowed."
+ },
+ "mitii.github.issueCommentLimit": {
+ "type": "number",
+ "default": 8,
+ "minimum": 0,
+ "maximum": 25,
+ "description": "Maximum number of GitHub issue comments to include in injected issue context."
+ },
+ "mitii.github.tokenRef": {
+ "type": "string",
+ "default": "mitii.github.token",
+ "description": "VS Code SecretStorage key for an optional GitHub token used to fetch private issues. The token itself is not stored in settings."
+ },
+ "mitii.agent.subagentsEnabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Enable subagent tools for typed delegation."
+ },
+ "mitii.agent.subagentTypesEnabled": {
+ "type": "array",
+ "default": [
+ "research"
+ ],
+ "items": {
+ "type": "string"
+ },
+ "description": "Allowed subagent types. Built-ins are research, implementer, reviewer, and verifier; custom .mitii/agents ids may also be listed."
+ },
+ "mitii.agent.maxConcurrentSubagents": {
+ "type": "number",
+ "default": 2,
+ "minimum": 1,
+ "maximum": 10,
+ "description": "Maximum subagents allowed to run concurrently in one session."
+ },
+ "mitii.agent.implementerRequiresScope": {
+ "type": "boolean",
+ "default": true,
+ "description": "Require implementer subagents to receive targetFiles or scopeRoot before write tools are available."
+ },
+ "mitii.agent.subagentDailyBudget": {
+ "type": "number",
+ "default": 0,
+ "minimum": 0,
+ "description": "Maximum subagent invocations per session day. 0 means unlimited."
+ },
+ "mitii.agent.maxSteps": {
+ "type": "number",
+ "default": 15,
+ "minimum": 1,
+ "maximum": 100,
+ "description": "Maximum tool-use steps before the main agent stops or auto-continues."
+ },
+ "mitii.agent.askMaxSteps": {
+ "type": "number",
+ "default": 18,
+ "minimum": 1,
+ "maximum": 50,
+ "description": "Maximum tool-use steps per Ask-mode turn (read-only exploration). Use 16-20 for deep codebase explanations."
+ },
+ "mitii.agent.askDepth": {
+ "type": "string",
+ "enum": [
+ "auto",
+ "quick",
+ "standard",
+ "deep",
+ "pilot",
+ "enterprise"
+ ],
+ "default": "auto",
+ "description": "Ask-mode exploration depth. Auto chooses by intent; quick is concise; deep explores more before answering."
+ },
+ "mitii.agent.planDepth": {
+ "type": "string",
+ "enum": [
+ "auto",
+ "quick",
+ "standard",
+ "deep",
+ "pilot",
+ "enterprise"
+ ],
+ "default": "auto",
+ "description": "Plan-mode discovery depth before structured plan compilation. Auto chooses by intent; deep explores more of the codebase."
+ },
+ "mitii.agent.actDepth": {
+ "type": "string",
+ "enum": [
+ "auto",
+ "quick",
+ "standard",
+ "deep",
+ "pilot",
+ "enterprise"
+ ],
+ "default": "auto",
+ "description": "Agent/Act execution depth. Auto chooses by task route; quick caps direct work, deep allows up to 16 tool steps."
+ },
+ "mitii.agent.askAutoContinue": {
+ "type": "boolean",
+ "default": true,
+ "description": "Allow Ask mode to continue when deep exploration reaches its per-round limit."
+ },
+ "mitii.agent.askMaxAutoContinues": {
+ "type": "number",
+ "default": 1,
+ "minimum": 0,
+ "maximum": 10,
+ "description": "Maximum automatic continuation rounds for Ask mode."
+ },
+ "mitii.agent.autoContinue": {
+ "type": "boolean",
+ "default": true,
+ "description": "Allow the agent to keep working after it reaches the per-round step limit."
+ },
+ "mitii.agent.maxAutoContinues": {
+ "type": "number",
+ "default": 2,
+ "minimum": 0,
+ "maximum": 10,
+ "description": "Maximum number of automatic continuation rounds after the main step limit."
+ },
+ "mitii.agent.researchAgentMaxSteps": {
+ "type": "number",
+ "default": 10,
+ "minimum": 1,
+ "maximum": 50,
+ "description": "Maximum tool-use steps for each read-only research subagent."
+ },
+ "mitii.agent.researchAgentModel": {
+ "type": "string",
+ "default": "",
+ "description": "Optional faster model for read-only research subagents. Empty uses the main provider model."
+ },
+ "mitii.agent.researchAgentBaseUrl": {
+ "type": "string",
+ "default": "",
+ "description": "Optional OpenAI-compatible base URL for the research subagent model. Empty uses mitii.provider.baseUrl."
+ },
+ "mitii.agent.orchestrationEnabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Enable the multi-step planner in Plan mode. Act mode uses a faster direct agent path by default."
+ },
+ "mitii.agent.showDiffPreview": {
+ "type": "boolean",
+ "default": false,
+ "description": "Open VS Code diff preview tabs before write_file/apply_patch tool calls. Disabled by default to avoid editor focus changes."
+ },
+ "mitii.agent.verifyCommands": {
+ "type": "array",
+ "default": [],
+ "items": {
+ "type": "string"
+ },
+ "description": "Optional shell commands to run after Act-mode completes. Leave empty to auto-discover from package.json scripts (typecheck, lint, test, build) based on touched files."
+ },
+ "mitii.agent.verifyOnActComplete": {
+ "type": "boolean",
+ "default": true,
+ "description": "After Act-mode runs, discover and run project-appropriate verification (package.json scripts). Uses verifyCommands when set; otherwise auto-discovers."
+ },
+ "mitii.agent.planModel": {
+ "type": "string",
+ "default": "",
+ "description": "Optional model override for Plan mode. Empty uses mitii.provider.model."
+ },
+ "mitii.agent.planBaseUrl": {
+ "type": "string",
+ "default": "",
+ "description": "Optional base URL override for Plan mode."
+ },
+ "mitii.agent.actModel": {
+ "type": "string",
+ "default": "",
+ "description": "Optional model override for Agent (Act) mode. Empty uses mitii.provider.model."
+ },
+ "mitii.agent.actBaseUrl": {
+ "type": "string",
+ "default": "",
+ "description": "Optional base URL override for Agent (Act) mode."
+ },
+ "mitii.agent.checkpointStrategy": {
+ "type": "string",
+ "enum": [
+ "file-copy",
+ "git-stash",
+ "shadow-git"
+ ],
+ "default": "git-stash",
+ "description": "Checkpoint strategy before file writes. Git stash when repo available, else file copy."
+ },
+ "mitii.safety.allowUntrustedWorkspace": {
+ "type": "boolean",
+ "default": false,
+ "description": "Allow writes and shell in untrusted workspaces (not recommended)."
+ },
+ "mitii.mcp.enabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Enable Model Context Protocol servers. Built-in free servers start automatically unless mitii.mcp.preloadBuiltin is false."
+ },
+ "mitii.mcp.preloadBuiltin": {
+ "type": "boolean",
+ "default": true,
+ "description": "Preload official free MCP servers (filesystem, memory, sequential-thinking) on extension startup. Toggle individual servers in the Mitii settings UI."
+ },
+ "mitii.mcp.builtinServers": {
+ "type": "object",
+ "default": {
+ "filesystem": true,
+ "memory": true,
+ "sequentialThinking": true,
+ "agentmemory": false
+ },
+ "description": "Default on/off state for built-in MCP servers. Per-session toggles are available in the Mitii Integrations settings tab."
+ },
+ "mitii.mcp.maxConcurrentStartup": {
+ "type": "number",
+ "default": 4,
+ "minimum": 1,
+ "maximum": 20,
+ "description": "Maximum MCP stdio servers Mitii starts at the same time."
+ },
+ "mitii.mcp.servers": {
+ "type": "object",
+ "default": {},
+ "description": "MCP server definitions keyed by server name. Supports stdio servers: { \"my-server\": { \"command\": \"npx\", \"args\": [\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"] } }"
+ },
+ "mitii.workspace.rootPathOverride": {
+ "type": "string",
+ "default": "",
+ "description": "Fallback workspace root when no VS Code folder is open. When a folder is open, Mitii always uses that folder and ignores this setting."
+ },
+ "mitii.indexing.watchDebounceMs": {
+ "type": "number",
+ "default": 500,
+ "minimum": 100,
+ "maximum": 10000,
+ "description": "Debounce in milliseconds for single-file indexing updates."
+ },
+ "mitii.indexing.priorityPaths": {
+ "type": "array",
+ "default": [],
+ "items": {
+ "type": "string"
+ },
+ "description": "Relative paths that should be indexed before background backlog work."
+ },
+ "mitii.github.autoPrEnabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Allow Mitii workflows to create GitHub pull requests when explicitly requested."
+ },
+ "mitii.github.defaultBaseBranch": {
+ "type": "string",
+ "default": "",
+ "description": "Default target branch for Mitii-created pull requests. Empty uses CLI defaults."
+ },
+ "mitii.github.webhookSecret": {
+ "type": "string",
+ "default": "",
+ "description": "Shared secret used to verify GitHub webhook requests before enqueueing async jobs."
+ },
+ "mitii.agent.teamsEnabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Enable persistent team coordination features when a team name is selected."
+ },
+ "mitii.enterprise.channelsDisabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Disable Slack, Telegram, Discord, and other channel connectors by policy."
+ },
+ "mitii.enterprise.maxParallel": {
+ "type": "number",
+ "default": 10,
+ "minimum": 1,
+ "maximum": 100,
+ "description": "Maximum parallel Mitii sessions or teammates allowed by managed enterprise policy."
+ },
+ "thunder.indexing.watchDebounceMs": {
+ "type": "number",
+ "default": 500,
+ "minimum": 100,
+ "maximum": 10000,
+ "description": "Debounce in milliseconds for single-file indexing updates.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
+ },
+ "thunder.indexing.priorityPaths": {
+ "type": "array",
+ "default": [],
+ "items": {
+ "type": "string"
+ },
+ "description": "Relative paths that should be indexed before background backlog work.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
+ },
+ "thunder.github.autoPrEnabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Allow Mitii workflows to create GitHub pull requests when explicitly requested.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
+ },
+ "thunder.github.defaultBaseBranch": {
+ "type": "string",
+ "default": "",
+ "description": "Default target branch for Mitii-created pull requests. Empty uses CLI defaults.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
+ },
+ "thunder.github.webhookSecret": {
+ "type": "string",
+ "default": "",
+ "description": "Shared secret used to verify GitHub webhook requests before enqueueing async jobs.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
+ },
+ "thunder.agent.teamsEnabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Enable persistent team coordination features when a team name is selected.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
+ },
+ "thunder.enterprise.channelsDisabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Disable Slack, Telegram, Discord, and other channel connectors by policy.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
+ },
+ "thunder.enterprise.maxParallel": {
+ "type": "number",
+ "default": 10,
+ "minimum": 1,
+ "maximum": 100,
+ "description": "Maximum parallel Mitii sessions or teammates allowed by managed enterprise policy.",
+ "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0."
}
}
}
@@ -610,13 +1474,15 @@
"vscode:prepublish": "pnpm run compile",
"compile": "pnpm run compile:extension && pnpm run compile:webview && pnpm run compile:cli",
"setup": "bash scripts/dev-setup.sh",
- "setup:cursor": "THUNDER_EDITOR=cursor bash scripts/dev-setup.sh",
+ "setup:cursor": "MITII_EDITOR=cursor bash scripts/dev-setup.sh",
"rebuild:native": "node scripts/rebuild-native.mjs",
"rebuild:node": "node scripts/rebuild-node.mjs",
"rebuild:all": "pnpm run rebuild:native && pnpm run rebuild:node",
- "postinstall": "node -e \"console.log('Run pnpm run rebuild:native before F5 if better-sqlite3 fails to load')\"",
+ "postinstall": "node -e \"console.log('Run pnpm run rebuild:native before F5 if native modules or MiniLM embeddings fail to load')\"",
"prepare": "node scripts/install-git-hooks.mjs",
"version:bump": "node scripts/bump-version.mjs",
+ "sdk:sync-version": "node scripts/sync-sdk-version.mjs",
+ "sdk:build": "pnpm --filter @mitii/sdk build",
"readme:sync-version": "node scripts/sync-readme-version.mjs",
"release:check-assets": "node scripts/check-release-assets.mjs",
"release:prepare": "pnpm run compile:cli && node dist/cli.js prepare-release",
@@ -632,6 +1498,9 @@
"eval:aggregate": "node tools/benchmark/scripts/aggregate-results.mjs",
"eval:matrix": "pnpm --filter @mitii/benchmark eval:matrix",
"eval:reset-fixtures": "node tools/benchmark/scripts/reset-fixtures.mjs",
+ "eval:retrieval": "vitest run test/benchmark/retrieval-eval.test.ts",
+ "benchmark:manual": "pnpm --filter @mitii/benchmark benchmark:manual",
+ "benchmark:manual:validate": "pnpm --filter @mitii/benchmark benchmark:manual:validate",
"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:skills": "node scripts/copy-bundled-skills.mjs",
@@ -640,6 +1509,8 @@
"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",
"test": "vitest run",
+ "test:daemon": "vitest run test/daemon.test.ts",
+ "test:parallel": "vitest run test/parallel-agents.test.ts",
"test:watch": "vitest",
"lint": "tsc --noEmit",
"scripts:search": "node scripts/search-script-catalog.mjs",
@@ -668,7 +1539,10 @@
"publish:patch": "pnpm run version:patch && pnpm run publish:vsce",
"publish:minor": "pnpm run version:minor && pnpm run publish:vsce",
"publish:major": "pnpm run version:major && pnpm run publish:vsce",
- "smoke": "vitest run test/smoke"
+ "smoke": "vitest run test/smoke",
+ "publish:ovsx": "pnpm dlx ovsx publish",
+ "release:all": "pnpm test && pnpm run package && pnpm run publish:ovsx",
+ "sync:versions": "node scripts/sync-versions.mjs"
},
"devDependencies": {
"@electron/rebuild": "^3.7.2",
diff --git a/packages/board/package.json b/packages/board/package.json
new file mode 100644
index 00000000..8082dbf4
--- /dev/null
+++ b/packages/board/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@mitii/board",
+ "version": "2.7.32",
+ "description": "Minimal task board server for Mitii parallel agents.",
+ "license": "AGPL-3.0-or-later",
+ "type": "module",
+ "main": "./dist/server.js",
+ "engines": {
+ "node": ">=20"
+ },
+ "types": "./dist/server.d.ts",
+ "files": [
+ "dist",
+ "README.md",
+ "LICENSE"
+ ],
+ "scripts": {
+ "build": "esbuild src/server.ts --bundle --outfile=dist/server.js --platform=node --format=esm --packages=external"
+ }
+}
diff --git a/packages/board/src/server.ts b/packages/board/src/server.ts
new file mode 100644
index 00000000..6cc761b9
--- /dev/null
+++ b/packages/board/src/server.ts
@@ -0,0 +1,102 @@
+import { createServer, type Server } from 'http';
+import { resolve } from 'path';
+import { TaskBoardService, ParallelAgentRunner, type ParallelAgentRunnerOptions } from '../../../src/core/task';
+import type { HeadlessRuntime } from '../../../src/core/headless/HeadlessConfig';
+import { writeJson } from '../../daemon/src/authMiddleware';
+
+export interface BoardServerOptions {
+ cwd: string;
+ hostname?: string;
+ port?: number;
+ token?: string;
+}
+
+export async function startMitiiBoard(options: BoardServerOptions): Promise<{ server: Server; url: string; close(): Promise }> {
+ const cwd = resolve(options.cwd);
+ const board = new TaskBoardService(cwd);
+ const hostname = options.hostname ?? '127.0.0.1';
+ const port = options.port ?? 4311;
+ const token = options.token ?? process.env.MITII_BOARD_TOKEN;
+ const server = createServer(async (req, res) => {
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
+ if (token && req.headers.authorization !== `Bearer ${token}`) {
+ writeJson(res, 401, { error: { code: 'unauthorized', message: 'Bearer token required' } });
+ return;
+ }
+ if (req.method === 'GET' && url.pathname === '/') {
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
+ res.end(renderBoard(board.list()));
+ return;
+ }
+ if (req.method === 'GET' && url.pathname === '/tasks') {
+ writeJson(res, 200, { tasks: board.list() });
+ return;
+ }
+ if (req.method === 'POST' && url.pathname === '/tasks') {
+ const body = await readJson(req);
+ const task = board.add({ title: String(body.title ?? 'Untitled task'), prompt: String(body.prompt ?? body.title ?? '') });
+ writeJson(res, 201, { task });
+ return;
+ }
+ const match = url.pathname.match(/^\/tasks\/([^/]+)\/start$/);
+ if (req.method === 'POST' && match) {
+ const task = board.transition(match[1], 'running');
+ writeJson(res, 200, { task });
+ return;
+ }
+ if (req.method === 'POST' && url.pathname === '/tasks/run') {
+ const runtime = (url.searchParams.get('runtime') as HeadlessRuntime | null) ?? 'real';
+ const providerType = url.searchParams.get('provider') ?? undefined;
+ const runner = new ParallelAgentRunner({
+ workspace: cwd,
+ parallel: Number(url.searchParams.get('parallel') ?? 2),
+ runtime,
+ providerType: providerType as ParallelAgentRunnerOptions['providerType'],
+ });
+ writeJson(res, 202, await runner.runRunnable());
+ return;
+ }
+ writeJson(res, 404, { error: { code: 'not_found', message: 'Route not found' } });
+ });
+ await new Promise((resolveListen, reject) => {
+ server.once('error', reject);
+ server.listen(port, hostname, () => resolveListen());
+ });
+ return {
+ server,
+ url: `http://${hostname}:${port}`,
+ close: () => new Promise((resolveClose) => server.close(() => resolveClose())),
+ };
+}
+
+async function readJson(req: NodeJS.ReadableStream): Promise> {
+ const chunks: Buffer[] = [];
+ for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ const raw = Buffer.concat(chunks).toString('utf-8').trim();
+ return raw ? JSON.parse(raw) as Record : {};
+}
+
+function renderBoard(tasks: Array<{ id: string; title: string; status: string; prompt: string }>): string {
+ const columns = ['backlog', 'running', 'review', 'done', 'failed', 'cancelled'];
+ const body = columns.map((column) => {
+ const cards = tasks.filter((task) => task.status === column).map((task) =>
+ `${escapeHtml(task.title)}${task.id}${escapeHtml(task.prompt)}
`
+ ).join('');
+ return `${column}
${cards || 'No tasks
'}`;
+ }).join('');
+ return `
+Mitii Board
+${body}`;
+}
+
+function escapeHtml(value: string): string {
+ return value.replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char] ?? char));
+}
diff --git a/packages/channels/package.json b/packages/channels/package.json
new file mode 100644
index 00000000..696092a6
--- /dev/null
+++ b/packages/channels/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@mitii/channels",
+ "version": "2.7.32",
+ "private": true,
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "files": [
+ "src"
+ ],
+ "dependencies": {
+ "@mitii/sdk": "workspace:*"
+ }
+}
diff --git a/packages/channels/src/base.ts b/packages/channels/src/base.ts
new file mode 100644
index 00000000..ea5a2ae8
--- /dev/null
+++ b/packages/channels/src/base.ts
@@ -0,0 +1,92 @@
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
+import { dirname } from 'path';
+import { DaemonClient, DaemonSessionClient } from '../../sdk/src';
+import type { MitiiMode } from '../../sdk/src';
+
+export interface ChannelMessage {
+ channel: string;
+ threadId: string;
+ userId: string;
+ text: string;
+}
+
+export interface ChannelSecurityPolicy {
+ allowedUsers?: string[];
+ allowedThreads?: string[];
+ readOnly?: boolean;
+ maxPromptChars?: number;
+}
+
+export interface ChannelRuntimeOptions {
+ daemonUrl: string;
+ daemonToken?: string;
+ cwd: string;
+ sessionStorePath: string;
+ security?: ChannelSecurityPolicy;
+}
+
+export class ChannelRuntimeAdapter {
+ private readonly client: DaemonClient;
+ private readonly sessions: Record;
+
+ constructor(private readonly options: ChannelRuntimeOptions) {
+ this.client = new DaemonClient({ baseUrl: options.daemonUrl, token: options.daemonToken });
+ this.sessions = readJson>(options.sessionStorePath, {});
+ }
+
+ async prompt(message: ChannelMessage, mode: MitiiMode = 'agent'): Promise {
+ const policy = this.options.security ?? {};
+ if (policy.allowedUsers?.length && !policy.allowedUsers.includes(message.userId)) {
+ return 'This user is not allowed to use Mitii from this channel.';
+ }
+ if (policy.allowedThreads?.length && !policy.allowedThreads.includes(message.threadId)) {
+ return 'This channel thread is not allowlisted for Mitii.';
+ }
+ const safeMode = policy.readOnly && mode === 'agent' ? 'plan' : mode;
+ const text = sanitizeOutbound(message.text).slice(0, policy.maxPromptChars ?? 12_000);
+ const session = await this.getOrCreateSession(message.threadId, safeMode);
+ const events = session.events();
+ await session.prompt({ mode: safeMode, message: text });
+ let output = '';
+ for await (const event of events) {
+ if (event.type === 'assistant_delta') output += event.content;
+ if (event.type === 'error') return `Mitii error: ${event.message}`;
+ if (event.type === 'approval_required') output += `\nApproval required: ${event.tool}\n`;
+ if (event.type === 'done') break;
+ }
+ return sanitizeOutbound(output || 'Mitii completed without assistant output.');
+ }
+
+ private async getOrCreateSession(threadId: string, mode: MitiiMode): Promise {
+ const existing = this.sessions[threadId];
+ if (existing) {
+ return new DaemonSessionClient(this.client, await this.client.getSession(existing));
+ }
+ const session = await this.client.createSession({
+ cwd: this.options.cwd,
+ mode,
+ approval: 'manual',
+ runtime: 'real',
+ });
+ this.sessions[threadId] = session.id;
+ writeJson(this.options.sessionStorePath, this.sessions);
+ return new DaemonSessionClient(this.client, session);
+ }
+}
+
+export function sanitizeOutbound(value: string): string {
+ return value.replace(/\b[A-Za-z0-9_=-]{24,}\b/g, '[redacted]');
+}
+
+function readJson(path: string, fallback: T): T {
+ try {
+ return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) as T : fallback;
+ } catch {
+ return fallback;
+ }
+}
+
+function writeJson(path: string, value: unknown): void {
+ mkdirSync(dirname(path), { recursive: true });
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
+}
diff --git a/packages/channels/src/index.ts b/packages/channels/src/index.ts
new file mode 100644
index 00000000..fdcca32a
--- /dev/null
+++ b/packages/channels/src/index.ts
@@ -0,0 +1,2 @@
+export * from './base';
+export * from './telegram';
diff --git a/packages/channels/src/telegram.ts b/packages/channels/src/telegram.ts
new file mode 100644
index 00000000..71b7979d
--- /dev/null
+++ b/packages/channels/src/telegram.ts
@@ -0,0 +1,79 @@
+import { homedir } from 'os';
+import { join } from 'path';
+import { ChannelRuntimeAdapter, type ChannelRuntimeOptions } from './base';
+import type { MitiiMode } from '../../sdk/src';
+
+export interface TelegramConnectorOptions extends Omit {
+ token: string;
+ pollTimeoutSeconds?: number;
+ sessionStorePath?: string;
+}
+
+export class TelegramConnector {
+ private readonly runtime: ChannelRuntimeAdapter;
+ private offset = 0;
+
+ constructor(private readonly options: TelegramConnectorOptions) {
+ this.runtime = new ChannelRuntimeAdapter({
+ ...options,
+ sessionStorePath: options.sessionStorePath ?? join(homedir(), '.mitii', 'connectors', 'telegram-sessions.json'),
+ });
+ }
+
+ async start(): Promise {
+ while (true) {
+ const updates = await this.request<{ ok: boolean; result: TelegramUpdate[] }>('getUpdates', {
+ offset: this.offset,
+ timeout: this.options.pollTimeoutSeconds ?? 30,
+ });
+ for (const update of updates.result ?? []) {
+ this.offset = Math.max(this.offset, update.update_id + 1);
+ const message = update.message;
+ if (!message?.text) continue;
+ const { mode, text } = parseTelegramCommand(message.text);
+ const reply = await this.runtime.prompt({
+ channel: 'telegram',
+ threadId: String(message.chat.id),
+ userId: String(message.from?.id ?? message.chat.id),
+ text,
+ }, mode);
+ await this.request('sendMessage', {
+ chat_id: message.chat.id,
+ text: reply.slice(0, 3900),
+ reply_to_message_id: message.message_id,
+ });
+ }
+ }
+ }
+
+ private async request(method: string, body: Record): Promise {
+ const response = await fetch(`https://api.telegram.org/bot${this.options.token}/${method}`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ if (!response.ok) throw new Error(`Telegram ${method} failed with ${response.status}`);
+ return await response.json() as T;
+ }
+}
+
+function parseTelegramCommand(input: string): { mode: MitiiMode; text: string } {
+ const trimmed = input.trim();
+ const [command, ...rest] = trimmed.split(/\s+/);
+ if (command === '/ask') return { mode: 'ask', text: rest.join(' ') };
+ if (command === '/plan') return { mode: 'plan', text: rest.join(' ') };
+ if (command === '/agent') return { mode: 'agent', text: rest.join(' ') };
+ if (command === '/status') return { mode: 'ask', text: 'Summarize the current Mitii session status.' };
+ if (command === '/cancel') return { mode: 'ask', text: 'Cancel is not available from this connector yet.' };
+ return { mode: 'agent', text: trimmed };
+}
+
+interface TelegramUpdate {
+ update_id: number;
+ message?: {
+ message_id: number;
+ text?: string;
+ chat: { id: number };
+ from?: { id: number };
+ };
+}
diff --git a/packages/cli/README.md b/packages/cli/README.md
new file mode 100644
index 00000000..4a14a46b
--- /dev/null
+++ b/packages/cli/README.md
@@ -0,0 +1,3 @@
+# Mitii CLI
+
+This package is the npm entry point for the platform-native `mitii` binary. It resolves one of the optional `@mitii/cli--` packages at install time and falls back to the Node launcher during development builds.
diff --git a/packages/cli/bin/mitii b/packages/cli/bin/mitii
new file mode 100755
index 00000000..2f5285de
--- /dev/null
+++ b/packages/cli/bin/mitii
@@ -0,0 +1,28 @@
+#!/usr/bin/env node
+import { existsSync } from 'fs';
+import { dirname, join } from 'path';
+import { fileURLToPath } from 'url';
+import { spawnSync } from 'child_process';
+import { createRequire } from 'module';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const require = createRequire(import.meta.url);
+const platformPackage = `@mitii/cli-${process.platform}-${process.arch}`;
+let binary;
+
+try {
+ const pkgPath = require.resolve(`${platformPackage}/package.json`);
+ const pkgDir = dirname(pkgPath);
+ const exe = process.platform === 'win32' ? 'mitii.exe' : 'mitii';
+ binary = join(pkgDir, 'bin', exe);
+} catch {
+ binary = join(here, '..', '..', '..', 'bin', 'mitii.js');
+}
+
+if (!existsSync(binary)) {
+ console.error(`Mitii binary not found for ${process.platform}-${process.arch}.`);
+ process.exit(1);
+}
+
+const result = spawnSync(binary, process.argv.slice(2), { stdio: 'inherit' });
+process.exit(result.status ?? 1);
diff --git a/packages/cli/optional-packages/mitii-darwin-arm64/package.json b/packages/cli/optional-packages/mitii-darwin-arm64/package.json
new file mode 100644
index 00000000..39141e24
--- /dev/null
+++ b/packages/cli/optional-packages/mitii-darwin-arm64/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@mitii/cli-darwin-arm64",
+ "version": "2.7.32",
+ "description": "Mitii native CLI for macOS arm64",
+ "license": "AGPL-3.0-or-later",
+ "os": [
+ "darwin"
+ ],
+ "cpu": [
+ "arm64"
+ ],
+ "files": [
+ "bin"
+ ]
+}
diff --git a/packages/cli/optional-packages/mitii-darwin-x64/package.json b/packages/cli/optional-packages/mitii-darwin-x64/package.json
new file mode 100644
index 00000000..f8af7913
--- /dev/null
+++ b/packages/cli/optional-packages/mitii-darwin-x64/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@mitii/cli-darwin-x64",
+ "version": "2.7.32",
+ "description": "Mitii native CLI for macOS x64",
+ "license": "AGPL-3.0-or-later",
+ "os": [
+ "darwin"
+ ],
+ "cpu": [
+ "x64"
+ ],
+ "files": [
+ "bin"
+ ]
+}
diff --git a/packages/cli/optional-packages/mitii-linux-x64/package.json b/packages/cli/optional-packages/mitii-linux-x64/package.json
new file mode 100644
index 00000000..a4872d73
--- /dev/null
+++ b/packages/cli/optional-packages/mitii-linux-x64/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@mitii/cli-linux-x64",
+ "version": "2.7.32",
+ "description": "Mitii native CLI for Linux x64",
+ "license": "AGPL-3.0-or-later",
+ "os": [
+ "linux"
+ ],
+ "cpu": [
+ "x64"
+ ],
+ "files": [
+ "bin"
+ ]
+}
diff --git a/packages/cli/optional-packages/mitii-win32-x64/package.json b/packages/cli/optional-packages/mitii-win32-x64/package.json
new file mode 100644
index 00000000..f98bd459
--- /dev/null
+++ b/packages/cli/optional-packages/mitii-win32-x64/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@mitii/cli-win32-x64",
+ "version": "2.7.32",
+ "description": "Mitii native CLI for Windows x64",
+ "license": "AGPL-3.0-or-later",
+ "os": [
+ "win32"
+ ],
+ "cpu": [
+ "x64"
+ ],
+ "files": [
+ "bin"
+ ]
+}
diff --git a/packages/cli/package.json b/packages/cli/package.json
new file mode 100644
index 00000000..d158c791
--- /dev/null
+++ b/packages/cli/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "mitii",
+ "version": "2.7.32",
+ "description": "Mitii native CLI launcher",
+ "license": "AGPL-3.0-or-later",
+ "type": "module",
+ "bin": {
+ "mitii": "bin/mitii"
+ },
+ "files": [
+ "bin",
+ "README.md"
+ ],
+ "optionalDependencies": {
+ "@mitii/cli-darwin-arm64": "2.7.32",
+ "@mitii/cli-darwin-x64": "2.7.32",
+ "@mitii/cli-linux-x64": "2.7.32",
+ "@mitii/cli-win32-x64": "2.7.32"
+ },
+ "scripts": {
+ "build:platform": "node scripts/build-platform.mjs",
+ "publish:npm": "node scripts/publish-npm.mjs"
+ }
+}
diff --git a/packages/cli/scripts/build-platform.mjs b/packages/cli/scripts/build-platform.mjs
new file mode 100644
index 00000000..801df5b2
--- /dev/null
+++ b/packages/cli/scripts/build-platform.mjs
@@ -0,0 +1,16 @@
+import { mkdirSync, copyFileSync, chmodSync, writeFileSync } from 'fs';
+import { dirname, join, resolve } from 'path';
+import { execFileSync } from 'child_process';
+import { fileURLToPath } from 'url';
+
+const scriptDir = dirname(fileURLToPath(import.meta.url));
+const repoRoot = resolve(scriptDir, '../../..');
+const target = process.env.MITII_PLATFORM_TARGET ?? `${process.platform}-${process.arch}`;
+const outDir = join(repoRoot, 'dist-native', target);
+mkdirSync(outDir, { recursive: true });
+
+execFileSync('pnpm', ['build'], { cwd: repoRoot, stdio: 'inherit' });
+copyFileSync(join(repoRoot, 'bin/mitii.js'), join(outDir, process.platform === 'win32' ? 'mitii.cmd' : 'mitii'));
+chmodSync(join(outDir, process.platform === 'win32' ? 'mitii.cmd' : 'mitii'), 0o755);
+writeFileSync(join(outDir, 'SHA256SUMS'), '# populated by release workflow after archive creation\n');
+console.log(`Prepared Mitii platform bundle in ${outDir}`);
diff --git a/packages/cli/scripts/publish-npm.mjs b/packages/cli/scripts/publish-npm.mjs
new file mode 100644
index 00000000..8de1e24f
--- /dev/null
+++ b/packages/cli/scripts/publish-npm.mjs
@@ -0,0 +1,14 @@
+import { execFileSync } from 'child_process';
+
+const tag = process.env.NPM_TAG ?? 'latest';
+const packages = [
+ 'packages/cli/optional-packages/mitii-darwin-arm64',
+ 'packages/cli/optional-packages/mitii-darwin-x64',
+ 'packages/cli/optional-packages/mitii-linux-x64',
+ 'packages/cli/optional-packages/mitii-win32-x64',
+ 'packages/cli',
+];
+
+for (const dir of packages) {
+ execFileSync('npm', ['publish', '--access', 'public', '--tag', tag], { cwd: dir, stdio: 'inherit' });
+}
diff --git a/packages/daemon/package.json b/packages/daemon/package.json
new file mode 100644
index 00000000..a64a66f6
--- /dev/null
+++ b/packages/daemon/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "@mitii/daemon",
+ "version": "2.7.32",
+ "description": "HTTP/SSE daemon runtime for Mitii sessions.",
+ "license": "AGPL-3.0-or-later",
+ "type": "module",
+ "main": "./dist/server.js",
+ "types": "./dist/server.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/server.d.ts",
+ "import": "./dist/server.js",
+ "default": "./dist/server.js"
+ }
+ },
+ "files": [
+ "dist",
+ "README.md",
+ "LICENSE"
+ ],
+ "engines": {
+ "node": ">=20"
+ },
+ "scripts": {
+ "build": "esbuild src/server.ts --bundle --outfile=dist/server.js --platform=node --format=esm --packages=external && esbuild src/cli.ts --bundle --outfile=dist/cli.js --platform=node --format=esm --packages=external"
+ }
+}
diff --git a/packages/daemon/src/authMiddleware.ts b/packages/daemon/src/authMiddleware.ts
new file mode 100644
index 00000000..6a68bd89
--- /dev/null
+++ b/packages/daemon/src/authMiddleware.ts
@@ -0,0 +1,34 @@
+import { timingSafeEqual } from 'crypto';
+import type { IncomingMessage, ServerResponse } from 'http';
+
+export interface AuthOptions {
+ token?: string;
+}
+
+export function isAuthorized(req: IncomingMessage, options: AuthOptions): boolean {
+ if (!options.token) return true;
+ const header = req.headers.authorization ?? '';
+ const prefix = 'Bearer ';
+ if (!header.startsWith(prefix)) return false;
+ return safeEqual(header.slice(prefix.length), options.token);
+}
+
+export function writeUnauthorized(res: ServerResponse): void {
+ writeJson(res, 401, { error: { code: 'unauthorized', message: 'Missing or invalid bearer token' } });
+}
+
+export function safeEqual(a: string, b: string): boolean {
+ const left = Buffer.from(a);
+ const right = Buffer.from(b);
+ if (left.length !== right.length) return false;
+ return timingSafeEqual(left, right);
+}
+
+export function writeJson(res: ServerResponse, status: number, body: unknown, headers: Record = {}): void {
+ res.writeHead(status, {
+ 'content-type': 'application/json; charset=utf-8',
+ 'cache-control': 'no-store',
+ ...headers,
+ });
+ res.end(JSON.stringify(body));
+}
diff --git a/packages/daemon/src/cli.ts b/packages/daemon/src/cli.ts
new file mode 100644
index 00000000..1024c4e5
--- /dev/null
+++ b/packages/daemon/src/cli.ts
@@ -0,0 +1,37 @@
+import { startMitiiDaemon } from './server';
+
+export async function serveCommand(args: string[], cwd: string): Promise {
+ const hostname = valueOf(args, '--hostname') ?? '127.0.0.1';
+ const port = Number(valueOf(args, '--port') ?? 4310);
+ const token = valueOf(args, '--token') ?? process.env.MITII_SERVER_TOKEN;
+ const maxSessions = Number(valueOf(args, '--max-sessions') ?? 5);
+ const allowOrigin = valueOf(args, '--allow-origin');
+ const insecureBind = args.includes('--insecure-bind');
+ const server = await startMitiiDaemon({
+ cwd: valueOf(args, '--cwd') ?? cwd,
+ hostname,
+ port: Number.isFinite(port) ? port : 4310,
+ token,
+ maxSessions: Number.isFinite(maxSessions) ? maxSessions : 5,
+ allowOrigin,
+ insecureBind,
+ });
+ process.stderr.write(`Mitii daemon listening on ${server.url}\n`);
+ process.stderr.write(`Workspace: ${valueOf(args, '--cwd') ?? cwd}\n`);
+ process.stderr.write(token ? 'Auth: bearer token enabled\n' : 'Auth: disabled for loopback\n');
+
+ const shutdown = async () => {
+ process.stderr.write('Stopping Mitii daemon...\n');
+ await server.close();
+ process.exit(0);
+ };
+ process.once('SIGINT', () => void shutdown());
+ process.once('SIGTERM', () => void shutdown());
+ await new Promise(() => undefined);
+ return 0;
+}
+
+function valueOf(args: string[], name: string): string | undefined {
+ const idx = args.indexOf(name);
+ return idx >= 0 ? args[idx + 1] : undefined;
+}
diff --git a/packages/daemon/src/server.ts b/packages/daemon/src/server.ts
new file mode 100644
index 00000000..efe9981d
--- /dev/null
+++ b/packages/daemon/src/server.ts
@@ -0,0 +1,258 @@
+import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http';
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+import { version as nodeVersion } from 'process';
+import { isAuthorized, writeJson, writeUnauthorized } from './authMiddleware';
+import { SessionConflictError, SessionLimitError, SessionManager, SessionNotFoundError } from './sessionManager';
+import { SseHub } from './sseHub';
+import { isLoopbackHost, validateWorkspace } from './workspaceBinding';
+import type { MitiiApprovalDecision, MitiiMode } from '../../../src/core/headless/events';
+import { IndexWorkerService } from '../../../src/core/indexing/IndexWorkerService';
+
+export interface MitiiDaemonServerOptions {
+ cwd: string;
+ hostname?: string;
+ port?: number;
+ token?: string;
+ maxSessions?: number;
+ allowOrigin?: string;
+ insecureBind?: boolean;
+ packageRoot?: string;
+}
+
+export interface MitiiDaemonServerHandle {
+ server: Server;
+ url: string;
+ close(): Promise;
+}
+
+export async function startMitiiDaemon(options: MitiiDaemonServerOptions): Promise {
+ const hostname = options.hostname ?? '127.0.0.1';
+ const port = options.port ?? 4310;
+ const token = options.token ?? process.env.MITII_SERVER_TOKEN;
+ if (!isLoopbackHost(hostname) && !options.insecureBind) {
+ throw new Error('Refusing non-loopback bind without --insecure-bind');
+ }
+ if (!isLoopbackHost(hostname) && !token) {
+ throw new Error('MITII_SERVER_TOKEN or --token is required for non-loopback daemon binds');
+ }
+
+ const sseHub = new SseHub();
+ const sessions = new SessionManager({
+ cwd: resolve(options.cwd),
+ maxSessions: options.maxSessions ?? 5,
+ packageRoot: options.packageRoot,
+ sseHub,
+ });
+ const indexWorker = new IndexWorkerService({ workspace: resolve(options.cwd) });
+ await indexWorker.initialize();
+
+ const server = createServer(async (req, res) => {
+ try {
+ applyCors(req, res, options.allowOrigin);
+ if (req.method === 'OPTIONS') {
+ res.writeHead(204);
+ res.end();
+ return;
+ }
+ if (!isAuthorized(req, { token })) {
+ writeUnauthorized(res);
+ return;
+ }
+ await route(req, res, sessions, sseHub, indexWorker, options);
+ } catch (error) {
+ const status = statusForError(error);
+ writeJson(res, status, { error: { code: codeForStatus(status), message: error instanceof Error ? error.message : String(error) } });
+ }
+ });
+
+ await new Promise((resolveListen, reject) => {
+ server.once('error', reject);
+ server.listen(port, hostname, () => resolveListen());
+ });
+
+ const close = async () => {
+ indexWorker.dispose();
+ await sessions.dispose();
+ await new Promise((resolveClose) => server.close(() => resolveClose()));
+ };
+
+ return { server, url: `http://${hostname}:${port}`, close };
+}
+
+async function route(
+ req: IncomingMessage,
+ res: ServerResponse,
+ sessions: SessionManager,
+ sseHub: SseHub,
+ indexWorker: IndexWorkerService,
+ options: MitiiDaemonServerOptions
+): Promise {
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
+ const parts = url.pathname.split('/').filter(Boolean);
+
+ if (req.method === 'GET' && url.pathname === '/health') {
+ writeJson(res, 200, {
+ ok: true,
+ version: readPackageVersion(),
+ node: nodeVersion,
+ cwd: resolve(options.cwd),
+ sessions: sessions.list().length,
+ });
+ return;
+ }
+
+ if (req.method === 'GET' && url.pathname === '/capabilities') {
+ writeJson(res, 200, {
+ features: ['sessions', 'sse', 'permissions', 'cancel', 'subagents', 'worktrees', 'index-worker'],
+ maxSessions: options.maxSessions ?? 5,
+ supportedModes: ['ask', 'plan', 'agent', 'review'],
+ eventReplay: true,
+ auth: Boolean(options.token ?? process.env.MITII_SERVER_TOKEN),
+ });
+ return;
+ }
+
+ if (parts[0] === 'index') {
+ if (req.method === 'GET' && parts[1] === 'status') {
+ writeJson(res, 200, { index: indexWorker.status() });
+ return;
+ }
+ if (req.method === 'POST' && parts[1] === 'enqueue') {
+ const body = await readJson(req);
+ const result = await indexWorker.enqueue(
+ Array.isArray(body.paths) ? body.paths.filter((path): path is string => typeof path === 'string') : undefined,
+ { priorityPaths: Array.isArray(body.priorityPaths) ? body.priorityPaths.filter((path): path is string => typeof path === 'string') : undefined }
+ );
+ writeJson(res, 202, result);
+ return;
+ }
+ if (req.method === 'POST' && parts[1] === 'delete') {
+ const body = await readJson(req);
+ const relPath = stringValue(body.path);
+ if (!relPath) {
+ writeJson(res, 400, { error: { code: 'bad_request', message: 'path is required' } });
+ return;
+ }
+ writeJson(res, 200, { removed: indexWorker.delete(relPath) });
+ return;
+ }
+ if (req.method === 'POST' && parts[1] === 'repair') {
+ writeJson(res, 200, { repair: indexWorker.repair() });
+ return;
+ }
+ }
+
+ if (req.method === 'GET' && url.pathname === '/sessions') {
+ writeJson(res, 200, { sessions: sessions.list() });
+ return;
+ }
+
+ if (req.method === 'POST' && url.pathname === '/session') {
+ const body = await readJson(req);
+ const validation = validateWorkspace(options.cwd, stringValue(body.cwd));
+ if (!validation.ok) {
+ writeJson(res, 400, { error: { code: 'workspace_mismatch', message: validation.message } });
+ return;
+ }
+ const session = await sessions.create(body);
+ writeJson(res, 201, { session });
+ return;
+ }
+
+ if (parts[0] === 'session' && parts[1]) {
+ const id = parts[1];
+ if (req.method === 'GET' && parts.length === 2) {
+ const session = sessions.get(id);
+ if (!session) throw new SessionNotFoundError(id);
+ writeJson(res, 200, { session });
+ return;
+ }
+ if (req.method === 'DELETE' && parts.length === 2) {
+ writeJson(res, sessions.close(id) ? 200 : 404, { closed: true });
+ return;
+ }
+ if (req.method === 'POST' && parts[2] === 'prompt') {
+ const body = await readJson(req);
+ const validation = validateWorkspace(options.cwd, stringValue(body.cwd));
+ if (!validation.ok) {
+ writeJson(res, 400, { error: { code: 'workspace_mismatch', message: validation.message } });
+ return;
+ }
+ const result = await sessions.prompt(id, {
+ mode: isMitiiMode(body.mode) ? body.mode : undefined,
+ message: String(body.message ?? ''),
+ attachments: Array.isArray(body.attachments) ? body.attachments : undefined,
+ });
+ writeJson(res, 202, result);
+ return;
+ }
+ if (req.method === 'POST' && parts[2] === 'cancel') {
+ writeJson(res, sessions.cancel(id) ? 200 : 404, { cancelled: true });
+ return;
+ }
+ if (req.method === 'GET' && parts[2] === 'events') {
+ const last = Number(req.headers['last-event-id'] ?? url.searchParams.get('lastEventId') ?? 0);
+ const unsubscribe = sseHub.subscribe(id, res, last);
+ req.on('close', unsubscribe);
+ return;
+ }
+ if (req.method === 'POST' && parts[2] === 'permissions' && parts[3] && parts[4] === 'respond') {
+ const body = await readJson(req);
+ const ok = sessions.respondToPermission(id, parts[3], body.decision as MitiiApprovalDecision);
+ writeJson(res, ok ? 200 : 404, { ok });
+ return;
+ }
+ }
+
+ writeJson(res, 404, { error: { code: 'not_found', message: 'Route not found' } });
+}
+
+function applyCors(req: IncomingMessage, res: ServerResponse, allowOrigin?: string): void {
+ const origin = req.headers.origin;
+ if (!allowOrigin || !origin) return;
+ if (allowOrigin === '*' || allowOrigin === origin) {
+ res.setHeader('access-control-allow-origin', origin);
+ res.setHeader('access-control-allow-headers', 'authorization, content-type, last-event-id');
+ res.setHeader('access-control-allow-methods', 'GET,POST,DELETE,OPTIONS');
+ }
+}
+
+async function readJson(req: IncomingMessage): Promise> {
+ const chunks: Buffer[] = [];
+ for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ const raw = Buffer.concat(chunks).toString('utf-8').trim();
+ if (!raw) return {};
+ return JSON.parse(raw) as Record;
+}
+
+function readPackageVersion(): string {
+ try {
+ const pkg = JSON.parse(readFileSync(resolve('package.json'), 'utf-8')) as { version?: string };
+ return pkg.version ?? '0.0.0';
+ } catch {
+ return '0.0.0';
+ }
+}
+
+function statusForError(error: unknown): number {
+ if (error instanceof SessionNotFoundError) return 404;
+ if (error instanceof SessionLimitError) return 503;
+ if (error instanceof SessionConflictError) return 409;
+ return 500;
+}
+
+function codeForStatus(status: number): string {
+ if (status === 404) return 'not_found';
+ if (status === 409) return 'conflict';
+ if (status === 503) return 'unavailable';
+ return 'internal_error';
+}
+
+function stringValue(value: unknown): string | undefined {
+ return typeof value === 'string' ? value : undefined;
+}
+
+function isMitiiMode(value: unknown): value is MitiiMode {
+ return value === 'ask' || value === 'plan' || value === 'agent' || value === 'review';
+}
diff --git a/packages/daemon/src/sessionManager.ts b/packages/daemon/src/sessionManager.ts
new file mode 100644
index 00000000..b62cf47a
--- /dev/null
+++ b/packages/daemon/src/sessionManager.ts
@@ -0,0 +1,210 @@
+import { randomUUID } from 'crypto';
+import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'fs';
+import { dirname, join } from 'path';
+import { HeadlessAgentHost } from '../../../src/core/headless/HeadlessAgentHost';
+import type { HeadlessAgentOptions } from '../../../src/core/headless/HeadlessConfig';
+import type { MitiiApprovalDecision, MitiiMode } from '../../../src/core/headless/events';
+import { SseHub } from './sseHub';
+import { canonicalWorkspace } from './workspaceBinding';
+
+export interface DaemonSessionCreateOptions {
+ cwd?: string;
+ mode?: MitiiMode;
+ approval?: 'auto' | 'manual';
+ providerType?: HeadlessAgentOptions['providerType'];
+ baseUrl?: string;
+ model?: string;
+ apiKey?: string;
+ runtime?: HeadlessAgentOptions['runtime'];
+ indexWorkspace?: boolean;
+}
+
+export interface DaemonSessionInfo {
+ id: string;
+ cwd: string;
+ mode: MitiiMode;
+ approval: 'auto' | 'manual';
+ createdAt: number;
+ updatedAt: number;
+ running: boolean;
+ closed: boolean;
+}
+
+interface ManagedSession {
+ info: DaemonSessionInfo;
+ host: HeadlessAgentHost;
+ abortController?: AbortController;
+ promptInFlight: boolean;
+}
+
+export interface SessionManagerOptions {
+ cwd: string;
+ maxSessions: number;
+ packageRoot?: string;
+ sseHub: SseHub;
+}
+
+export class SessionManager {
+ private readonly sessions = new Map();
+ private readonly cwd: string;
+ private readonly registryPath: string;
+ private readonly auditPath: string;
+
+ constructor(private readonly options: SessionManagerOptions) {
+ this.cwd = canonicalWorkspace(options.cwd);
+ this.registryPath = join(this.cwd, '.mitii', 'daemon', 'sessions.json');
+ this.auditPath = join(this.cwd, '.mitii', 'daemon', 'audit.jsonl');
+ mkdirSync(dirname(this.registryPath), { recursive: true });
+ }
+
+ list(): DaemonSessionInfo[] {
+ return [...this.sessions.values()].map((session) => ({ ...session.info }));
+ }
+
+ get(id: string): DaemonSessionInfo | undefined {
+ const session = this.sessions.get(id);
+ return session ? { ...session.info } : undefined;
+ }
+
+ async create(options: DaemonSessionCreateOptions = {}): Promise {
+ if (this.sessions.size >= this.options.maxSessions) {
+ throw new SessionLimitError(`Maximum sessions reached (${this.options.maxSessions})`);
+ }
+ const id = randomUUID();
+ const mode = options.mode ?? 'agent';
+ const approval = options.approval ?? 'manual';
+ const host = new HeadlessAgentHost({
+ cwd: this.cwd,
+ packageRoot: this.options.packageRoot,
+ runtime: options.runtime,
+ providerType: options.providerType,
+ baseUrl: options.baseUrl,
+ model: options.model,
+ apiKey: options.apiKey,
+ approval,
+ indexWorkspace: options.indexWorkspace,
+ sessionId: id,
+ onEvent: (event) => this.options.sseHub.publish(id, event),
+ });
+ await host.initialize();
+ const info: DaemonSessionInfo = {
+ id,
+ cwd: this.cwd,
+ mode,
+ approval,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ running: false,
+ closed: false,
+ };
+ this.sessions.set(id, { info, host, promptInFlight: false });
+ this.persist();
+ this.audit('session_create', { id, mode, approval });
+ return { ...info };
+ }
+
+ async prompt(id: string, input: { mode?: MitiiMode; message: string; attachments?: unknown[] }): Promise<{ accepted: true }> {
+ const session = this.sessions.get(id);
+ if (!session || session.info.closed) throw new SessionNotFoundError(id);
+ if (session.promptInFlight) throw new SessionConflictError('A prompt is already running for this session');
+ session.promptInFlight = true;
+ session.info.running = true;
+ session.info.mode = input.mode ?? session.info.mode;
+ session.info.updatedAt = Date.now();
+ session.abortController = new AbortController();
+ this.persist();
+ this.audit('prompt_start', { id, mode: session.info.mode, messageLength: input.message.length });
+
+ void this.runPrompt(session, input.message, session.abortController.signal);
+ return { accepted: true };
+ }
+
+ cancel(id: string): boolean {
+ const session = this.sessions.get(id);
+ if (!session) return false;
+ session.abortController?.abort();
+ session.host.cancel();
+ this.audit('session_cancel', { id });
+ return true;
+ }
+
+ respondToPermission(id: string, approvalId: string, decision: MitiiApprovalDecision): boolean {
+ const session = this.sessions.get(id);
+ if (!session) return false;
+ const ok = session.host.resolveApproval(approvalId, decision);
+ this.audit('permission_response', { id, approvalId, decision, ok });
+ return ok;
+ }
+
+ close(id: string): boolean {
+ const session = this.sessions.get(id);
+ if (!session) return false;
+ session.abortController?.abort();
+ void session.host.dispose();
+ session.info.closed = true;
+ session.info.running = false;
+ this.sessions.delete(id);
+ this.options.sseHub.clear(id);
+ this.persist();
+ this.audit('session_close', { id });
+ return true;
+ }
+
+ async dispose(): Promise {
+ for (const id of [...this.sessions.keys()]) {
+ this.close(id);
+ }
+ }
+
+ loadPersistedMetadata(): DaemonSessionInfo[] {
+ if (!existsSync(this.registryPath)) return [];
+ try {
+ const parsed = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DaemonSessionInfo[];
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+ }
+
+ private async runPrompt(session: ManagedSession, prompt: string, signal: AbortSignal): Promise {
+ try {
+ const mode = session.info.mode;
+ if (mode === 'ask') {
+ const content = await session.host.ask(prompt);
+ this.options.sseHub.publish(session.info.id, { type: 'assistant_delta', content });
+ this.options.sseHub.publish(session.info.id, { type: 'done', content });
+ } else if (mode === 'plan') {
+ const plan = await session.host.plan(prompt);
+ this.options.sseHub.publish(session.info.id, { type: 'plan', plan });
+ this.options.sseHub.publish(session.info.id, { type: 'done', content: JSON.stringify(plan) });
+ } else {
+ for await (const event of session.host.agent(prompt, signal)) {
+ this.options.sseHub.publish(session.info.id, event);
+ }
+ }
+ this.audit('prompt_done', { id: session.info.id });
+ } catch (error) {
+ const message = signal.aborted ? 'Prompt cancelled' : error instanceof Error ? error.message : String(error);
+ this.options.sseHub.publish(session.info.id, { type: 'error', message });
+ this.audit('prompt_error', { id: session.info.id, message });
+ } finally {
+ session.promptInFlight = false;
+ session.info.running = false;
+ session.info.updatedAt = Date.now();
+ this.persist();
+ }
+ }
+
+ private persist(): void {
+ writeFileSync(this.registryPath, `${JSON.stringify(this.list(), null, 2)}\n`, 'utf-8');
+ }
+
+ private audit(type: string, data: Record): void {
+ mkdirSync(dirname(this.auditPath), { recursive: true });
+ appendFileSync(this.auditPath, `${JSON.stringify({ at: Date.now(), type, data })}\n`);
+ }
+}
+
+export class SessionNotFoundError extends Error {}
+export class SessionLimitError extends Error {}
+export class SessionConflictError extends Error {}
diff --git a/packages/daemon/src/sseHub.ts b/packages/daemon/src/sseHub.ts
new file mode 100644
index 00000000..6b1e04c1
--- /dev/null
+++ b/packages/daemon/src/sseHub.ts
@@ -0,0 +1,81 @@
+import type { ServerResponse } from 'http';
+import type { MitiiEvent } from '../../../src/core/headless/events';
+
+export interface BufferedSseEvent {
+ id: number;
+ event: string;
+ data: MitiiEvent;
+ at: number;
+}
+
+interface Subscriber {
+ res: ServerResponse;
+}
+
+export class SseHub {
+ private readonly buffers = new Map();
+ private readonly subscribers = new Map>();
+ private nextId = 1;
+
+ constructor(private readonly maxEvents = 500) {}
+
+ publish(sessionId: string, event: MitiiEvent): BufferedSseEvent {
+ const framed: BufferedSseEvent = {
+ id: this.nextId++,
+ event: event.type,
+ data: event,
+ at: Date.now(),
+ };
+ const buffer = [...(this.buffers.get(sessionId) ?? []), framed].slice(-this.maxEvents);
+ this.buffers.set(sessionId, buffer);
+ for (const subscriber of this.subscribers.get(sessionId) ?? []) {
+ writeFrame(subscriber.res, framed);
+ }
+ return framed;
+ }
+
+ subscribe(sessionId: string, res: ServerResponse, lastEventId?: number): () => void {
+ res.writeHead(200, {
+ 'content-type': 'text/event-stream; charset=utf-8',
+ 'cache-control': 'no-cache, no-transform',
+ connection: 'keep-alive',
+ 'x-accel-buffering': 'no',
+ });
+ res.write(': connected\n\n');
+
+ for (const event of this.replay(sessionId, lastEventId)) {
+ writeFrame(res, event);
+ }
+
+ const subscriber: Subscriber = { res };
+ const set = this.subscribers.get(sessionId) ?? new Set();
+ set.add(subscriber);
+ this.subscribers.set(sessionId, set);
+
+ return () => {
+ set.delete(subscriber);
+ if (set.size === 0) this.subscribers.delete(sessionId);
+ res.end();
+ };
+ }
+
+ replay(sessionId: string, lastEventId?: number): BufferedSseEvent[] {
+ const buffer = this.buffers.get(sessionId) ?? [];
+ if (!lastEventId || Number.isNaN(lastEventId)) return buffer;
+ return buffer.filter((event) => event.id > lastEventId);
+ }
+
+ clear(sessionId: string): void {
+ this.buffers.delete(sessionId);
+ for (const subscriber of this.subscribers.get(sessionId) ?? []) {
+ subscriber.res.end();
+ }
+ this.subscribers.delete(sessionId);
+ }
+}
+
+function writeFrame(res: ServerResponse, event: BufferedSseEvent): void {
+ res.write(`id: ${event.id}\n`);
+ res.write(`event: ${event.event}\n`);
+ res.write(`data: ${JSON.stringify(event.data)}\n\n`);
+}
diff --git a/packages/daemon/src/workspaceBinding.ts b/packages/daemon/src/workspaceBinding.ts
new file mode 100644
index 00000000..56b145a6
--- /dev/null
+++ b/packages/daemon/src/workspaceBinding.ts
@@ -0,0 +1,18 @@
+import { realpathSync } from 'fs';
+import { resolve } from 'path';
+
+export function canonicalWorkspace(path: string): string {
+ return realpathSync(resolve(path));
+}
+
+export function validateWorkspace(boundCwd: string, requested?: string): { ok: true } | { ok: false; message: string } {
+ if (!requested) return { ok: true };
+ const actual = canonicalWorkspace(requested);
+ const expected = canonicalWorkspace(boundCwd);
+ if (actual === expected) return { ok: true };
+ return { ok: false, message: `Daemon is bound to ${expected}; requested cwd ${actual}` };
+}
+
+export function isLoopbackHost(hostname: string): boolean {
+ return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1';
+}
diff --git a/packages/sdk/README.md b/packages/sdk/README.md
new file mode 100644
index 00000000..167d432d
--- /dev/null
+++ b/packages/sdk/README.md
@@ -0,0 +1,43 @@
+# @mitii/sdk
+
+Run Mitii headless ask, plan, and agent sessions from Node 20+.
+
+```bash
+npm install @mitii/sdk
+```
+
+```ts
+import { query } from '@mitii/sdk';
+
+for await (const event of query({
+ cwd: process.cwd(),
+ prompt: 'Find the test command for this repo',
+ mode: 'agent',
+ runtime: 'real',
+ provider: 'openai-compatible',
+ baseUrl: 'http://localhost:11434/v1',
+ model: 'qwen3-coder:30b',
+ approval: 'auto',
+ allowNetwork: false,
+})) {
+ if (event.type === 'assistant_delta') process.stdout.write(event.content);
+ if (event.type === 'tool_start') console.error('tool:', event.tool);
+}
+```
+
+## Event Contract
+
+`query()` returns an async iterable of NDJSON-friendly events:
+
+- `session_start`: session id, mode, and workspace.
+- `assistant_delta`: streamed assistant text.
+- `reasoning_delta`: streamed reasoning text when the provider returns it.
+- `tool_start` / `tool_end`: sanitized tool activity with path/command previews.
+- `approval_required` / `approval_resolved`: manual approval lifecycle.
+- `plan`: plan object for Plan mode or plan creation events.
+- `metrics`: duration, tool count, session log path, and audit tool names.
+- `error`: normalized runtime error.
+- `done`: terminal content event.
+- `log`: additional sanitized session log events.
+
+Tool inputs and outputs are previews only. Session log sanitization redacts API-key-like values before SDK events are emitted.
diff --git a/packages/sdk/examples/daemon-client-quickstart.ts b/packages/sdk/examples/daemon-client-quickstart.ts
new file mode 100644
index 00000000..dd71b408
--- /dev/null
+++ b/packages/sdk/examples/daemon-client-quickstart.ts
@@ -0,0 +1,25 @@
+import { DaemonClient, DaemonSessionClient } from '../src/daemon';
+
+const client = new DaemonClient({
+ baseUrl: process.env.MITII_DAEMON_URL ?? 'http://127.0.0.1:4310',
+ token: process.env.MITII_SERVER_TOKEN,
+});
+
+const session = await DaemonSessionClient.createOrAttach(client, {
+ cwd: process.cwd(),
+ mode: 'agent',
+ approval: 'manual',
+ runtime: 'stub',
+});
+
+void (async () => {
+ for await (const event of session.events()) {
+ if (event.type === 'approval_required') {
+ await session.respondToPermission(event.id, 'approved');
+ }
+ console.log(JSON.stringify(event));
+ if (event.type === 'done' || event.type === 'error') break;
+ }
+})();
+
+await session.prompt({ message: 'Say hello from the daemon quickstart.' });
diff --git a/packages/sdk/package.json b/packages/sdk/package.json
new file mode 100644
index 00000000..b3c42c2c
--- /dev/null
+++ b/packages/sdk/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "@mitii/sdk",
+ "version": "2.7.32",
+ "description": "Node SDK for running Mitii headless ask, plan, and agent sessions.",
+ "license": "AGPL-3.0-or-later",
+ "type": "module",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "default": "./dist/index.js"
+ },
+ "./types": "./dist/types.d.ts",
+ "./daemon": {
+ "types": "./dist/daemon.d.ts",
+ "import": "./dist/daemon.js",
+ "default": "./dist/daemon.js"
+ }
+ },
+ "files": [
+ "dist",
+ "README.md",
+ "LICENSE"
+ ],
+ "engines": {
+ "node": ">=20"
+ },
+ "scripts": {
+ "build": "esbuild src/index.ts --bundle --outfile=dist/index.js --platform=node --format=esm --packages=external --alias:vscode=../../src/node/vscode-shim.ts && esbuild src/daemon.ts --bundle --outfile=dist/daemon.js --platform=node --format=esm --packages=external && cp src/index.d.ts dist/index.d.ts && cp src/types.d.ts dist/types.d.ts && cp src/daemon.d.ts dist/daemon.d.ts",
+ "test": "vitest run"
+ },
+ "peerDependencies": {
+ "better-sqlite3": ">=12",
+ "@xenova/transformers": ">=2"
+ },
+ "peerDependenciesMeta": {
+ "@xenova/transformers": {
+ "optional": true
+ }
+ },
+ "devDependencies": {
+ "esbuild": "^0.21.5",
+ "typescript": "^5.5.2",
+ "vitest": "^1.6.0"
+ }
+}
diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts
new file mode 100644
index 00000000..a70488b5
--- /dev/null
+++ b/packages/sdk/src/client.ts
@@ -0,0 +1,125 @@
+import { HeadlessAgentHost } from '../../../src/core/headless/HeadlessAgentHost';
+import type { ProviderType } from '../../../src/core/config/schema';
+import type { MitiiClientOptions, MitiiEvent, MitiiQueryOptions, MitiiResult } from './types';
+
+export class MitiiClient {
+ private host?: HeadlessAgentHost;
+ private initialized = false;
+
+ constructor(private readonly options: MitiiClientOptions) {}
+
+ async initialize(): Promise {
+ if (this.initialized) return;
+ this.host = new HeadlessAgentHost(toHeadlessOptions(this.options));
+ await this.host.initialize();
+ this.initialized = true;
+ }
+
+ async ask(prompt: string): Promise {
+ await this.initialize();
+ return this.host!.ask(prompt);
+ }
+
+ async plan(prompt: string): Promise> {
+ await this.initialize();
+ return await this.host!.plan(prompt) as Record;
+ }
+
+ async *agent(prompt: string, signal?: AbortSignal): AsyncIterable {
+ await this.initialize();
+ for await (const event of this.host!.agent(prompt) as AsyncIterable) {
+ if (signal?.aborted) throw new Error('Mitii query aborted');
+ yield event;
+ }
+ }
+
+ async *query(options: Omit & { prompt: string }): AsyncIterable {
+ const mode = options.mode ?? 'agent';
+ const started = Date.now();
+ const events: MitiiEvent[] = [];
+ let content = '';
+ const emit = async function* (event: MitiiEvent): AsyncIterable {
+ events.push(event);
+ yield event;
+ };
+
+ if (mode === 'ask') {
+ content = await this.ask(options.prompt);
+ yield* emit({ type: 'assistant_delta', content });
+ } else if (mode === 'plan') {
+ const plan = await this.plan(options.prompt);
+ yield* emit({ type: 'plan', plan });
+ content = JSON.stringify(plan);
+ } else {
+ for await (const event of this.agent(options.prompt, options.signal)) {
+ if (event.type === 'assistant_delta') content += event.content;
+ if (event.type === 'done') {
+ content = event.content;
+ continue;
+ }
+ yield* emit(event);
+ }
+ }
+
+ yield* emit({
+ type: 'metrics',
+ durationMs: Date.now() - started,
+ toolCalls: this.host?.getToolAudit().length ?? 0,
+ sessionLogPath: this.host?.getSessionLog().getLogPath() || undefined,
+ auditTools: this.host?.getToolAudit().map((entry) => entry.toolName),
+ });
+ yield* emit({ type: 'done', content });
+ }
+
+ async run(options: Omit & { prompt: string }): Promise {
+ const events: MitiiEvent[] = [];
+ let content = '';
+ for await (const event of this.query(options)) {
+ events.push(event);
+ if (event.type === 'done') content = event.content;
+ }
+ return { content, events };
+ }
+
+ resolveApproval(id: string, decision: 'approved' | 'denied'): boolean {
+ return this.host?.resolveApproval(id, decision) ?? false;
+ }
+
+ async dispose(): Promise {
+ await this.host?.dispose();
+ this.host = undefined;
+ this.initialized = false;
+ }
+}
+
+export function createClient(options: MitiiClientOptions): MitiiClient {
+ return new MitiiClient(options);
+}
+
+export async function* query(options: MitiiQueryOptions): AsyncIterable {
+ const client = createClient(options);
+ try {
+ yield* client.query(options);
+ } finally {
+ await client.dispose();
+ }
+}
+
+function toHeadlessOptions(options: MitiiClientOptions): ConstructorParameters[0] {
+ return {
+ cwd: options.cwd,
+ packageRoot: options.packageRoot,
+ runtime: options.runtime,
+ providerType: (options.providerType ?? options.provider) as ProviderType | undefined,
+ baseUrl: options.baseUrl,
+ model: options.model,
+ apiKey: options.apiKey,
+ approval: options.approval,
+ allowNetwork: options.allowNetwork,
+ enablePuppeteer: options.enablePuppeteer,
+ indexWorkspace: options.indexWorkspace,
+ configOverrides: options.vectors === undefined
+ ? undefined
+ : { indexing: { vectorsEnabled: options.vectors } } as never,
+ };
+}
diff --git a/packages/sdk/src/daemon.d.ts b/packages/sdk/src/daemon.d.ts
new file mode 100644
index 00000000..50aa80ac
--- /dev/null
+++ b/packages/sdk/src/daemon.d.ts
@@ -0,0 +1,72 @@
+import type { MitiiApprovalDecision, MitiiApprovalMode, MitiiEvent, MitiiMode, MitiiRuntime } from './types';
+
+export interface DaemonClientOptions {
+ baseUrl?: string;
+ token?: string;
+ timeoutMs?: number;
+ fetch?: typeof fetch;
+}
+
+export interface DaemonSessionCreateOptions {
+ cwd: string;
+ mode?: MitiiMode;
+ approval?: MitiiApprovalMode;
+ providerType?: string;
+ baseUrl?: string;
+ model?: string;
+ apiKey?: string;
+ runtime?: MitiiRuntime;
+ indexWorkspace?: boolean;
+}
+
+export interface DaemonSessionInfo {
+ id: string;
+ cwd: string;
+ mode: MitiiMode;
+ approval: MitiiApprovalMode;
+ createdAt: number;
+ updatedAt: number;
+ running: boolean;
+ closed: boolean;
+}
+
+export interface DaemonPromptInput {
+ mode?: MitiiMode;
+ message: string;
+ attachments?: unknown[];
+}
+
+export interface ParsedSseEvent {
+ id: number;
+ event?: string;
+ data: T;
+}
+
+export declare class DaemonClient {
+ readonly baseUrl: string;
+ constructor(options?: DaemonClientOptions);
+ health(): Promise>;
+ capabilities(): Promise>;
+ createSession(options: DaemonSessionCreateOptions): Promise;
+ listSessions(): Promise;
+ getSession(id: string): Promise;
+ closeSession(id: string): Promise>;
+ prompt(id: string, input: DaemonPromptInput): Promise>;
+ cancel(id: string): Promise>;
+ respondToPermission(id: string, approvalId: string, decision: MitiiApprovalDecision): Promise>;
+ events(id: string, lastSeenEventId?: number): AsyncIterable>;
+}
+
+export declare class DaemonSessionClient {
+ readonly client: DaemonClient;
+ readonly session: DaemonSessionInfo;
+ lastSeenEventId: number;
+ constructor(client: DaemonClient, session: DaemonSessionInfo);
+ static createOrAttach(client: DaemonClient, options: DaemonSessionCreateOptions): Promise;
+ prompt(input: DaemonPromptInput): Promise>;
+ cancel(): Promise>;
+ respondToPermission(id: string, decision: MitiiApprovalDecision): Promise>;
+ events(): AsyncIterable;
+}
+
+export declare function parseSseStream(responsePromise: Promise): AsyncIterable>;
diff --git a/packages/sdk/src/daemon.ts b/packages/sdk/src/daemon.ts
new file mode 100644
index 00000000..d3c43b70
--- /dev/null
+++ b/packages/sdk/src/daemon.ts
@@ -0,0 +1,216 @@
+import type { MitiiApprovalDecision, MitiiApprovalMode, MitiiEvent, MitiiMode, MitiiRuntime } from './types';
+
+export interface DaemonClientOptions {
+ baseUrl?: string;
+ token?: string;
+ timeoutMs?: number;
+ fetch?: typeof fetch;
+}
+
+export interface DaemonSessionCreateOptions {
+ cwd: string;
+ mode?: MitiiMode;
+ approval?: MitiiApprovalMode;
+ providerType?: string;
+ baseUrl?: string;
+ model?: string;
+ apiKey?: string;
+ runtime?: MitiiRuntime;
+ indexWorkspace?: boolean;
+}
+
+export interface DaemonSessionInfo {
+ id: string;
+ cwd: string;
+ mode: MitiiMode;
+ approval: MitiiApprovalMode;
+ createdAt: number;
+ updatedAt: number;
+ running: boolean;
+ closed: boolean;
+}
+
+export interface DaemonPromptInput {
+ mode?: MitiiMode;
+ message: string;
+ attachments?: unknown[];
+}
+
+export class DaemonClient {
+ readonly baseUrl: string;
+ private readonly token?: string;
+ private readonly timeoutMs: number;
+ private readonly fetchImpl: typeof fetch;
+
+ constructor(options: DaemonClientOptions = {}) {
+ this.baseUrl = (options.baseUrl ?? 'http://127.0.0.1:4310').replace(/\/$/, '');
+ this.token = options.token;
+ this.timeoutMs = options.timeoutMs ?? 30_000;
+ this.fetchImpl = options.fetch ?? fetch;
+ }
+
+ health(): Promise> {
+ return this.request('GET', '/health');
+ }
+
+ capabilities(): Promise> {
+ return this.request('GET', '/capabilities');
+ }
+
+ async createSession(options: DaemonSessionCreateOptions): Promise {
+ const body = await this.request<{ session: DaemonSessionInfo }>('POST', '/session', options);
+ return body.session;
+ }
+
+ async listSessions(): Promise {
+ const body = await this.request<{ sessions: DaemonSessionInfo[] }>('GET', '/sessions');
+ return body.sessions;
+ }
+
+ async getSession(id: string): Promise {
+ const body = await this.request<{ session: DaemonSessionInfo }>('GET', `/session/${encodeURIComponent(id)}`);
+ return body.session;
+ }
+
+ closeSession(id: string): Promise> {
+ return this.request('DELETE', `/session/${encodeURIComponent(id)}`);
+ }
+
+ prompt(id: string, input: DaemonPromptInput): Promise> {
+ return this.request('POST', `/session/${encodeURIComponent(id)}/prompt`, input);
+ }
+
+ cancel(id: string): Promise> {
+ return this.request('POST', `/session/${encodeURIComponent(id)}/cancel`);
+ }
+
+ respondToPermission(id: string, approvalId: string, decision: MitiiApprovalDecision): Promise> {
+ return this.request('POST', `/session/${encodeURIComponent(id)}/permissions/${encodeURIComponent(approvalId)}/respond`, { decision });
+ }
+
+ events(id: string, lastSeenEventId?: number): AsyncIterable> {
+ const headers: Record = this.headers();
+ if (lastSeenEventId) headers['last-event-id'] = String(lastSeenEventId);
+ return parseSseStream(
+ this.fetchImpl(`${this.baseUrl}/session/${encodeURIComponent(id)}/events`, { headers })
+ );
+ }
+
+ private async request>(method: string, path: string, body?: unknown): Promise {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
+ try {
+ const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
+ method,
+ headers: {
+ ...this.headers(),
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
+ },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ signal: controller.signal,
+ });
+ const text = await res.text();
+ const parsed = text ? JSON.parse(text) : {};
+ if (!res.ok) {
+ const message = parsed?.error?.message ?? `${method} ${path} failed with ${res.status}`;
+ throw new Error(message);
+ }
+ return parsed as T;
+ } finally {
+ clearTimeout(timer);
+ }
+ }
+
+ private headers(): Record {
+ return this.token ? { authorization: `Bearer ${this.token}` } : {};
+ }
+}
+
+export class DaemonSessionClient {
+ lastSeenEventId = 0;
+
+ constructor(readonly client: DaemonClient, readonly session: DaemonSessionInfo) {}
+
+ static async createOrAttach(client: DaemonClient, options: DaemonSessionCreateOptions): Promise {
+ const existing = (await client.listSessions()).find((session) => session.cwd === options.cwd && !session.closed);
+ return new DaemonSessionClient(client, existing ?? await client.createSession(options));
+ }
+
+ prompt(input: DaemonPromptInput): Promise> {
+ return this.client.prompt(this.session.id, input);
+ }
+
+ cancel(): Promise> {
+ return this.client.cancel(this.session.id);
+ }
+
+ respondToPermission(id: string, decision: MitiiApprovalDecision): Promise> {
+ return this.client.respondToPermission(this.session.id, id, decision);
+ }
+
+ async *events(): AsyncIterable {
+ for await (const event of this.client.events(this.session.id, this.lastSeenEventId)) {
+ this.lastSeenEventId = event.id;
+ yield event.data;
+ }
+ }
+}
+
+export interface ParsedSseEvent {
+ id: number;
+ event?: string;
+ data: T;
+}
+
+export async function* parseSseStream(responsePromise: Promise): AsyncIterable> {
+ const response = await responsePromise;
+ if (!response.ok || !response.body) {
+ throw new Error(`SSE connection failed with ${response.status}`);
+ }
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+ try {
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ let boundary = findFrameBoundary(buffer);
+ while (boundary >= 0) {
+ const raw = buffer.slice(0, boundary);
+ const skip = buffer.slice(boundary, boundary + 4).startsWith('\r\n\r\n') ? 4 : 2;
+ buffer = buffer.slice(boundary + skip);
+ const parsed = parseSseFrame(raw);
+ if (parsed) yield parsed;
+ boundary = findFrameBoundary(buffer);
+ }
+ }
+ } finally {
+ reader.releaseLock();
+ }
+}
+
+function parseSseFrame(frame: string): ParsedSseEvent | null {
+ let id = 0;
+ let event: string | undefined;
+ const data: string[] = [];
+ for (const line of frame.split(/\r?\n/)) {
+ if (!line || line.startsWith(':')) continue;
+ const index = line.indexOf(':');
+ const field = index >= 0 ? line.slice(0, index) : line;
+ const value = index >= 0 ? line.slice(index + 1).replace(/^ /, '') : '';
+ if (field === 'id') id = Number(value);
+ if (field === 'event') event = value;
+ if (field === 'data') data.push(value);
+ }
+ if (data.length === 0) return null;
+ return { id, event, data: JSON.parse(data.join('\n')) as T };
+}
+
+function findFrameBoundary(buffer: string): number {
+ const lf = buffer.indexOf('\n\n');
+ const crlf = buffer.indexOf('\r\n\r\n');
+ if (lf < 0) return crlf;
+ if (crlf < 0) return lf;
+ return Math.min(lf, crlf);
+}
diff --git a/packages/sdk/src/events.ts b/packages/sdk/src/events.ts
new file mode 100644
index 00000000..ffc3d3d0
--- /dev/null
+++ b/packages/sdk/src/events.ts
@@ -0,0 +1,9 @@
+import type { MitiiEvent } from './types';
+
+export function isMitiiEvent(value: unknown): value is MitiiEvent {
+ return Boolean(value && typeof value === 'object' && typeof (value as { type?: unknown }).type === 'string');
+}
+
+export function isTerminalEvent(event: MitiiEvent): boolean {
+ return event.type === 'done' || event.type === 'error';
+}
diff --git a/packages/sdk/src/index.d.ts b/packages/sdk/src/index.d.ts
new file mode 100644
index 00000000..dbbc1aea
--- /dev/null
+++ b/packages/sdk/src/index.d.ts
@@ -0,0 +1,29 @@
+export type {
+ MitiiApprovalDecision,
+ MitiiApprovalMode,
+ MitiiClientOptions,
+ MitiiEvent,
+ MitiiMode,
+ MitiiQueryOptions,
+ MitiiResult,
+ MitiiRuntime,
+} from './types';
+
+import type { MitiiApprovalDecision, MitiiClientOptions, MitiiEvent, MitiiQueryOptions, MitiiResult } from './types';
+
+export declare class MitiiClient {
+ constructor(options: MitiiClientOptions);
+ initialize(): Promise;
+ ask(prompt: string): Promise;
+ plan(prompt: string): Promise>;
+ agent(prompt: string, signal?: AbortSignal): AsyncIterable;
+ query(options: Omit & { prompt: string }): AsyncIterable;
+ run(options: Omit & { prompt: string }): Promise;
+ resolveApproval(id: string, decision: MitiiApprovalDecision): boolean;
+ dispose(): Promise;
+}
+
+export declare function createClient(options: MitiiClientOptions): MitiiClient;
+export declare function query(options: MitiiQueryOptions): AsyncIterable;
+export declare function isMitiiEvent(value: unknown): value is MitiiEvent;
+export declare function isTerminalEvent(event: MitiiEvent): boolean;
diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts
new file mode 100644
index 00000000..2e26cf03
--- /dev/null
+++ b/packages/sdk/src/index.ts
@@ -0,0 +1,13 @@
+export { MitiiClient, createClient, query } from './client';
+export { DaemonClient, DaemonSessionClient, parseSseStream } from './daemon';
+export { isMitiiEvent, isTerminalEvent } from './events';
+export type {
+ MitiiApprovalDecision,
+ MitiiApprovalMode,
+ MitiiClientOptions,
+ MitiiEvent,
+ MitiiMode,
+ MitiiQueryOptions,
+ MitiiResult,
+ MitiiRuntime,
+} from './types';
diff --git a/packages/sdk/src/types.d.ts b/packages/sdk/src/types.d.ts
new file mode 100644
index 00000000..c8e72a2f
--- /dev/null
+++ b/packages/sdk/src/types.d.ts
@@ -0,0 +1,47 @@
+export type MitiiMode = 'ask' | 'plan' | 'agent' | 'review';
+export type MitiiApprovalMode = 'auto' | 'manual';
+export type MitiiRuntime = 'real' | 'stub';
+
+export interface MitiiClientOptions {
+ cwd: string;
+ packageRoot?: string;
+ runtime?: MitiiRuntime;
+ provider?: string;
+ providerType?: string;
+ baseUrl?: string;
+ model?: string;
+ apiKey?: string;
+ approval?: MitiiApprovalMode;
+ allowNetwork?: boolean;
+ enablePuppeteer?: boolean;
+ vectors?: boolean;
+ indexWorkspace?: boolean;
+}
+
+export interface MitiiQueryOptions extends MitiiClientOptions {
+ mode?: MitiiMode;
+ prompt: string;
+ sessionId?: string;
+ signal?: AbortSignal;
+}
+
+export type MitiiApprovalDecision = 'approved' | 'denied';
+
+export type MitiiEvent =
+ | { type: 'session_start'; sessionId: string; mode: string; cwd: string; data?: Record }
+ | { type: 'assistant_delta'; content: string }
+ | { type: 'reasoning_delta'; content: string }
+ | { type: 'tool_start'; tool: string; input?: Record; id?: string; message?: string }
+ | { type: 'tool_end'; tool: string; success: boolean; output?: string; error?: string; durationMs?: number; id?: string }
+ | { type: 'approval_required'; id: string; tool: string; input?: Record; message?: string }
+ | { type: 'approval_resolved'; id?: string; tool?: string; decision?: MitiiApprovalDecision | string }
+ | { type: 'plan'; plan: Record }
+ | { type: 'metrics'; durationMs: number; toolCalls: number; sessionLogPath?: string; auditTools?: string[] }
+ | { type: 'error'; message: string; data?: Record }
+ | { type: 'done'; content: string; metrics?: Record }
+ | { type: 'log'; event: Record };
+
+export interface MitiiResult {
+ content: string;
+ events: MitiiEvent[];
+}
diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts
new file mode 100644
index 00000000..c8e72a2f
--- /dev/null
+++ b/packages/sdk/src/types.ts
@@ -0,0 +1,47 @@
+export type MitiiMode = 'ask' | 'plan' | 'agent' | 'review';
+export type MitiiApprovalMode = 'auto' | 'manual';
+export type MitiiRuntime = 'real' | 'stub';
+
+export interface MitiiClientOptions {
+ cwd: string;
+ packageRoot?: string;
+ runtime?: MitiiRuntime;
+ provider?: string;
+ providerType?: string;
+ baseUrl?: string;
+ model?: string;
+ apiKey?: string;
+ approval?: MitiiApprovalMode;
+ allowNetwork?: boolean;
+ enablePuppeteer?: boolean;
+ vectors?: boolean;
+ indexWorkspace?: boolean;
+}
+
+export interface MitiiQueryOptions extends MitiiClientOptions {
+ mode?: MitiiMode;
+ prompt: string;
+ sessionId?: string;
+ signal?: AbortSignal;
+}
+
+export type MitiiApprovalDecision = 'approved' | 'denied';
+
+export type MitiiEvent =
+ | { type: 'session_start'; sessionId: string; mode: string; cwd: string; data?: Record }
+ | { type: 'assistant_delta'; content: string }
+ | { type: 'reasoning_delta'; content: string }
+ | { type: 'tool_start'; tool: string; input?: Record; id?: string; message?: string }
+ | { type: 'tool_end'; tool: string; success: boolean; output?: string; error?: string; durationMs?: number; id?: string }
+ | { type: 'approval_required'; id: string; tool: string; input?: Record; message?: string }
+ | { type: 'approval_resolved'; id?: string; tool?: string; decision?: MitiiApprovalDecision | string }
+ | { type: 'plan'; plan: Record }
+ | { type: 'metrics'; durationMs: number; toolCalls: number; sessionLogPath?: string; auditTools?: string[] }
+ | { type: 'error'; message: string; data?: Record }
+ | { type: 'done'; content: string; metrics?: Record }
+ | { type: 'log'; event: Record };
+
+export interface MitiiResult {
+ content: string;
+ events: MitiiEvent[];
+}
diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json
new file mode 100644
index 00000000..3949515f
--- /dev/null
+++ b/packages/sdk/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "declaration": true,
+ "declarationMap": true,
+ "emitDeclarationOnly": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "noUnusedLocals": false
+ },
+ "include": ["src/**/*"],
+ "exclude": ["dist", "test"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ac05277d..b52a8bf7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -125,6 +125,37 @@ importers:
specifier: ^0.24.7
version: 0.24.7
+ packages/board: {}
+
+ packages/channels:
+ dependencies:
+ '@mitii/sdk':
+ specifier: workspace:*
+ version: link:../sdk
+
+ packages/cli: {}
+
+ packages/daemon: {}
+
+ packages/sdk:
+ dependencies:
+ '@xenova/transformers':
+ specifier: '>=2'
+ version: 2.17.2
+ better-sqlite3:
+ specifier: '>=12'
+ version: 12.11.1
+ devDependencies:
+ esbuild:
+ specifier: ^0.21.5
+ version: 0.21.5
+ typescript:
+ specifier: ^5.5.2
+ version: 5.9.3
+ vitest:
+ specifier: ^1.6.0
+ version: 1.6.1(@types/node@20.19.43)
+
tools/benchmark: {}
packages:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 2cda91e2..7190d75d 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,2 +1,6 @@
packages:
- 'tools/*'
+ - 'packages/*'
+
+onlyBuiltDependencies:
+ - sharp
diff --git a/scripts/build-airgap-bundle.sh b/scripts/build-airgap-bundle.sh
new file mode 100755
index 00000000..575a3e22
--- /dev/null
+++ b/scripts/build-airgap-bundle.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+version="$(node -p "require('./package.json').version")"
+out="${MITII_AIRGAP_OUT:-dist-airgap/mitii-${version}}"
+mkdir -p "$out"
+
+pnpm run package
+cp ./*.vsix "$out/" 2>/dev/null || true
+cp scripts/install.sh scripts/install.ps1 "$out/"
+cp README.md LICENSE "$out/"
+
+cat > "$out/README.md" </dev/null 2>&1; then
+ echo "Node.js 20+ is required." >&2
+ exit 1
+fi
+
+NODE_MAJOR="$(node -p "process.versions.node.split('.')[0]")"
+if [[ "${NODE_MAJOR}" -lt 20 ]]; then
+ echo "Node.js 20+ is required; found $(node -v)." >&2
+ exit 1
+fi
+
+mkdir -p "${ROOT}/.mitii"
+cat > "${ROOT}/.mitii/mcp.json" <<'JSON'
+{
+ "mcpServers": {
+ "agentmemory": {
+ "disabled": false,
+ "type": "streamable-http",
+ "url": "http://localhost:3111/mcp",
+ "headers": {},
+ "timeoutMs": 30000
+ }
+ }
+}
+JSON
+
+echo "agentmemory MCP configured at ${ROOT}/.mitii/mcp.json"
+echo "Install/start agentmemory separately, then verify http://localhost:3111/agentmemory/livez"
diff --git a/scripts/copy-bundled-skills.mjs b/scripts/copy-bundled-skills.mjs
index e55fcc72..e450bd97 100644
--- a/scripts/copy-bundled-skills.mjs
+++ b/scripts/copy-bundled-skills.mjs
@@ -3,14 +3,18 @@ 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);
+function copyBundledDir(relSource, relDest, label) {
+ const source = join(root, relSource);
+ const dest = join(root, relDest);
+ if (!existsSync(source)) {
+ console.error(`Missing bundled ${label} source:`, source);
+ process.exit(1);
+ }
+ mkdirSync(dirname(dest), { recursive: true });
+ cpSync(source, dest, { recursive: true, force: true });
+ console.log(`Copied bundled ${label} to`, dest);
}
-mkdirSync(dirname(dest), { recursive: true });
-cpSync(source, dest, { recursive: true, force: true });
-console.log('Copied bundled skills to', dest);
+copyBundledDir('src/core/skills/bundled', 'dist/core/skills/bundled', 'skills');
+copyBundledDir('src/core/rules/bundled', 'dist/core/rules/bundled', 'rules');
diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh
index 3b494303..be2ec465 100755
--- a/scripts/dev-setup.sh
+++ b/scripts/dev-setup.sh
@@ -3,7 +3,7 @@ set -euo pipefail
cd "$(dirname "$0")/.."
-editor="${THUNDER_EDITOR:-vscode}"
+editor="${MITII_EDITOR:-${THUNDER_EDITOR:-vscode}}"
echo "Installing dependencies..."
pnpm install
@@ -13,12 +13,12 @@ pnpm run compile
if [[ "$(uname -s)" == "Darwin" ]]; then
echo "Rebuilding native modules for ${editor}..."
- THUNDER_EDITOR="${editor}" pnpm run rebuild:native
+ MITII_EDITOR="${editor}" pnpm run rebuild:native
else
cat <<'NOTE'
Skipping Electron native rebuild auto-detection on this OS.
-Set THUNDER_ELECTRON_VERSION for your editor, then run:
- THUNDER_ELECTRON_VERSION= pnpm run rebuild:native
+Set MITII_ELECTRON_VERSION for your editor, then run:
+ MITII_ELECTRON_VERSION= pnpm run rebuild:native
NOTE
fi
diff --git a/scripts/install.ps1 b/scripts/install.ps1
new file mode 100644
index 00000000..1e1e09f8
--- /dev/null
+++ b/scripts/install.ps1
@@ -0,0 +1,28 @@
+$ErrorActionPreference = "Stop"
+
+$Version = if ($env:MITII_VERSION) { $env:MITII_VERSION } else { "latest" }
+$InstallDir = if ($env:MITII_INSTALL_DIR) { $env:MITII_INSTALL_DIR } else { Join-Path $HOME ".mitii\\bin" }
+$Asset = "mitii-win32-x64.zip"
+$Base = "https://github.com/codewithshinde/thunder-ai-agent/releases"
+$Url = if ($Version -eq "latest") { "$Base/latest/download/$Asset" } else { "$Base/download/$Version/$Asset" }
+$SumsUrl = if ($Version -eq "latest") { "$Base/latest/download/SHA256SUMS" } else { "$Base/download/$Version/SHA256SUMS" }
+
+New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
+$Temp = New-Item -ItemType Directory -Force -Path (Join-Path ([System.IO.Path]::GetTempPath()) "mitii-install-$([System.Guid]::NewGuid())")
+try {
+ $Archive = Join-Path $Temp $Asset
+ Invoke-WebRequest -Uri $Url -OutFile $Archive
+ try {
+ $Sums = Join-Path $Temp "SHA256SUMS"
+ Invoke-WebRequest -Uri $SumsUrl -OutFile $Sums
+ $Expected = (Get-Content $Sums | Select-String " $Asset$").ToString().Split(" ")[0]
+ $Actual = (Get-FileHash $Archive -Algorithm SHA256).Hash.ToLowerInvariant()
+ if ($Expected -and $Actual -ne $Expected.ToLowerInvariant()) { throw "SHA256 mismatch" }
+ } catch {
+ Write-Warning "Checksum verification skipped: $_"
+ }
+ Expand-Archive -Force $Archive $InstallDir
+ Write-Host "Installed mitii to $InstallDir"
+} finally {
+ Remove-Item -Recurse -Force $Temp
+}
diff --git a/scripts/install.sh b/scripts/install.sh
new file mode 100755
index 00000000..41423294
--- /dev/null
+++ b/scripts/install.sh
@@ -0,0 +1,44 @@
+#!/usr/bin/env sh
+set -eu
+
+VERSION="${MITII_VERSION:-latest}"
+BASE_URL="${MITII_RELEASE_BASE_URL:-https://github.com/codewithshinde/thunder-ai-agent/releases/download}"
+INSTALL_DIR="${MITII_INSTALL_DIR:-$HOME/.mitii/bin}"
+
+os="$(uname -s | tr '[:upper:]' '[:lower:]')"
+arch="$(uname -m)"
+case "$arch" in
+ arm64|aarch64) arch="arm64" ;;
+ x86_64|amd64) arch="x64" ;;
+ *) echo "Unsupported architecture: $arch" >&2; exit 1 ;;
+esac
+
+asset="mitii-${os}-${arch}.tar.gz"
+if [ "$VERSION" = "latest" ]; then
+ url="https://github.com/codewithshinde/thunder-ai-agent/releases/latest/download/$asset"
+ sums_url="https://github.com/codewithshinde/thunder-ai-agent/releases/latest/download/SHA256SUMS"
+else
+ url="$BASE_URL/$VERSION/$asset"
+ sums_url="$BASE_URL/$VERSION/SHA256SUMS"
+fi
+
+tmp="$(mktemp -d)"
+trap 'rm -rf "$tmp"' EXIT
+mkdir -p "$INSTALL_DIR"
+curl -fsSL "$url" -o "$tmp/$asset"
+if curl -fsSL "$sums_url" -o "$tmp/SHA256SUMS"; then
+ if command -v sha256sum >/dev/null 2>&1; then
+ (cd "$tmp" && grep " $asset$" SHA256SUMS | sha256sum -c -)
+ else
+ expected="$(grep " $asset$" "$tmp/SHA256SUMS" | awk '{print $1}')"
+ actual="$(shasum -a 256 "$tmp/$asset" | awk '{print $1}')"
+ [ "$expected" = "$actual" ] || { echo "SHA256 mismatch" >&2; exit 1; }
+ fi
+fi
+tar -xzf "$tmp/$asset" -C "$INSTALL_DIR"
+chmod +x "$INSTALL_DIR/mitii"
+echo "Installed mitii to $INSTALL_DIR/mitii"
+case ":$PATH:" in
+ *":$INSTALL_DIR:"*) ;;
+ *) echo "Add $INSTALL_DIR to PATH to run mitii from any shell." ;;
+esac
diff --git a/scripts/rebuild-native.mjs b/scripts/rebuild-native.mjs
index bc2098b8..3e701f10 100644
--- a/scripts/rebuild-native.mjs
+++ b/scripts/rebuild-native.mjs
@@ -1,15 +1,35 @@
#!/usr/bin/env node
/**
- * Rebuild native modules (better-sqlite3) for VS Code / Cursor Electron.
+ * Rebuild native modules for VS Code / Cursor Electron.
* A normal install compiles for Node.js; the extension host uses Electron's ABI.
+ * Also ensures sharp carries vendored libvips so MiniLM text embeddings do not fail
+ * when @xenova/transformers imports its image utility module.
*
- * Override: THUNDER_ELECTRON_VERSION=42.2.0 pnpm run rebuild:native
- * Override editor: THUNDER_EDITOR=cursor pnpm run rebuild:native
+ * Override: MITII_ELECTRON_VERSION=42.2.0 pnpm run rebuild:native
+ * Override editor: MITII_EDITOR=cursor pnpm run rebuild:native
*/
import { execSync, spawnSync } from 'child_process';
import { existsSync } from 'fs';
+import { dirname, resolve } from 'path';
+import { fileURLToPath } from 'url';
const MODULES = ['better-sqlite3'];
+const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+
+function ensureSharpVendor() {
+ console.log('Ensuring sharp vendored libvips is installed for MiniLM embeddings…');
+ const result = spawnSync(
+ 'pnpm',
+ ['rebuild', 'sharp'],
+ {
+ cwd: packageRoot,
+ stdio: 'inherit',
+ shell: process.platform === 'win32',
+ env: { ...process.env, SHARP_IGNORE_GLOBAL_LIBVIPS: '1' },
+ }
+ );
+ return result.status === 0;
+}
function readElectronFromPlist(plistPath) {
if (!existsSync(plistPath)) return null;
@@ -23,8 +43,8 @@ function readElectronFromPlist(plistPath) {
}
function detectElectronVersion() {
- if (process.env.THUNDER_ELECTRON_VERSION) {
- return process.env.THUNDER_ELECTRON_VERSION;
+ if (process.env.MITII_ELECTRON_VERSION || process.env.THUNDER_ELECTRON_VERSION) {
+ return process.env.MITII_ELECTRON_VERSION || process.env.THUNDER_ELECTRON_VERSION;
}
const editors = {
@@ -38,7 +58,7 @@ function detectElectronVersion() {
},
};
- const preferred = (process.env.THUNDER_EDITOR ?? 'vscode').toLowerCase();
+ const preferred = (process.env.MITII_EDITOR ?? process.env.THUNDER_EDITOR ?? 'vscode').toLowerCase();
const order =
preferred === 'cursor' ? ['cursor', 'vscode'] : ['vscode', 'cursor'];
@@ -56,6 +76,11 @@ function detectElectronVersion() {
}
function main() {
+ if (!ensureSharpVendor()) {
+ console.error('\nRebuild failed for sharp/libvips.');
+ process.exit(1);
+ }
+
const electronVersion = detectElectronVersion();
console.log(`Rebuilding native modules for Electron ${electronVersion}…`);
@@ -72,18 +97,18 @@ function main() {
'-w',
...MODULES,
],
- { stdio: 'inherit', shell: true }
+ { cwd: packageRoot, stdio: 'inherit', shell: true }
);
if (result.status !== 0) {
console.error('\nRebuild failed. Try:');
- console.error(' THUNDER_ELECTRON_VERSION=42.2.0 pnpm run rebuild:native # VS Code 1.124+');
- console.error(' THUNDER_ELECTRON_VERSION=39.8.1 pnpm run rebuild:native # Cursor');
+ console.error(' MITII_ELECTRON_VERSION=42.2.0 pnpm run rebuild:native # VS Code 1.124+');
+ console.error(' MITII_ELECTRON_VERSION=39.8.1 pnpm run rebuild:native # Cursor');
process.exit(result.status ?? 1);
}
console.log('\nNative rebuild complete. Reload the Extension Development Host (F5).');
- console.log('Note: run "pnpm run rebuild:node" before CLI eval or "pnpm test" if sqlite fails under Node.');
+ console.log('Note: run "pnpm run rebuild:node" before CLI eval or "pnpm test" if native modules fail under Node.');
}
main();
diff --git a/scripts/rebuild-node.mjs b/scripts/rebuild-node.mjs
index edfe97d1..78279c74 100644
--- a/scripts/rebuild-node.mjs
+++ b/scripts/rebuild-node.mjs
@@ -1,7 +1,9 @@
#!/usr/bin/env node
/**
- * Rebuild native modules (better-sqlite3) for system Node.js.
+ * Rebuild native modules for system Node.js.
* Required for headless CLI eval and Vitest — distinct from Electron (rebuild:native).
+ * Also ensures sharp carries vendored libvips so MiniLM text embeddings do not fail
+ * just because the host machine lacks a matching global libvips install.
*/
import { spawnSync } from 'child_process';
import { createRequire } from 'module';
@@ -28,7 +30,27 @@ function rebuildModule(name) {
return result.status === 0;
}
+function ensureSharpVendor() {
+ console.log('Ensuring sharp vendored libvips is installed for MiniLM embeddings…');
+ const result = spawnSync(
+ 'pnpm',
+ ['rebuild', 'sharp'],
+ {
+ cwd: packageRoot,
+ stdio: 'inherit',
+ shell: process.platform === 'win32',
+ env: { ...process.env, SHARP_IGNORE_GLOBAL_LIBVIPS: '1' },
+ }
+ );
+ return result.status === 0;
+}
+
function main() {
+ if (!ensureSharpVendor()) {
+ console.error('\nRebuild failed for sharp/libvips.');
+ process.exit(1);
+ }
+
for (const name of MODULES) {
if (!rebuildModule(name)) {
console.error(`\nRebuild failed for ${name}.`);
@@ -36,7 +58,7 @@ function main() {
}
}
console.log('\nNative rebuild complete for system Node.');
- console.log('Run pnpm run rebuild:native before F5 if the VS Code extension fails to load sqlite.');
+ console.log('Run pnpm run rebuild:native before F5 if the VS Code extension fails to load native modules.');
}
main();
diff --git a/scripts/sync-sdk-version.mjs b/scripts/sync-sdk-version.mjs
new file mode 100644
index 00000000..53526467
--- /dev/null
+++ b/scripts/sync-sdk-version.mjs
@@ -0,0 +1,11 @@
+import { readFileSync, writeFileSync } from 'fs';
+
+const rootPackagePath = new URL('../package.json', import.meta.url);
+const sdkPackagePath = new URL('../packages/sdk/package.json', import.meta.url);
+
+const rootPackage = JSON.parse(readFileSync(rootPackagePath, 'utf8'));
+const sdkPackage = JSON.parse(readFileSync(sdkPackagePath, 'utf8'));
+
+sdkPackage.version = rootPackage.version;
+writeFileSync(sdkPackagePath, `${JSON.stringify(sdkPackage, null, 2)}\n`);
+console.log(`@mitii/sdk version synced to ${rootPackage.version}`);
diff --git a/scripts/sync-versions.mjs b/scripts/sync-versions.mjs
new file mode 100644
index 00000000..2cf36fe3
--- /dev/null
+++ b/scripts/sync-versions.mjs
@@ -0,0 +1,24 @@
+import { existsSync, readFileSync, writeFileSync } from 'fs';
+
+const root = JSON.parse(readFileSync('package.json', 'utf8'));
+const version = root.version;
+const packageFiles = [
+ 'packages/sdk/package.json',
+ 'packages/daemon/package.json',
+ 'packages/board/package.json',
+ 'packages/channels/package.json',
+ 'packages/cli/package.json',
+ 'packages/cli/optional-packages/mitii-darwin-arm64/package.json',
+ 'packages/cli/optional-packages/mitii-darwin-x64/package.json',
+ 'packages/cli/optional-packages/mitii-linux-x64/package.json',
+ 'packages/cli/optional-packages/mitii-win32-x64/package.json',
+];
+
+for (const file of packageFiles) {
+ if (!existsSync(file)) continue;
+ const pkg = JSON.parse(readFileSync(file, 'utf8'));
+ pkg.version = version;
+ writeFileSync(file, `${JSON.stringify(pkg, null, 2)}\n`);
+}
+
+console.log(`Mitii packages synced to ${version}`);
diff --git a/src/core/app/ThunderController.ts b/src/core/app/ThunderController.ts
index 116f19e8..6b608617 100644
--- a/src/core/app/ThunderController.ts
+++ b/src/core/app/ThunderController.ts
@@ -10,6 +10,12 @@ import { IgnoreService } from '../indexing/IgnoreService';
import { FileDiscoveryService } from '../indexing/FileDiscoveryService';
import { WorkspaceScanner } from '../indexing/WorkspaceScanner';
import { IndexQueue } from '../indexing/IndexQueue';
+import {
+ AUTO_INDEX_BACKGROUND_DELAY_MS,
+ AUTO_INDEX_INITIAL_FILE_LIMIT,
+ priorityDiscoveryRoots,
+ sortIndexCandidates,
+} from '../indexing/indexingPolicy';
import { initTreeSitter, preloadCommonLanguages } from '../indexing/TreeSitterService';
import { setTreeSitterEnabled } from '../indexing/SymbolExtractor';
import { FtsIndex } from '../indexing/FtsIndex';
@@ -28,8 +34,8 @@ import { debounce } from '../util/debounce';
import { ChatOrchestrator } from '../orchestration/ChatOrchestrator';
import { ToolRuntime } from '../tools/ToolRuntime';
import {
- createReadFileTool, createReadFilesTool, createListFilesTool, createSearchTool,
- createSearchBatchTool, createSearchScriptCatalogTool, createSpawnResearchAgentTool,
+ createReadFileTool, createReadFilesTool, createListFilesTool, createResolvePathTool, createSearchTool,
+ createSearchBatchTool, createSearchScriptCatalogTool, createSpawnResearchAgentTool, createSpawnSubagentTool,
createExecuteWorkspaceScriptTool, createUseSkillTool,
createRepoMapTool, createRetrieveContextTool, createGitDiffTool,
createDiagnosticsTool, createWriteFileTool, createApplyPatchTool, createRunCommandTool,
@@ -58,10 +64,14 @@ import { MemoryExtractor } from '../runtime/MemoryExtractor';
import { SubagentTracker } from '../runtime/SubagentTracker';
import { PassiveMemoryInjector } from '../memory/PassiveMemoryInjector';
import { MemoryHookService } from '../memory/MemoryHookService';
+import { AutoMemoryContextSource, AutoMemoryFileWriter } from '../memory/AutoMemoryFileWriter';
import { PostEditValidator } from '../apply/PostEditValidator';
import { VectorContextSource } from '../context/sources/VectorContextSource';
+import { CallGraphContextSource } from '../context/sources/callGraphSource';
import { VectorIndexService } from '../indexing/VectorIndex';
import { createEmbeddingProvider, describeEmbeddingProvider } from '../indexing/embeddingFactory';
+import { getOrCreateLanguageService, disposeLanguageService } from '../indexing/languageServiceFactory';
+import { isTsLikeFile, type WorkspaceLanguageService } from '../indexing/WorkspaceLanguageService';
import { createVectorIndex, describeVectorBackend } from '../indexing/vectorIndexFactory';
import { isLanceDbAvailable, isMinilmAvailable } from '../indexing/vectorAvailability';
import type { EmbeddingProvider } from '../indexing/EmbeddingProvider';
@@ -98,7 +108,7 @@ import {
import { listCustomMcpServers } from '../mcp/mcpWorkspaceConfig';
import { resolveDbPath } from '../indexing/paths';
import { searchWorkspacePaths, resolvePickedPaths } from '../context/contextPathSearch';
-import { createWorkspacePattern, isWorkspaceInVscodeFolders, normalizeWorkspaceRoot, toWorkspaceRelPath } from '../util/paths';
+import { createWorkspacePattern, isWorkspaceInVscodeFolders, normalizeWorkspaceRoot, toWorkspaceRelPath, resolveWorkspaceRelPath } from '../util/paths';
import type { CommitMessageResult } from '../scm';
import { MicroTaskExecutor } from '../microtasks';
import { AuditPackBuilder } from '../audit';
@@ -140,6 +150,7 @@ export class ThunderController {
private gitService: GitService | undefined;
private diagnosticsService = new DiagnosticsService();
private memoryService: MemoryService | undefined;
+ private autoMemoryWriter: AutoMemoryFileWriter | undefined;
private checkpointService: CheckpointService | undefined;
private sessionService: SessionService | undefined;
private planPersistence: PlanPersistence | undefined;
@@ -150,6 +161,9 @@ export class ThunderController {
private postEditValidator: PostEditValidator | undefined;
private vectorIndexService: VectorIndexService | undefined;
private embeddingProvider: EmbeddingProvider | undefined;
+ private languageService: WorkspaceLanguageService | undefined;
+ private languageServiceSyncDisposable: vscode.Disposable | undefined;
+ private languageServiceUpdateDebouncers = new Map void>();
private mcpManager = new McpManager();
private projectRulesService: ProjectRulesService | undefined;
private providerProfilesService: ProviderProfilesService | undefined;
@@ -164,6 +178,7 @@ export class ThunderController {
private mcpToggles: McpToggles = defaultMcpToggles();
private pendingWatchJobs = new Map();
private watchDebounceTimer: ReturnType | undefined;
+ private backgroundIndexTimer: ReturnType | undefined;
private debouncedRebuildRetriever: (() => void) | undefined;
private currentPlan: PlanView | null = null;
private currentReviewDiff: WebviewState['reviewDiff'] = null;
@@ -389,7 +404,7 @@ export class ThunderController {
this.chatOrchestrator?.configure({ researchAgentProvider: this.researchAgentProvider });
this.configDisposable = vscode.workspace.onDidChangeConfiguration((e) => {
- if (e.affectsConfiguration('thunder.workspace') || e.affectsConfiguration('thunder')) {
+ if (e.affectsConfiguration('mitii.workspace') || e.affectsConfiguration('mitii') || e.affectsConfiguration('thunder.workspace') || e.affectsConfiguration('thunder')) {
void this.reloadWorkspace();
}
});
@@ -424,7 +439,7 @@ export class ThunderController {
const config = this.configService.getConfig();
if (!config.indexing.enabled || !config.indexing.autoIndexOnOpen) return;
try {
- await this.indexWorkspace({ force: false });
+ await this.indexWorkspace({ force: false, auto: true });
} catch (error) {
log.warn('Auto-index on open failed', {
error: error instanceof Error ? error.message : String(error),
@@ -478,6 +493,7 @@ export class ThunderController {
respectGitignore: config.indexing.respectGitignore,
respectThunderignore: config.indexing.respectThunderignore,
});
+ this.languageService = getOrCreateLanguageService(workspace, this.ignoreService, config.indexing);
this.scanner = new WorkspaceScanner(db, workspace);
setTreeSitterEnabled(config.indexing.treeSitterEnabled);
@@ -489,6 +505,7 @@ export class ThunderController {
this.indexQueue = new IndexQueue(db, {
maxConcurrency: config.indexing.maxConcurrency,
maxFileSizeBytes: config.indexing.maxFileSizeBytes,
+ deferVectorWrites: true,
});
this.indexQueue.setVectorService(workspace, this.vectorIndexService);
this.indexQueue.onIndexingComplete(() => {
@@ -516,6 +533,10 @@ export class ThunderController {
maxItems: config.memory.maxItems,
hybridSearchEnabled: config.memory.hybridSearchEnabled,
});
+ this.autoMemoryWriter = new AutoMemoryFileWriter(workspace, {
+ enabled: config.memory.autoMemoryEnabled,
+ scope: config.memory.autoMemoryScope,
+ });
if (config.indexing.vectorsEnabled) {
this.memoryService.setEmbedder(this.embeddingProvider);
}
@@ -551,8 +572,11 @@ export class ThunderController {
this.notifyUi({
subagents: runs.map((r) => ({
id: r.id,
+ type: r.type,
task: r.task,
focus: r.focus,
+ scope: r.scope,
+ progress: r.progress,
status: r.status,
startedAt: r.startedAt,
finishedAt: r.finishedAt,
@@ -574,7 +598,8 @@ export class ThunderController {
this.policyEngine = new ToolPolicyEngine(
effectiveSafety,
(path) => this.ignoreService.isIgnored(path),
- () => this.isWorkspaceTrusted()
+ () => this.isWorkspaceTrusted(),
+ (path) => resolveWorkspaceRelPath(workspace, path)
);
this.toolExecutor = new ToolExecutor(
@@ -612,14 +637,16 @@ export class ThunderController {
const repoMap = new RepoMapService(db, workspace);
const fts = new FtsIndex(db);
- this.toolRuntime.register(createReadFileTool(workspace, this.ignoreService));
- this.toolRuntime.register(createReadFilesTool(workspace, this.ignoreService));
+ this.toolRuntime.register(createReadFileTool(workspace, this.ignoreService, db));
+ this.toolRuntime.register(createReadFilesTool(workspace, this.ignoreService, db));
this.toolRuntime.register(createListFilesTool(workspace, this.ignoreService));
+ this.toolRuntime.register(createResolvePathTool(workspace, this.ignoreService, db));
this.toolRuntime.register(createSearchTool(fts, workspace));
this.toolRuntime.register(createSearchBatchTool(fts, workspace));
this.toolRuntime.register(createSearchScriptCatalogTool(workspace, this.context.extensionPath));
this.toolRuntime.register(createExecuteWorkspaceScriptTool(workspace, this.context.extensionPath, this.ignoreService));
this.toolRuntime.register(createUseSkillTool(this.skillCatalogService));
+ this.toolRuntime.register(createSpawnSubagentTool());
this.toolRuntime.register(createSpawnResearchAgentTool());
this.toolRuntime.register(createRepoMapTool(repoMap));
this.toolRuntime.register(createRetrieveContextTool(retriever, budgeter));
@@ -659,10 +686,43 @@ export class ThunderController {
this.memoryExtractor = new MemoryExtractor(
this.memoryService,
- config.memory.summarizeAfterTask
+ config.memory.summarizeAfterTask,
+ this.autoMemoryWriter
);
this.setupFileWatcher(workspace);
+ this.setupLanguageServiceSync(workspace);
+ }
+
+ /** Keeps the persistent language service's in-memory AST synchronized with unsaved editor
+ * buffers. Debounced per-file — a single shared debounce would drop updates when the user
+ * edits two files close together in time. */
+ private setupLanguageServiceSync(workspace: string): void {
+ this.languageServiceSyncDisposable?.dispose();
+ this.languageServiceUpdateDebouncers.clear();
+
+ const config = this.configService.getConfig();
+ const pendingContent = new Map();
+ this.languageServiceSyncDisposable = vscode.workspace.onDidChangeTextDocument((e) => {
+ if (!isTsLikeFile(e.document.fileName)) return;
+ const relPath = toWorkspaceRelPath(e.document.uri, workspace);
+ if (!relPath || this.ignoreService.isIgnored(relPath)) return;
+
+ // Always capture the latest text synchronously; only the *flush* is debounced, so a
+ // debounced closure created on an earlier keystroke never applies stale content.
+ pendingContent.set(relPath, e.document.getText());
+
+ let scheduled = this.languageServiceUpdateDebouncers.get(relPath);
+ if (!scheduled) {
+ scheduled = debounce(() => {
+ const content = pendingContent.get(relPath);
+ if (content !== undefined) this.languageService?.updateFile(relPath, content);
+ }, config.indexing.watchDebounceMs);
+ this.languageServiceUpdateDebouncers.set(relPath, scheduled);
+ }
+ scheduled();
+ });
+ this.context.subscriptions.push(this.languageServiceSyncDisposable);
}
private createChatOrchestrator(
@@ -825,9 +885,13 @@ export class ThunderController {
if (this.contextToggles.gitDiff && this.gitService) sources.push(new GitDiffContextSource(this.gitService));
if (this.contextToggles.diagnostics) sources.push(new DiagnosticsContextSource(this.diagnosticsService));
if (this.contextToggles.memory) sources.push(new MemoryContextSource(this.memoryService));
+ if (this.contextToggles.memory && this.autoMemoryWriter) sources.push(new AutoMemoryContextSource(this.autoMemoryWriter));
if (this.contextToggles.vectors && this.vectorIndexService) {
sources.push(new VectorContextSource(this.vectorIndexService, workspace));
}
+ if (this.contextToggles.callGraph && this.languageService) {
+ sources.push(new CallGraphContextSource(db, workspace, this.languageService));
+ }
const config = this.configService.getConfig();
const reranker = createContextReranker(
@@ -864,10 +928,11 @@ export class ThunderController {
);
const enqueue = (uri: vscode.Uri) => {
- if (!this.indexQueue || !this.scanner) return;
if (!this.isWorkspaceTrusted()) return;
const relPath = toWorkspaceRelPath(uri, workspace);
if (!relPath || this.ignoreService.isIgnored(relPath)) return;
+ if (isTsLikeFile(relPath)) this.languageService?.syncFileFromDisk(relPath);
+ if (!this.indexQueue || !this.scanner) return;
const fileId = this.scanner.getFileId(relPath);
if (fileId) {
this.pendingWatchJobs.set(relPath, {
@@ -887,6 +952,13 @@ export class ThunderController {
watcher.onDidChange(enqueue);
watcher.onDidCreate(enqueue);
+ watcher.onDidDelete((uri) => {
+ if (!this.isWorkspaceTrusted()) return;
+ const relPath = toWorkspaceRelPath(uri, workspace);
+ if (relPath && isTsLikeFile(relPath) && !this.ignoreService.isIgnored(relPath)) {
+ this.languageService?.syncFileFromDisk(relPath);
+ }
+ });
this.context.subscriptions.push(watcher);
const refreshSkills = () => {
@@ -940,20 +1012,18 @@ export class ThunderController {
agentLiveStatus: base.agentLiveStatus ?? this.agentLiveStatus,
subagents: base.subagents ?? this.subagentTracker.getRuns().map((r) => ({
id: r.id,
+ type: r.type,
task: r.task,
focus: r.focus,
+ scope: r.scope,
+ progress: r.progress,
status: r.status,
startedAt: r.startedAt,
finishedAt: r.finishedAt,
summary: r.summary,
error: r.error,
})),
- vectorIndex: {
- enabled: config.indexing.vectorsEnabled,
- embeddedChunks: this.vectorIndexService?.count(workspacePath) ?? 0,
- provider: describeEmbeddingProvider(config.indexing),
- backend: describeVectorBackend(config.indexing),
- },
+ vectorIndex: buildVectorIndexStatusView(config.indexing, workspacePath, this.vectorIndexService),
tokenUsage: base.tokenUsage ?? {
...this.tokenUsage,
contextWindow: config.provider.contextWindow,
@@ -995,6 +1065,9 @@ export class ThunderController {
requireApprovalWrites: config.safety.requireApprovalForWrites,
requireApprovalShell: config.safety.requireApprovalForShell,
memoryEnabled: config.memory.enabled,
+ summarizeAfterTask: config.memory.summarizeAfterTask,
+ autoMemoryEnabled: config.memory.autoMemoryEnabled,
+ autoMemoryScope: config.memory.autoMemoryScope,
subagentsEnabled: config.agent.subagentsEnabled,
agentMaxSteps: config.agent.maxSteps,
askDepth: config.agent.askDepth,
@@ -1236,6 +1309,21 @@ export class ThunderController {
}
getSession(): ThunderSession | undefined { return this.session; }
+
+ /** Reset per-turn task routing state when the user switches chat modes mid-thread. */
+ handleModeChange(mode: ThunderMode): void {
+ this.session?.setMode(mode);
+ this.agentTaskState.reset();
+ this.agentTaskState.setLimits({
+ maxSequentialThinkingCalls: this.configService.getConfig().agent.maxSequentialThinkingCallsPerTurn,
+ });
+ this.chatOrchestrator?.clearRoutingState();
+
+ if (mode === 'ask' && this.session?.id) {
+ this.planPersistence?.complete(this.session.id);
+ this.currentPlan = null;
+ }
+ }
restoreChatSession(sessionId: string, options: { mode?: ThunderMode } = {}): PlanView | null {
const restoredId = sessionId.trim();
if (!restoredId) return this.currentPlan;
@@ -1773,8 +1861,14 @@ export class ThunderController {
this.configureSessionLogging(this.session, workspace);
}
this.chatOrchestrator = undefined;
+ if (this.backgroundIndexTimer) {
+ clearTimeout(this.backgroundIndexTimer);
+ this.backgroundIndexTimer = undefined;
+ }
this.indexService?.dispose();
this.indexService = undefined;
+ if (previousWorkspace) disposeLanguageService(previousWorkspace);
+ this.languageService = undefined;
this.scanner = undefined;
this.indexQueue = undefined;
this.projectRulesService = undefined;
@@ -1816,9 +1910,11 @@ export class ThunderController {
const audit = this.toolRuntime.getAuditLog();
const summary = this.buildTurnSummary(audit);
- const hadActivityErrors = this.agentActivity.some((entry) => entry.kind === 'error');
- const hadToolFailures = audit.some((entry) => !entry.result.success);
- const hadError = options?.hadError || hadActivityErrors || hadToolFailures;
+ const fatalToolFailures = findFatalToolFailures(audit);
+ const hadActivityErrors = this.agentActivity.some(
+ (entry) => entry.kind === 'error' && !isRecoveredToolActivity(entry.message, audit, fatalToolFailures)
+ );
+ const hadError = options?.hadError || hadActivityErrors || fatalToolFailures.length > 0;
const entry: import('../../vscode/webview/messages').AgentActivityEntry = {
id: `act-complete-${Date.now()}`,
@@ -2206,14 +2302,19 @@ export class ThunderController {
const result = await this.toolExecutor.executeApproved(request.toolName, fullInput);
if (result.success) {
+ const isExternalRead = ['read_file', 'read_files'].includes(request.toolName);
const successMessage = request.toolName === 'run_command'
? 'Ran approved command'
- : `Applied ${path ?? request.toolName}`;
+ : isExternalRead
+ ? `Read ${path ?? 'external file'}`
+ : `Applied ${path ?? request.toolName}`;
this.pushActivity(request.toolName === 'run_command' ? 'tool' : 'apply', successMessage, result.output);
if (request.toolName === 'run_command' && typeof fullInput.command === 'string') {
this.pendingApprovalOutputs.push(
`### Command\n\`${fullInput.command}\`\n\n### Output\n${result.output.slice(0, 6000)}`
);
+ } else if (isExternalRead) {
+ this.pendingApprovalOutputs.push(`Read ${request.toolName} for \`${path ?? request.files.join(', ')}\``);
} else if (path) {
this.pendingApprovalOutputs.push(`Applied ${request.toolName} to \`${path}\``);
}
@@ -2227,9 +2328,13 @@ export class ThunderController {
});
}
void vscode.window.showInformationMessage(
- request.toolName === 'run_command' ? brandMessage('Command completed.') : `${AGENT_NAME}: Updated ${path ?? 'file'}`
+ request.toolName === 'run_command'
+ ? brandMessage('Command completed.')
+ : isExternalRead
+ ? `${AGENT_NAME}: Read ${path ?? 'external file'}`
+ : `${AGENT_NAME}: Updated ${path ?? 'file'}`
);
- if (path) {
+ if (path && !isExternalRead) {
const workspace = this.resolveWorkspacePath();
if (workspace) {
void vscode.window.showTextDocument(vscode.Uri.file(join(workspace, path)));
@@ -2397,9 +2502,14 @@ export class ThunderController {
}
async saveAgentSettings(settings: AgentSettingsPayload): Promise {
+ const previousDepth = this.configService.getConfig().agent.actDepth;
await this.configService.updateAgentSettings(normalizeAgentSettings(settings));
const config = this.configService.getConfig();
+ if (settings.actDepth === 'quick' || (previousDepth !== 'quick' && config.agent.actDepth === 'quick')) {
+ this.agentTaskState.reset();
+ this.chatOrchestrator?.clearRoutingState();
+ }
await this.refreshResearchAgentProvider();
this.chatOrchestrator?.configure({
agentConfig: config.agent,
@@ -2433,12 +2543,19 @@ export class ThunderController {
try {
const beforeConfig = this.configService.getConfig();
const normalized = normalizeThunderSettings(settings, beforeConfig.provider.contextWindow, this.mcpToggles);
+ const normalizedMemory = normalized.memory ?? {
+ summarizeAfterTask: true,
+ autoMemoryEnabled: true,
+ autoMemoryScope: 'user' as const,
+ };
const vectorConfigChanged =
beforeConfig.indexing.vectorsEnabled !== normalized.indexing.vectorsEnabled ||
beforeConfig.indexing.embeddingProvider !== normalized.indexing.embeddingProvider ||
beforeConfig.indexing.vectorBackend !== normalized.indexing.vectorBackend ||
- beforeConfig.memory.hybridSearchEnabled !== normalized.indexing.hybridMemorySearch;
+ beforeConfig.memory.hybridSearchEnabled !== normalized.indexing.hybridMemorySearch ||
+ beforeConfig.memory.autoMemoryEnabled !== normalizedMemory.autoMemoryEnabled ||
+ beforeConfig.memory.autoMemoryScope !== normalizedMemory.autoMemoryScope;
await this.configService.updateAllSettings(normalized);
@@ -2586,6 +2703,7 @@ export class ThunderController {
memory: builtin.memory,
sequentialThinking: builtin.sequentialThinking,
puppeteer: builtin.puppeteer ?? false,
+ agentmemory: builtin.agentmemory ?? false,
};
}
@@ -2663,7 +2781,7 @@ export class ThunderController {
await this.showInlineDiffForPendingApprovals(approvalId);
}
- async indexWorkspace(options: { force?: boolean } = { force: true }): Promise {
+ async indexWorkspace(options: { force?: boolean; auto?: boolean; background?: boolean } = { force: true }): Promise {
const workspace = this.resolveWorkspacePath();
if (!workspace) {
this.setWorkspaceNotice('warn', 'Set a workspace path first (Browse or paste an absolute path).');
@@ -2693,18 +2811,31 @@ export class ThunderController {
return;
}
- const discovery = new FileDiscoveryService(workspace, this.ignoreService, config.indexing);
- const files = discovery.discover();
-
if (!this.scanner || !this.indexQueue) {
void vscode.window.showErrorMessage(brandMessage('Index services not initialized.'));
return;
}
- const diff = this.scanner.computeDiff(files);
+ const previousStatus = this.indexQueue.getStatus();
+ const firstAutoRun = Boolean(options.auto && !options.background && previousStatus.indexed === 0);
+ const priorityRoots = firstAutoRun ? priorityDiscoveryRoots(workspace) : [];
+ const isPartialDiscovery = firstAutoRun;
+ const discovery = new FileDiscoveryService(workspace, this.ignoreService, config.indexing);
+ const files = sortIndexCandidates(
+ await discovery.discoverAsync({
+ roots: isPartialDiscovery && priorityRoots.length > 0 ? priorityRoots : undefined,
+ limit: isPartialDiscovery ? AUTO_INDEX_INITIAL_FILE_LIMIT : undefined,
+ }),
+ config.indexing.priorityPaths
+ );
+
+ const diff = this.scanner.computeDiff(files, { includeDeleted: !isPartialDiscovery });
this.scanner.persistScan(diff);
- const filesToIndex = options.force ? files : [...diff.added, ...diff.changed];
+ const filesToIndex = sortIndexCandidates(
+ options.force ? files : [...diff.added, ...diff.changed],
+ config.indexing.priorityPaths
+ );
const jobs = filesToIndex.map((f) => ({
fileId: this.scanner!.getFileId(f.relPath)!,
relPath: f.relPath,
@@ -2717,25 +2848,44 @@ export class ThunderController {
this.setWorkspaceNotice('ok', 'Index is up to date');
this.sessionLog.append('index_complete', 'Index up to date', { workspace, jobCount: 0 });
this.notifyUi({ indexing: this.indexingStatus, workspaceNotice: this.workspaceNotice });
+ if (firstAutoRun) this.scheduleBackgroundIndex(workspace);
return;
}
this.indexQueue.enqueue(jobs);
this.indexingStatus = this.indexQueue.getStatus();
- this.setWorkspaceNotice('ok', `${options.force ? 'Reindexing' : 'Indexing'} ${jobs.length} files…`);
- this.sessionLog.append('index_start', `${options.force ? 'Reindexing' : 'Indexing'} ${jobs.length} files`, {
+ const label = options.background
+ ? 'Background indexing'
+ : firstAutoRun
+ ? 'Indexing priority files'
+ : options.force
+ ? 'Reindexing'
+ : 'Indexing';
+ this.setWorkspaceNotice('ok', `${label} ${jobs.length} files…`);
+ this.sessionLog.append('index_start', `${label} ${jobs.length} files`, {
workspace,
added: diff.added.length,
changed: diff.changed.length,
removed: diff.deleted.length,
forced: options.force,
+ partial: isPartialDiscovery,
});
this.notifyUi({ indexing: this.indexingStatus, workspaceNotice: this.workspaceNotice });
- log.info('indexWorkspace', { total: jobs.length });
+ log.info('indexWorkspace', { total: jobs.length, partial: isPartialDiscovery, background: options.background });
+ if (firstAutoRun) this.scheduleBackgroundIndex(workspace);
void this.waitForIndexingComplete(workspace, jobs.length);
}
+ private scheduleBackgroundIndex(workspace: string): void {
+ if (this.backgroundIndexTimer || this.disposed) return;
+ this.backgroundIndexTimer = setTimeout(() => {
+ this.backgroundIndexTimer = undefined;
+ if (this.disposed || this.resolveWorkspacePath() !== workspace) return;
+ void this.indexWorkspace({ force: false, background: true });
+ }, AUTO_INDEX_BACKGROUND_DELAY_MS);
+ }
+
private async waitForIndexingComplete(workspace: string, jobCount: number): Promise {
if (!this.indexQueue || jobCount === 0) {
this.sessionLog.append('index_complete', 'Index up to date', { workspace, jobCount: 0 });
@@ -2747,6 +2897,7 @@ export class ThunderController {
await new Promise((resolve) => setTimeout(resolve, 500));
if (Date.now() - start > 600_000) break;
}
+ await this.indexQueue.waitForVectorIndexing();
const status = this.indexQueue.getStatus();
this.sessionLog.append('index_complete', 'Indexing finished', {
@@ -2765,8 +2916,15 @@ export class ThunderController {
this.disposed = true;
this.configService.dispose();
void this.mcpManager.closeAll();
+ if (this.backgroundIndexTimer) clearTimeout(this.backgroundIndexTimer);
+ if (this.watchDebounceTimer) clearTimeout(this.watchDebounceTimer);
+ if (this.indexStatusNotifyTimer) clearTimeout(this.indexStatusNotifyTimer);
+ if (this.tokenUsageNotifyTimer) clearTimeout(this.tokenUsageNotifyTimer);
this.indexService?.dispose();
this.indexQueue?.cancel();
+ this.languageServiceSyncDisposable?.dispose();
+ if (this.session?.workspace) disposeLanguageService(this.session.workspace);
+ this.languageService = undefined;
this.session = undefined;
log.info('ThunderController disposed');
}
@@ -2787,6 +2945,30 @@ export function toApprovalView(r: import('../safety/ApprovalQueue').ApprovalRequ
};
}
+function buildVectorIndexStatusView(
+ indexingConfig: import('../config/schema').IndexingConfig,
+ workspace: string,
+ vectorIndexService: VectorIndexService | undefined
+): import('../../vscode/webview/messages').VectorIndexStatusView {
+ const health = vectorIndexService?.getHealth();
+ const degradedParts: string[] = [];
+ if (health?.embedder.status === 'degraded') {
+ degradedParts.push(`embeddings: ${health.embedder.detail ?? 'unavailable'}`);
+ }
+ if (health?.backend.status === 'degraded') {
+ degradedParts.push(`vector backend: ${health.backend.detail ?? 'unavailable'}`);
+ }
+
+ return {
+ enabled: indexingConfig.vectorsEnabled,
+ embeddedChunks: vectorIndexService?.count(workspace) ?? 0,
+ provider: describeEmbeddingProvider(indexingConfig),
+ backend: describeVectorBackend(indexingConfig),
+ degraded: degradedParts.length > 0,
+ degradedDetail: degradedParts.length > 0 ? degradedParts.join('; ') : undefined,
+ };
+}
+
function toPlanView(plan: import('../plans/PlanActEngine').ThunderPlan | null | undefined): PlanView | null {
if (!plan) return null;
const stepStatus = new Map(plan.steps.map((step) => [step.id, step.status]));
@@ -2827,6 +3009,55 @@ function normalizePromptBreakdown(
];
}
+function findFatalToolFailures(
+ audit: import('../tools/types').ToolCallAudit[]
+): import('../tools/types').ToolCallAudit[] {
+ return audit.filter((entry, index) => isFatalToolFailure(entry, index, audit));
+}
+
+function isFatalToolFailure(
+ entry: import('../tools/types').ToolCallAudit,
+ index: number,
+ audit: import('../tools/types').ToolCallAudit[]
+): boolean {
+ if (entry.result.success || entry.result.skipped) return false;
+
+ const later = audit.slice(index + 1);
+ if (isExplorationTool(entry.toolName)) {
+ return !later.some((candidate) => candidate.result.success && isExplorationTool(candidate.toolName));
+ }
+
+ const key = recoveryKey(entry);
+ return !later.some((candidate) =>
+ candidate.result.success &&
+ candidate.toolName === entry.toolName &&
+ recoveryKey(candidate) === key
+ );
+}
+
+function isRecoveredToolActivity(
+ message: string,
+ audit: import('../tools/types').ToolCallAudit[],
+ fatalToolFailures: import('../tools/types').ToolCallAudit[]
+): boolean {
+ if (!/\bfailed\b/i.test(message)) return false;
+ if (fatalToolFailures.length > 0) return false;
+ return audit.some((entry) => !entry.result.success || entry.result.skipped);
+}
+
+function isExplorationTool(toolName: string): boolean {
+ return ['read_file', 'read_files', 'list_files', 'search', 'search_batch', 'resolve_path', 'repo_map'].includes(toolName);
+}
+
+function recoveryKey(entry: import('../tools/types').ToolCallAudit): string {
+ const input = entry.input as Record;
+ if (typeof input.path === 'string') return `path:${input.path}`;
+ if (typeof input.command === 'string') return `command:${input.command}`;
+ if (typeof input.stepId === 'string') return `step:${input.stepId}`;
+ if (typeof input.script === 'string') return `script:${input.script}`;
+ return entry.toolName;
+}
+
function readPackageVersion(workspace: string): string {
try {
const pkg = JSON.parse(readFileSync(join(workspace, 'package.json'), 'utf8')) as { version?: string };
diff --git a/src/core/apply/PatchApplyService.ts b/src/core/apply/PatchApplyService.ts
index 8fce5bd6..cb3e542b 100644
--- a/src/core/apply/PatchApplyService.ts
+++ b/src/core/apply/PatchApplyService.ts
@@ -101,6 +101,10 @@ export class PatchApplyService {
}
}
+ if (!/\.(?:tsx?|jsx?|mjs|cjs)$/i.test(path)) {
+ return { success: true, proposedContent: content };
+ }
+
const balances = countCodeDelimiters(content);
const jsxBalance = path.endsWith('.tsx') ? countJsxTagBalance(content) : 0;
diff --git a/src/core/config/keys.ts b/src/core/config/keys.ts
index 1a91228f..c6fc76f3 100644
--- a/src/core/config/keys.ts
+++ b/src/core/config/keys.ts
@@ -1,5 +1,10 @@
-export const CONFIG_SECTION = 'thunder';
+export const CONFIG_SECTION = 'mitii';
+export const LEGACY_CONFIG_SECTION = 'thunder';
export function thunderConfigKey(path: string): string {
return `${CONFIG_SECTION}.${path}`;
}
+
+export function legacyThunderConfigKey(path: string): string {
+ return `${LEGACY_CONFIG_SECTION}.${path}`;
+}
diff --git a/src/core/config/schema.ts b/src/core/config/schema.ts
index 194d35ac..c726b3c2 100644
--- a/src/core/config/schema.ts
+++ b/src/core/config/schema.ts
@@ -20,7 +20,7 @@ export const ProviderConfigSchema = z.object({
model: z.string().default('qwen3-coder:30b'),
apiVersion: z.string().default('2024-10-21'),
region: z.string().default('us-east-1'),
- apiKeyRef: z.string().default('thunder.apiKey'),
+ apiKeyRef: z.string().default('mitii.apiKey'),
contextWindow: z.number().int().positive().default(8192),
supportsStreaming: z.boolean().default(true),
supportsTools: z.boolean().default(true),
@@ -30,7 +30,7 @@ export const ProviderConfigSchema = z.object({
});
export const EmbeddingProviderSchema = z.enum(['hash', 'minilm']).default('minilm');
-export const VectorBackendSchema = z.enum(['sqlite', 'lancedb']).default('sqlite');
+export const VectorBackendSchema = z.enum(['sqlite', 'lancedb']).default('lancedb');
export const IndexingConfigSchema = z.object({
enabled: z.boolean().default(true),
@@ -44,6 +44,8 @@ export const IndexingConfigSchema = z.object({
vectorsEnabled: z.boolean().default(true),
embeddingProvider: EmbeddingProviderSchema,
vectorBackend: VectorBackendSchema,
+ watchDebounceMs: z.number().int().min(100).max(10_000).default(500),
+ priorityPaths: z.array(z.string()).default([]),
});
export const ContextConfigSchema = z.object({
@@ -62,6 +64,8 @@ export const EnterpriseConfigSchema = z.object({
localProvidersOnly: z.boolean().default(false),
stripFileContentsFromAuditPacks: z.boolean().default(false),
autoExportAuditPackOnSessionEnd: z.boolean().default(false),
+ channelsDisabled: z.boolean().default(false),
+ maxParallel: z.number().int().min(1).max(100).default(10),
});
export const AgentDepthSchema = z.enum(['auto', 'quick', 'standard', 'deep', 'pilot', 'enterprise']);
@@ -81,10 +85,17 @@ export const MemoryConfigSchema = z.object({
maxItems: z.number().int().positive().default(500),
summarizeAfterTask: z.boolean().default(true),
hybridSearchEnabled: z.boolean().default(true),
+ autoMemoryEnabled: z.boolean().default(true),
+ autoMemoryScope: z.enum(['user', 'workspace', 'both']).default('user'),
});
export const AgentConfigSchema = z.object({
subagentsEnabled: z.boolean().default(true),
+ teamsEnabled: z.boolean().default(false),
+ subagentTypesEnabled: z.array(z.string()).default(['research']),
+ maxConcurrentSubagents: z.number().int().min(1).max(10).default(2),
+ implementerRequiresScope: z.boolean().default(true),
+ subagentDailyBudget: z.number().int().min(0).default(0),
maxSteps: z.number().int().min(1).max(100).default(15),
askMaxSteps: z.number().int().min(1).max(50).default(18),
askDepth: AgentDepthSchema.default('auto'),
@@ -141,6 +152,7 @@ export const BuiltinMcpTogglesSchema = z.object({
memory: z.boolean().default(true),
sequentialThinking: z.boolean().default(true),
puppeteer: z.boolean().default(false),
+ agentmemory: z.boolean().default(false),
});
export const McpConfigSchema = z.object({
@@ -162,7 +174,10 @@ export const ScmConfigSchema = z.object({
export const GitHubConfigSchema = z.object({
issueFetchEnabled: z.boolean().default(true),
issueCommentLimit: z.number().int().min(0).max(25).default(8),
- tokenRef: z.string().default('thunder.github.token'),
+ tokenRef: z.string().default('mitii.github.token'),
+ autoPrEnabled: z.boolean().default(false),
+ defaultBaseBranch: z.string().default(''),
+ webhookSecret: z.string().default(''),
});
export const TelemetryConfigSchema = z.object({
diff --git a/src/core/config/settingPaths.ts b/src/core/config/settingPaths.ts
new file mode 100644
index 00000000..6d02647a
--- /dev/null
+++ b/src/core/config/settingPaths.ts
@@ -0,0 +1,92 @@
+export const MITII_SETTING_PATHS = [
+ 'debug',
+ 'telemetry.sessionLogging',
+ 'telemetry.debugMetrics',
+ 'telemetry.webhookUrl',
+ 'telemetry.webhookSecret',
+ 'telemetry.webhookTimeoutMs',
+ 'ui.showReasoning',
+ 'ui.reasoningPreviewMaxChars',
+ 'enterprise.localProvidersOnly',
+ 'enterprise.stripFileContentsFromAuditPacks',
+ 'enterprise.autoExportAuditPackOnSessionEnd',
+ 'enterprise.channelsDisabled',
+ 'enterprise.maxParallel',
+ 'runtime.mode',
+ 'runtime.daemonUrl',
+ 'runtime.daemonToken',
+ 'runtime.autoStartDaemon',
+ 'provider.type',
+ 'provider.baseUrl',
+ 'provider.model',
+ 'provider.apiVersion',
+ 'provider.region',
+ 'provider.contextWindow',
+ 'provider.supportsVision',
+ 'provider.supportsReasoning',
+ 'indexing.enabled',
+ 'indexing.autoIndexOnOpen',
+ 'indexing.maxFileSizeBytes',
+ 'indexing.hardSkipSizeBytes',
+ 'indexing.maxConcurrency',
+ 'indexing.vectorsEnabled',
+ 'indexing.treeSitterEnabled',
+ 'indexing.embeddingProvider',
+ 'indexing.vectorBackend',
+ 'indexing.watchDebounceMs',
+ 'indexing.priorityPaths',
+ 'context.rerankerEnabled',
+ 'context.rerankerCandidatePool',
+ 'context.rerankerTopK',
+ 'context.microTaskRoutingEnabled',
+ 'safety.requireApprovalForWrites',
+ 'safety.requireApprovalForShell',
+ 'safety.approvalMode',
+ 'safety.autonomyPreset',
+ 'safety.allowUntrustedWorkspace',
+ 'memory.enabled',
+ 'memory.hybridSearchEnabled',
+ 'memory.summarizeAfterTask',
+ 'memory.autoMemoryEnabled',
+ 'memory.autoMemoryScope',
+ 'scm.commitMessageEnabled',
+ 'github.issueFetchEnabled',
+ 'github.issueCommentLimit',
+ 'github.tokenRef',
+ 'github.autoPrEnabled',
+ 'github.defaultBaseBranch',
+ 'github.webhookSecret',
+ 'agent.subagentsEnabled',
+ 'agent.subagentTypesEnabled',
+ 'agent.maxConcurrentSubagents',
+ 'agent.implementerRequiresScope',
+ 'agent.subagentDailyBudget',
+ 'agent.teamsEnabled',
+ 'agent.maxSteps',
+ 'agent.askMaxSteps',
+ 'agent.askDepth',
+ 'agent.planDepth',
+ 'agent.actDepth',
+ 'agent.askAutoContinue',
+ 'agent.askMaxAutoContinues',
+ 'agent.autoContinue',
+ 'agent.maxAutoContinues',
+ 'agent.researchAgentMaxSteps',
+ 'agent.researchAgentModel',
+ 'agent.researchAgentBaseUrl',
+ 'agent.orchestrationEnabled',
+ 'agent.showDiffPreview',
+ 'agent.verifyCommands',
+ 'agent.verifyOnActComplete',
+ 'agent.planModel',
+ 'agent.planBaseUrl',
+ 'agent.actModel',
+ 'agent.actBaseUrl',
+ 'agent.checkpointStrategy',
+ 'mcp.enabled',
+ 'mcp.preloadBuiltin',
+ 'mcp.builtinServers',
+ 'mcp.maxConcurrentStartup',
+ 'mcp.servers',
+ 'workspace.rootPathOverride',
+] as const;
diff --git a/src/core/config/ui/mappers.ts b/src/core/config/ui/mappers.ts
index 3e226961..e4f01478 100644
--- a/src/core/config/ui/mappers.ts
+++ b/src/core/config/ui/mappers.ts
@@ -112,6 +112,11 @@ export function normalizeThunderSettings(
builtinServers: builtinMcpToggles,
},
indexing: settings.indexing,
+ memory: settings.memory ?? {
+ summarizeAfterTask: true,
+ autoMemoryEnabled: true,
+ autoMemoryScope: 'user',
+ },
telemetry: {
sessionLogging: settings.telemetry.sessionLogging,
debugMetrics: settings.telemetry.debugMetrics,
diff --git a/src/core/config/ui/payloads.ts b/src/core/config/ui/payloads.ts
index b524f2a8..6a7e68de 100644
--- a/src/core/config/ui/payloads.ts
+++ b/src/core/config/ui/payloads.ts
@@ -55,6 +55,7 @@ export interface McpToggles {
memory: boolean;
sequentialThinking: boolean;
puppeteer: boolean;
+ agentmemory?: boolean;
}
export interface McpCustomServerView {
@@ -91,11 +92,18 @@ export interface IndexingSettingsPayload {
hybridMemorySearch: boolean;
}
+export interface MemorySettingsPayload {
+ summarizeAfterTask: boolean;
+ autoMemoryEnabled: boolean;
+ autoMemoryScope: 'user' | 'workspace' | 'both';
+}
+
export interface ThunderSettingsPayload {
provider: ProviderSettingsPayload;
agent: AgentSettingsPayload;
safety: SafetySettingsPayload;
mcp: McpSettingsPayload;
indexing: IndexingSettingsPayload;
+ memory?: MemorySettingsPayload;
telemetry: TelemetrySettingsPayload;
}
diff --git a/src/core/config/vscode/migrate.ts b/src/core/config/vscode/migrate.ts
new file mode 100644
index 00000000..d6a64f13
--- /dev/null
+++ b/src/core/config/vscode/migrate.ts
@@ -0,0 +1,45 @@
+import * as vscode from 'vscode';
+import { CONFIG_SECTION, LEGACY_CONFIG_SECTION } from '../keys';
+
+export interface SettingsMigrationResult {
+ copied: string[];
+ skipped: string[];
+}
+
+export async function migrateThunderSettingsToMitii(
+ paths: string[],
+ target: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global
+): Promise {
+ const current = vscode.workspace.getConfiguration(CONFIG_SECTION);
+ const legacy = vscode.workspace.getConfiguration(LEGACY_CONFIG_SECTION);
+ const copied: string[] = [];
+ const skipped: string[] = [];
+
+ for (const path of paths) {
+ const legacyInspect = legacy.inspect(path);
+ if (!hasConfiguredValue(legacyInspect)) {
+ skipped.push(path);
+ continue;
+ }
+ if (hasConfiguredValue(current.inspect(path))) {
+ skipped.push(path);
+ continue;
+ }
+ await current.update(path, legacy.get(path), target);
+ copied.push(path);
+ }
+
+ return { copied, skipped };
+}
+
+function hasConfiguredValue(inspect: ReturnType): boolean {
+ if (!inspect) return false;
+ return [
+ inspect.globalValue,
+ inspect.workspaceValue,
+ inspect.workspaceFolderValue,
+ inspect.globalLanguageValue,
+ inspect.workspaceLanguageValue,
+ inspect.workspaceFolderLanguageValue,
+ ].some((value) => value !== undefined);
+}
diff --git a/src/core/config/vscode/read.ts b/src/core/config/vscode/read.ts
index caa06cd3..4b58a4ad 100644
--- a/src/core/config/vscode/read.ts
+++ b/src/core/config/vscode/read.ts
@@ -4,10 +4,10 @@ import {
type ThunderConfig,
} from '../schema';
import { defaultThunderConfig } from '../defaults';
-import { CONFIG_SECTION } from '../keys';
+import { CONFIG_SECTION, LEGACY_CONFIG_SECTION } from '../keys';
export function readThunderConfigFromSettings(): ThunderConfig {
- const config = vscode.workspace.getConfiguration(CONFIG_SECTION);
+ const config = createMitiiConfigReader();
const raw = {
debug: config.get('debug'),
provider: {
@@ -36,6 +36,8 @@ export function readThunderConfigFromSettings(): ThunderConfig {
vectorsEnabled: config.get('indexing.vectorsEnabled'),
embeddingProvider: config.get('indexing.embeddingProvider'),
vectorBackend: config.get('indexing.vectorBackend'),
+ watchDebounceMs: config.get('indexing.watchDebounceMs'),
+ priorityPaths: config.get('indexing.priorityPaths'),
},
context: {
rerankerEnabled: config.get('context.rerankerEnabled'),
@@ -57,9 +59,12 @@ export function readThunderConfigFromSettings(): ThunderConfig {
maxItems: config.get('memory.maxItems'),
summarizeAfterTask: config.get('memory.summarizeAfterTask'),
hybridSearchEnabled: config.get('memory.hybridSearchEnabled'),
+ autoMemoryEnabled: config.get('memory.autoMemoryEnabled'),
+ autoMemoryScope: config.get('memory.autoMemoryScope'),
},
agent: {
subagentsEnabled: config.get('agent.subagentsEnabled'),
+ teamsEnabled: config.get('agent.teamsEnabled'),
maxSteps: config.get('agent.maxSteps'),
askMaxSteps: config.get('agent.askMaxSteps'),
askDepth: config.get('agent.askDepth'),
@@ -104,6 +109,9 @@ export function readThunderConfigFromSettings(): ThunderConfig {
issueFetchEnabled: config.get('github.issueFetchEnabled'),
issueCommentLimit: config.get('github.issueCommentLimit'),
tokenRef: config.get('github.tokenRef'),
+ autoPrEnabled: config.get('github.autoPrEnabled'),
+ defaultBaseBranch: config.get('github.defaultBaseBranch'),
+ webhookSecret: config.get('github.webhookSecret'),
},
telemetry: {
sessionLogging: config.get('telemetry.sessionLogging'),
@@ -120,6 +128,8 @@ export function readThunderConfigFromSettings(): ThunderConfig {
localProvidersOnly: config.get('enterprise.localProvidersOnly'),
stripFileContentsFromAuditPacks: config.get('enterprise.stripFileContentsFromAuditPacks'),
autoExportAuditPackOnSessionEnd: config.get('enterprise.autoExportAuditPackOnSessionEnd'),
+ channelsDisabled: config.get('enterprise.channelsDisabled'),
+ maxParallel: config.get('enterprise.maxParallel'),
},
};
@@ -129,3 +139,32 @@ export function readThunderConfigFromSettings(): ThunderConfig {
}
return defaultThunderConfig();
}
+
+function createMitiiConfigReader(): { get(path: string): T | undefined } {
+ const current = vscode.workspace.getConfiguration(CONFIG_SECTION);
+ const legacy = vscode.workspace.getConfiguration(LEGACY_CONFIG_SECTION);
+ return {
+ get(path: string): T | undefined {
+ if (hasConfiguredValue(current.inspect(path))) {
+ return current.get(path);
+ }
+ if (hasConfiguredValue(legacy.inspect(path))) {
+ return legacy.get(path);
+ }
+ return current.get(path);
+ },
+ };
+}
+
+function hasConfiguredValue(inspect: ReturnType): boolean {
+ if (!inspect) return false;
+ return [
+ inspect.globalValue,
+ inspect.workspaceValue,
+ inspect.workspaceFolderValue,
+ inspect.defaultLanguageValue,
+ inspect.globalLanguageValue,
+ inspect.workspaceLanguageValue,
+ inspect.workspaceFolderLanguageValue,
+ ].some((value) => value !== undefined);
+}
diff --git a/src/core/config/vscode/write.ts b/src/core/config/vscode/write.ts
index 663d25f4..20590587 100644
--- a/src/core/config/vscode/write.ts
+++ b/src/core/config/vscode/write.ts
@@ -2,6 +2,7 @@ import * as vscode from 'vscode';
import type {
AgentSettingsPayload,
IndexingSettingsPayload,
+ MemorySettingsPayload,
ProviderSettingsPayload,
SafetySettingsPayload,
TelemetrySettingsPayload,
@@ -97,11 +98,22 @@ export async function updateTelemetrySettings(settings: TelemetrySettingsPayload
}
}
+export async function updateMemorySettings(settings: MemorySettingsPayload): Promise {
+ const config = vscode.workspace.getConfiguration(CONFIG_SECTION);
+ const target = vscode.ConfigurationTarget.Global;
+ await config.update('memory.summarizeAfterTask', settings.summarizeAfterTask, target);
+ await config.update('memory.autoMemoryEnabled', settings.autoMemoryEnabled, target);
+ await config.update('memory.autoMemoryScope', settings.autoMemoryScope, target);
+}
+
export async function updateAllSettings(settings: ThunderSettingsPayload): Promise {
await updateProviderSettings(settings.provider);
await updateAgentSettings(settings.agent);
await updateSafetySettings(settings.safety);
await updateMcpSettings(settings.mcp);
await updateIndexingSettings(settings.indexing);
+ if (settings.memory) {
+ await updateMemorySettings(settings.memory);
+ }
await updateTelemetrySettings(settings.telemetry);
}
diff --git a/src/core/context/ContextBudgeter.ts b/src/core/context/ContextBudgeter.ts
index b4538bd1..ee3df2c1 100644
--- a/src/core/context/ContextBudgeter.ts
+++ b/src/core/context/ContextBudgeter.ts
@@ -30,6 +30,7 @@ export class ContextBudgeter {
{ source: 'diagnostics', budget: maxTokens * BUDGET_SPLITS.openDiff * 0.2 },
{ source: 'memory', budget: maxTokens * BUDGET_SPLITS.memory },
{ source: 'vector', budget: maxTokens * 0.08 },
+ { source: 'call-graph', budget: maxTokens * 0.10 },
];
const includedIds = new Set();
diff --git a/src/core/context/ContextReranker.ts b/src/core/context/ContextReranker.ts
index 70394aed..1ec5b56c 100644
--- a/src/core/context/ContextReranker.ts
+++ b/src/core/context/ContextReranker.ts
@@ -19,6 +19,7 @@ function tokenize(query: string): string[] {
export class LexicalContextReranker implements ContextReranker {
async rerank(query: string, items: ContextItem[], limit: number): Promise {
const terms = tokenize(query);
+ log.debug('lexical:rerank', { termCount: terms.length, itemCount: items.length, limit });
if (terms.length === 0) return items.slice(0, limit);
return items
@@ -50,6 +51,7 @@ export class EmbeddingContextReranker implements ContextReranker {
return new LexicalContextReranker().rerank(query, items, limit);
}
+ log.debug('embedding:rerank', { itemCount: items.length, limit });
return items
.map((item, idx) => {
const vec = vectors[idx + 1];
diff --git a/src/core/context/HybridRetriever.ts b/src/core/context/HybridRetriever.ts
index fe9da4aa..eb97ac36 100644
--- a/src/core/context/HybridRetriever.ts
+++ b/src/core/context/HybridRetriever.ts
@@ -49,6 +49,13 @@ export class HybridRetriever {
) {}
async retrieve(query: ContextQuery): Promise {
+ log.debug('retrieve:start', {
+ queryText: query.text,
+ sourceCount: this.sources.length,
+ maxItems: query.maxItems,
+ scopeRoot: query.scopeRoot,
+ });
+
const sourceById = new Map(this.sources.map((s) => [s.id, s]));
const orderedSources: ContextSource[] = [];
const seen = new Set();
@@ -82,6 +89,12 @@ export class HybridRetriever {
if (result.status === 'fulfilled') {
this.onTiming?.(result.value.timing);
allItems.push(...result.value.items);
+ log.debug('source:complete', {
+ source: result.value.timing.source,
+ tier: result.value.timing.tier,
+ durationMs: result.value.timing.durationMs,
+ itemCount: result.value.timing.itemCount,
+ });
} else {
const reason = result.reason as { durationMs?: unknown; error?: unknown };
const durationMs = typeof reason.durationMs === 'number' ? reason.durationMs : 0;
@@ -103,9 +116,11 @@ export class HybridRetriever {
}
const deduped = deduplicateItems(allItems).sort((a, b) => b.score - a.score);
+ log.debug('dedup', { before: allItems.length, after: deduped.length });
if (this.reranker && this.rerankerConfig?.enabled) {
const pool = deduped.slice(0, this.rerankerConfig.candidatePool);
+ log.debug('rerank:start', { candidatePoolSize: pool.length, topK: this.rerankerConfig.topK });
const startedAt = Date.now();
try {
const reranked = await this.reranker.rerank(
@@ -121,6 +136,7 @@ export class HybridRetriever {
candidateCount: pool.length,
resultCount: sliced.length,
});
+ log.debug('rerank:done', { durationMs: Date.now() - startedAt, resultCount: sliced.length });
return sliced;
} catch (error) {
this.onTiming?.({
@@ -135,7 +151,9 @@ export class HybridRetriever {
}
}
- return deduped.slice(0, query.maxItems ?? 30);
+ const result = deduped.slice(0, query.maxItems ?? 30);
+ log.debug('retrieve:done', { returned: result.length });
+ return result;
}
}
diff --git a/src/core/context/fuzzyFileMatch.ts b/src/core/context/fuzzyFileMatch.ts
index 3ddae009..70bba1dc 100644
--- a/src/core/context/fuzzyFileMatch.ts
+++ b/src/core/context/fuzzyFileMatch.ts
@@ -1,5 +1,5 @@
const FILE_MENTION_PATTERN =
- /\b[\w./-]+\.(tsx?|jsx?|vue|svelte|py|go|rs|java|kt|swift|md|json|css|scss|html|yaml|yml|toml)\b/gi;
+ /\b[\w./-]+\.(tsx?|jsx?|mjs|cjs|vue|svelte|py|go|rs|java|kt|swift|mdx?|jsonl|json|log|txt|env|sh|sql|xml|ini|toml|yaml|yml|css|scss|html)\b/gi;
const PACKAGE_LIKE_PATTERN = /\b[a-z][a-z0-9]*(?:-[a-z0-9]+)+\b/gi;
export function extractFileMentions(text: string): string[] {
diff --git a/src/core/context/sources/callGraphSource.ts b/src/core/context/sources/callGraphSource.ts
new file mode 100644
index 00000000..c8b0effb
--- /dev/null
+++ b/src/core/context/sources/callGraphSource.ts
@@ -0,0 +1,127 @@
+import type { ContextItem, ContextQuery, ContextSource } from '../types';
+import type { ThunderDb } from '../../indexing/ThunderDb';
+import type { WorkspaceLanguageService } from '../../indexing/WorkspaceLanguageService';
+
+const MAX_CANDIDATE_TOKENS = 25;
+const MAX_ANCHOR_SYMBOLS = 3;
+const MAX_CALLERS_PER_SYMBOL = 8;
+
+interface SymbolRow {
+ name: string;
+ kind: string;
+ start_line: number;
+ rel_path: string;
+}
+
+/** Precise, on-demand expansion layered on top of the heuristic repo-map: resolves query-mentioned
+ * symbol names to their true (re-export-resolved) definition and actual call sites via the
+ * persistent language service, rather than the DB's plain name-matching. Query-time only — no
+ * full-repo call graph is precomputed, to keep this bounded and avoid the latency this would
+ * otherwise add to every retrieval. */
+export class CallGraphContextSource implements ContextSource {
+ readonly id = 'call-graph';
+
+ constructor(
+ private readonly db: ThunderDb,
+ private readonly workspace: string,
+ private readonly languageService: WorkspaceLanguageService
+ ) {}
+
+ async retrieve(query: ContextQuery): Promise {
+ try {
+ const tokens = extractCandidateTokens(query.text);
+ if (tokens.length === 0) return [];
+
+ const anchors = this.findAnchorSymbols(tokens).slice(0, MAX_ANCHOR_SYMBOLS);
+ if (anchors.length === 0) return [];
+
+ const items: ContextItem[] = [];
+ for (const anchor of anchors) {
+ items.push(...this.buildItemsForAnchor(anchor));
+ }
+ return items;
+ } catch {
+ return [];
+ }
+ }
+
+ private buildItemsForAnchor(anchor: SymbolRow): ContextItem[] {
+ const items: ContextItem[] = [];
+
+ const anchorColumn = this.languageService.findColumnForName(anchor.rel_path, anchor.start_line, anchor.name);
+ if (anchorColumn === undefined) return items;
+
+ const definitions = this.languageService.getDefinition(anchor.rel_path, anchor.start_line, anchorColumn);
+ const primaryDef = definitions[0];
+
+ if (primaryDef) {
+ items.push({
+ id: `call-graph-def-${primaryDef.relPath}-${primaryDef.startLine}`,
+ source: this.id,
+ relPath: primaryDef.relPath,
+ startLine: primaryDef.startLine,
+ endLine: primaryDef.endLine,
+ content: `Definition of ${primaryDef.name} (resolved via language service, bypassing re-exports):\n${primaryDef.preview}`,
+ score: 9,
+ reason: `Call graph: true definition of ${primaryDef.name}`,
+ tokenEstimate: Math.ceil(primaryDef.preview.length / 4),
+ });
+ }
+
+ // Resolve callers from the *true* definition site when available, so a re-exported name
+ // still surfaces callers of the real implementation rather than the re-export specifier.
+ const defRelPath = primaryDef?.relPath ?? anchor.rel_path;
+ const defLine = primaryDef?.startLine ?? anchor.start_line;
+ const defName = primaryDef?.name ?? anchor.name;
+ const defColumn = primaryDef
+ ? this.languageService.findColumnForName(defRelPath, defLine, defName)
+ : anchorColumn;
+ if (defColumn === undefined) return items;
+
+ const callers = this.languageService.getCallers(defRelPath, defLine, defColumn).slice(0, MAX_CALLERS_PER_SYMBOL);
+ if (callers.length > 0) {
+ const body = callers
+ .map((c) => `${c.relPath}:${c.line}${c.enclosingSymbol ? ` (in ${c.enclosingSymbol})` : ''} — ${c.preview}`)
+ .join('\n');
+ items.push({
+ id: `call-graph-callers-${defRelPath}-${defLine}`,
+ source: this.id,
+ relPath: defRelPath,
+ content: `Callers of ${defName} (${callers.length} found):\n${body}`,
+ score: 9,
+ reason: `Call graph: callers of ${defName}`,
+ tokenEstimate: Math.ceil(body.length / 4),
+ });
+ }
+
+ return items;
+ }
+
+ private findAnchorSymbols(tokens: string[]): SymbolRow[] {
+ const placeholders = tokens.map(() => '?').join(',');
+ return this.db.raw
+ .prepare(
+ `SELECT s.name, s.kind, s.start_line, f.rel_path
+ FROM symbols s
+ JOIN files f ON f.id = s.file_id
+ WHERE f.workspace = ? AND s.kind IN ('function', 'method', 'class') AND s.name IN (${placeholders})
+ LIMIT 10`
+ )
+ .all(this.workspace, ...tokens) as SymbolRow[];
+ }
+}
+
+function extractCandidateTokens(text: string): string[] {
+ const matches = text.match(/\b[A-Za-z_$][A-Za-z0-9_$]*\b/g) ?? [];
+ const seen = new Set();
+ const tokens: string[] = [];
+
+ for (const match of matches) {
+ if (match.length < 3 || seen.has(match)) continue;
+ seen.add(match);
+ tokens.push(match);
+ if (tokens.length >= MAX_CANDIDATE_TOKENS) break;
+ }
+
+ return tokens;
+}
diff --git a/src/core/context/sources/indexSources.ts b/src/core/context/sources/indexSources.ts
index 5eee3001..aee2a61b 100644
--- a/src/core/context/sources/indexSources.ts
+++ b/src/core/context/sources/indexSources.ts
@@ -7,6 +7,9 @@ import { RepoMapService } from '../RepoMapService';
import type { MemoryService } from '../../memory/MemoryService';
import { isProjectOverviewQuestion } from '../fuzzyFileMatch';
import { filterItemsToScope } from '../scopeFilter';
+import { createLogger } from '../../telemetry/Logger';
+
+const log = createLogger('ContextSources');
const OVERVIEW_FILES = [
'README.md',
@@ -71,6 +74,7 @@ export class FtsContextSource implements ContextSource {
async retrieve(query: ContextQuery): Promise {
const results = this.fts.search(query.text, query.maxItems ?? 10);
+ log.debug('fts:search', { queryText: query.text, matchCount: results.length });
return filterItemsToScope(results.map((r, i) => ({
id: `fts-${r.relPath}-${i}`,
source: this.id,
diff --git a/src/core/context/sources/indexedFileSource.ts b/src/core/context/sources/indexedFileSource.ts
index 51680144..1b9aacb4 100644
--- a/src/core/context/sources/indexedFileSource.ts
+++ b/src/core/context/sources/indexedFileSource.ts
@@ -5,6 +5,9 @@ import type { ThunderDb } from '../../indexing/ThunderDb';
import { extractIndexedSearchTerms } from '../fuzzyFileMatch';
import { applyContentTier, getSourceContentTier, loadFileSignatures } from '../contextTier';
import { isPathInScope } from '../scopeFilter';
+import { createLogger } from '../../telemetry/Logger';
+
+const log = createLogger('IndexedFileSearchContextSource');
const MAX_FILE_CHARS = 16_000;
@@ -18,6 +21,7 @@ export class IndexedFileSearchContextSource implements ContextSource {
async retrieve(query: ContextQuery): Promise {
const terms = extractIndexedSearchTerms(query.text);
+ log.debug('terms:extracted', { queryText: query.text, terms });
if (terms.length === 0) return [];
const paths = new Set();
@@ -65,6 +69,7 @@ export class IndexedFileSearchContextSource implements ContextSource {
}
}
+ log.debug('match:complete', { pathMatchCount: paths.size, itemCount: items.length });
return items;
}
}
diff --git a/src/core/context/sources/mentionedFileSource.ts b/src/core/context/sources/mentionedFileSource.ts
index acf93103..2c08f219 100644
--- a/src/core/context/sources/mentionedFileSource.ts
+++ b/src/core/context/sources/mentionedFileSource.ts
@@ -1,5 +1,5 @@
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
-import { join, relative, resolve } from 'path';
+import { isAbsolute, join, relative, resolve } from 'path';
import * as vscode from 'vscode';
import type { ContextItem, ContextQuery, ContextSource } from '../types';
import {
@@ -7,7 +7,12 @@ import {
expandCamelCaseTerms,
globPatternsForMention,
} from '../fuzzyFileMatch';
-import { createWorkspacePattern, canUseVscodeFindFiles, toWorkspaceRelPath } from '../../util/paths';
+import {
+ createWorkspacePattern,
+ canUseVscodeFindFiles,
+ toWorkspaceRelPath,
+ isPathInsideWorkspace,
+} from '../../util/paths';
import { createLogger } from '../../telemetry/Logger';
const log = createLogger('MentionedFileSource');
@@ -25,8 +30,57 @@ export class MentionedFileContextSource implements ContextSource {
const items: ContextItem[] = [];
const seen = new Set();
const searchedPatterns: string[] = [];
+ const fuzzyMentions: string[] = [];
+ // Exact paths the user gave verbatim are resolved directly off disk first —
+ // synchronous and immune to the findFiles/glob timeout below — so a literal
+ // path never gets silently dropped in favor of fuzzy retrieval.
for (const mention of mentions.slice(0, 5)) {
+ if (!mention.includes('/')) {
+ fuzzyMentions.push(mention);
+ continue;
+ }
+
+ const exact = resolveExactMention(this.workspace, mention);
+ if (!exact) {
+ fuzzyMentions.push(mention);
+ continue;
+ }
+
+ if (exact.outsideWorkspace) {
+ items.push({
+ id: `mention-external-${exact.absPath}`,
+ source: this.id,
+ content:
+ `The user referenced a file outside the ${this.workspace} workspace: ${exact.absPath}. ` +
+ `Its contents were not loaded automatically. Call read_file with this exact path if you ` +
+ `need to inspect it — reading external files requires user approval.`,
+ score: 14,
+ reason: `External file mentioned (outside workspace): ${exact.absPath}`,
+ tokenEstimate: 60,
+ });
+ continue;
+ }
+
+ if (seen.has(exact.relPath)) continue;
+ seen.add(exact.relPath);
+ try {
+ const content = readFileSync(exact.absPath, 'utf-8').slice(0, MAX_FILE_CHARS);
+ items.push({
+ id: `mention-${exact.relPath}`,
+ source: this.id,
+ relPath: exact.relPath,
+ content,
+ score: 14,
+ reason: `File mentioned in user message: ${exact.relPath}`,
+ tokenEstimate: Math.ceil(content.length / 4),
+ });
+ } catch {
+ // Skip unreadable files.
+ }
+ }
+
+ for (const mention of fuzzyMentions) {
const relPaths = await findMatchingFiles(this.workspace, mention, searchedPatterns);
for (const relPath of relPaths) {
if (!relPath || relPath === '.' || seen.has(relPath)) continue;
@@ -76,6 +130,36 @@ export class MentionedFileContextSource implements ContextSource {
}
}
+/** Runs findFiles for every candidate pattern concurrently so the total wait is
+ * bounded by the slowest single pattern, not the sum of all of them — a sequential
+ * await-per-pattern loop here was blowing past the tier's 800ms budget on repos with
+ * many candidate patterns per mention. */
+async function findFilesForPatterns(
+ workspace: string,
+ patterns: string[],
+ exclude: string
+): Promise<{ uris: vscode.Uri[]; allFailed: boolean }> {
+ const settled = await Promise.allSettled(
+ patterns.map((pattern) => {
+ try {
+ return vscode.workspace.findFiles(createWorkspacePattern(workspace, pattern), exclude, 5);
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ })
+ );
+
+ const uris: vscode.Uri[] = [];
+ let allFailed = settled.length > 0;
+ for (const result of settled) {
+ if (result.status === 'fulfilled') {
+ allFailed = false;
+ uris.push(...result.value);
+ }
+ }
+ return { uris, allFailed };
+}
+
async function findMatchingFiles(
workspace: string,
mention: string,
@@ -83,59 +167,43 @@ async function findMatchingFiles(
): Promise {
const patterns = globPatternsForMention(mention);
const exclude = '**/{node_modules,.git,dist,out,build,.mitii,.thunder}/**';
- const uris: vscode.Uri[] = [];
if (!canUseVscodeFindFiles(workspace)) {
return walkFindOnDisk(workspace, mention, 5);
}
- for (const pattern of patterns) {
- searchedPatterns.push(pattern);
- try {
- const found = await vscode.workspace.findFiles(
- createWorkspacePattern(workspace, pattern),
- exclude,
- 5
- );
- uris.push(...found);
- } catch (error) {
- log.warn('findFiles failed, using disk fallback', {
- pattern,
- error: error instanceof Error ? error.message : String(error),
- });
- return walkFindOnDisk(workspace, mention, 5);
- }
- if (uris.length >= 5) break;
+ searchedPatterns.push(...patterns);
+ const first = await findFilesForPatterns(workspace, patterns, exclude);
+ if (first.allFailed) {
+ log.warn('findFiles failed, using disk fallback', { patterns });
+ return walkFindOnDisk(workspace, mention, 5);
}
- if (uris.length > 0) {
+ if (first.uris.length > 0) {
return [...new Set(
- uris
+ first.uris
.map((u) => toWorkspaceRelPath(u, workspace))
.filter((p): p is string => Boolean(p))
)];
}
- for (const term of expandCamelCaseTerms(mention)) {
- if (term.length < 4) continue;
- const pattern = `**/*${term}*`;
- searchedPatterns.push(pattern);
- try {
- const found = await vscode.workspace.findFiles(
- createWorkspacePattern(workspace, pattern),
- exclude,
- 5
- );
- uris.push(...found);
- } catch {
- return walkFindOnDisk(workspace, term, 5);
- }
- if (uris.length >= 5) break;
+ const camelPatterns = expandCamelCaseTerms(mention)
+ .filter((term) => term.length >= 4)
+ .map((term) => `**/*${term}*`);
+
+ if (camelPatterns.length === 0) {
+ return walkFindOnDisk(workspace, mention, 5);
}
- if (uris.length > 0) {
+ searchedPatterns.push(...camelPatterns);
+ const second = await findFilesForPatterns(workspace, camelPatterns, exclude);
+ if (second.allFailed) {
+ return walkFindOnDisk(workspace, mention, 5);
+ }
+
+ if (second.uris.length > 0) {
return [...new Set(
- uris
+ second.uris
.map((u) => toWorkspaceRelPath(u, workspace))
.filter((p): p is string => Boolean(p))
)];
@@ -144,6 +212,32 @@ async function findMatchingFiles(
return walkFindOnDisk(workspace, mention, 5);
}
+/** Resolves a mention that already looks like a path (contains `/`) directly off
+ * disk — no globbing. Returns null when it's not an exact hit, so callers fall
+ * back to fuzzy search. */
+function resolveExactMention(
+ workspace: string,
+ mention: string
+): { absPath: string; relPath: string; outsideWorkspace: false } | { absPath: string; outsideWorkspace: true } | null {
+ const candidate = isAbsolute(mention) ? mention : join(workspace, mention);
+
+ let isFile: boolean;
+ try {
+ isFile = existsSync(candidate) && statSync(candidate).isFile();
+ } catch {
+ isFile = false;
+ }
+ if (!isFile) return null;
+
+ const absPath = resolve(candidate);
+ if (!isPathInsideWorkspace(absPath, workspace)) {
+ return { absPath, outsideWorkspace: true };
+ }
+
+ const relPath = relative(resolve(workspace), absPath).replace(/\\/g, '/');
+ return { absPath, relPath, outsideWorkspace: false };
+}
+
function walkFindOnDisk(workspace: string, needle: string, limit: number): string[] {
const root = resolve(workspace);
const results: string[] = [];
diff --git a/src/core/daemon/DaemonRuntimeAdapter.ts b/src/core/daemon/DaemonRuntimeAdapter.ts
new file mode 100644
index 00000000..64984066
--- /dev/null
+++ b/src/core/daemon/DaemonRuntimeAdapter.ts
@@ -0,0 +1,51 @@
+import { DaemonClient, DaemonSessionClient } from '../../../packages/sdk/src/daemon';
+import type { MitiiApprovalDecision, MitiiEvent, MitiiMode } from '../../../packages/sdk/src/types';
+
+export interface DaemonRuntimeAdapterOptions {
+ cwd: string;
+ daemonUrl?: string;
+ daemonToken?: string;
+ mode?: MitiiMode;
+}
+
+export class DaemonRuntimeAdapter {
+ private readonly client: DaemonClient;
+ private session?: DaemonSessionClient;
+
+ constructor(private readonly options: DaemonRuntimeAdapterOptions) {
+ this.client = new DaemonClient({
+ baseUrl: options.daemonUrl ?? 'http://127.0.0.1:4310',
+ token: options.daemonToken,
+ });
+ }
+
+ async connect(): Promise {
+ this.session = await DaemonSessionClient.createOrAttach(this.client, {
+ cwd: this.options.cwd,
+ mode: this.options.mode ?? 'agent',
+ approval: 'manual',
+ });
+ }
+
+ async sendMessage(message: string, mode: MitiiMode = this.options.mode ?? 'agent'): Promise> {
+ if (!this.session) await this.connect();
+ const session = this.session!;
+ const events = session.events();
+ await session.prompt({ mode, message });
+ return events;
+ }
+
+ approve(id: string, decision: MitiiApprovalDecision): Promise> {
+ if (!this.session) throw new Error('Daemon session not connected');
+ return this.session.respondToPermission(id, decision);
+ }
+
+ async getSubagents(): Promise {
+ return [];
+ }
+
+ cancel(): Promise> {
+ if (!this.session) throw new Error('Daemon session not connected');
+ return this.session.cancel();
+ }
+}
diff --git a/src/core/git/WorktreeService.ts b/src/core/git/WorktreeService.ts
new file mode 100644
index 00000000..a6e75af9
--- /dev/null
+++ b/src/core/git/WorktreeService.ts
@@ -0,0 +1,104 @@
+import { execFile } from 'child_process';
+import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
+import { dirname, join, resolve } from 'path';
+import { promisify } from 'util';
+import { scaffoldMitiiWorkspace } from '../mcp/scaffoldMitiiWorkspace';
+import { branchForTask, defaultWorktreePath } from './worktreePaths';
+import type { WorktreeCreateOptions, WorktreeInfo } from './worktreeTypes';
+
+const execFileAsync = promisify(execFile);
+
+export class WorktreeService {
+ private readonly registryPath: string;
+
+ constructor(private readonly repoRoot: string, private readonly extensionRoot = repoRoot) {
+ this.repoRoot = resolve(repoRoot);
+ this.registryPath = join(this.repoRoot, '.mitii', 'worktrees.json');
+ }
+
+ list(): WorktreeInfo[] {
+ const entries = this.readRegistry();
+ return entries.map((entry) => ({
+ ...entry,
+ status: existsSync(entry.path) && entry.status !== 'removed' ? 'active' : entry.status === 'removed' ? 'removed' : 'orphaned',
+ }));
+ }
+
+ getPath(taskId: string): string | undefined {
+ return this.list().find((entry) => entry.taskId === taskId && entry.status === 'active')?.path;
+ }
+
+ async create(options: WorktreeCreateOptions): Promise {
+ await this.ensureGitRepo();
+ const existing = this.list().find((entry) => entry.taskId === options.taskId && entry.status === 'active');
+ if (existing) return existing;
+
+ const path = defaultWorktreePath(this.repoRoot, options.taskId);
+ const branch = options.branch ?? branchForTask(options.taskId);
+ const args = ['worktree', 'add', '-b', branch, path];
+ if (options.baseRef) args.push(options.baseRef);
+ await execFileAsync('git', args, { cwd: this.repoRoot });
+ scaffoldMitiiWorkspace(path, { extensionRoot: this.extensionRoot, forceBundledSkills: false });
+
+ const entry: WorktreeInfo = {
+ taskId: options.taskId,
+ path,
+ branch,
+ status: 'active',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+ this.writeRegistry([...this.readRegistry().filter((item) => item.taskId !== options.taskId), entry]);
+ return entry;
+ }
+
+ async remove(taskId: string, options: { force?: boolean; deleteBranch?: boolean } = {}): Promise {
+ const entry = this.list().find((item) => item.taskId === taskId);
+ if (!entry) return false;
+ if (entry.status === 'active') {
+ const dirty = await this.isDirty(entry.path);
+ if (dirty && !options.force) {
+ throw new Error(`Worktree ${taskId} has uncommitted changes. Re-run with --force to remove.`);
+ }
+ await execFileAsync('git', ['worktree', 'remove', ...(options.force ? ['--force'] : []), entry.path], { cwd: this.repoRoot });
+ } else if (existsSync(entry.path) && options.force) {
+ rmSync(entry.path, { recursive: true, force: true });
+ }
+ if (options.deleteBranch) {
+ await execFileAsync('git', ['branch', '-D', entry.branch], { cwd: this.repoRoot }).catch(() => undefined);
+ }
+ this.writeRegistry(this.readRegistry().map((item) =>
+ item.taskId === taskId ? { ...item, status: 'removed', updatedAt: Date.now() } : item
+ ));
+ return true;
+ }
+
+ prune(): WorktreeInfo[] {
+ const kept = this.readRegistry().filter((entry) => entry.status === 'active' && existsSync(entry.path));
+ this.writeRegistry(kept);
+ return kept;
+ }
+
+ private async ensureGitRepo(): Promise {
+ await execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd: this.repoRoot });
+ }
+
+ private async isDirty(path: string): Promise {
+ const { stdout } = await execFileAsync('git', ['status', '--porcelain'], { cwd: path });
+ return stdout.trim().length > 0;
+ }
+
+ private readRegistry(): WorktreeInfo[] {
+ try {
+ const parsed = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as WorktreeInfo[];
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+ }
+
+ private writeRegistry(entries: WorktreeInfo[]): void {
+ mkdirSync(dirname(this.registryPath), { recursive: true });
+ writeFileSync(this.registryPath, `${JSON.stringify(entries, null, 2)}\n`, 'utf-8');
+ }
+}
diff --git a/src/core/git/index.ts b/src/core/git/index.ts
new file mode 100644
index 00000000..dfbd31bc
--- /dev/null
+++ b/src/core/git/index.ts
@@ -0,0 +1,3 @@
+export { WorktreeService } from './WorktreeService';
+export { branchForTask, defaultWorktreePath, safeTaskId } from './worktreePaths';
+export type { WorktreeCreateOptions, WorktreeInfo } from './worktreeTypes';
diff --git a/src/core/git/worktreePaths.ts b/src/core/git/worktreePaths.ts
new file mode 100644
index 00000000..2e9030bc
--- /dev/null
+++ b/src/core/git/worktreePaths.ts
@@ -0,0 +1,17 @@
+import { basename, dirname, join, resolve } from 'path';
+
+export function defaultWorktreePath(repoRoot: string, taskId: string): string {
+ const root = resolve(repoRoot);
+ const repo = basename(root).replace(/[^\w.-]+/g, '-');
+ return join(dirname(root), `${repo}-mitii-${safeTaskId(taskId)}`);
+}
+
+export function branchForTask(taskId: string, title?: string): string {
+ const slug = safeTaskId(title || taskId).slice(0, 48);
+ const shortId = safeTaskId(taskId).slice(0, 8);
+ return `mitii/task/${slug}-${shortId}`;
+}
+
+export function safeTaskId(value: string): string {
+ return value.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'task';
+}
diff --git a/src/core/git/worktreeTypes.ts b/src/core/git/worktreeTypes.ts
new file mode 100644
index 00000000..af572e89
--- /dev/null
+++ b/src/core/git/worktreeTypes.ts
@@ -0,0 +1,14 @@
+export interface WorktreeInfo {
+ taskId: string;
+ path: string;
+ branch: string;
+ status: 'active' | 'removed' | 'orphaned';
+ createdAt: number;
+ updatedAt: number;
+}
+
+export interface WorktreeCreateOptions {
+ branch?: string;
+ taskId: string;
+ baseRef?: string;
+}
diff --git a/src/core/headless/AgentRunner.ts b/src/core/headless/AgentRunner.ts
index 55b49ede..4e4e9139 100644
--- a/src/core/headless/AgentRunner.ts
+++ b/src/core/headless/AgentRunner.ts
@@ -75,7 +75,6 @@ export class HeadlessAgentRunner {
}
async *agent(prompt: string): AsyncIterable<{ type: string; message?: string; plan?: HeadlessPlan; content?: string }> {
- yield { type: 'start', message: 'headless agent started' };
const plan = this.plan(prompt);
yield { type: 'plan', plan };
const content = await this.ask([
@@ -84,7 +83,7 @@ export class HeadlessAgentRunner {
'Headless agent mode has no filesystem write tools in this runtime. Provide the best implementation guidance and verification checklist.',
].join('\n'));
yield { type: 'assistant_delta', content };
- yield { type: 'end', message: 'headless agent completed' };
+ yield { type: 'done', content };
}
private async complete(messages: Array<{ role: 'system' | 'user'; content: string }>): Promise {
diff --git a/src/core/headless/HeadlessAgentHost.ts b/src/core/headless/HeadlessAgentHost.ts
index 7c83845b..f4c0fb4d 100644
--- a/src/core/headless/HeadlessAgentHost.ts
+++ b/src/core/headless/HeadlessAgentHost.ts
@@ -1,4 +1,5 @@
import { join } from 'path';
+import { resolveWorkspaceRelPath } from '../util/paths';
import { ThunderSession, type ThunderMode } from '../session/ThunderSession';
import { IndexService } from '../indexing/IndexService';
import { IgnoreService } from '../indexing/IgnoreService';
@@ -6,6 +7,7 @@ import { WorkspaceScanner } from '../indexing/WorkspaceScanner';
import { IndexQueue } from '../indexing/IndexQueue';
import { FtsIndex } from '../indexing/FtsIndex';
import { HybridRetriever } from '../context/HybridRetriever';
+import type { ContextItem, ContextQuery } from '../context/types';
import { createContextReranker } from '../context/ContextReranker';
import { ContextBudgeter } from '../context/ContextBudgeter';
import { CurrentEditorContextSource, OpenFilesContextSource } from '../context/sources/editorSources';
@@ -15,12 +17,20 @@ import { MentionedFileContextSource } from '../context/sources/mentionedFileSour
import { GitService } from '../context/GitService';
import { GitDiffContextSource } from '../context/DiagnosticsService';
import { RepoMapService } from '../context/RepoMapService';
+import { VectorContextSource } from '../context/sources/VectorContextSource';
+import { CallGraphContextSource } from '../context/sources/callGraphSource';
+import { VectorIndexService } from '../indexing/VectorIndex';
+import { createVectorIndex } from '../indexing/vectorIndexFactory';
+import { createEmbeddingProvider } from '../indexing/embeddingFactory';
+import { getOrCreateLanguageService, disposeLanguageService } from '../indexing/languageServiceFactory';
+import type { WorkspaceLanguageService } from '../indexing/WorkspaceLanguageService';
+import type { EmbeddingProvider } from '../indexing/EmbeddingProvider';
import { setVerifyCommandPatterns } from '../plans/PlanActEngine';
import { ChatOrchestrator } from '../orchestration/ChatOrchestrator';
import { ToolRuntime } from '../tools/ToolRuntime';
import {
- createReadFileTool, createReadFilesTool, createListFilesTool, createSearchTool,
- createSearchBatchTool, createSearchScriptCatalogTool, createSpawnResearchAgentTool,
+ createReadFileTool, createReadFilesTool, createListFilesTool, createResolvePathTool, createSearchTool,
+ createSearchBatchTool, createSearchScriptCatalogTool, createSpawnResearchAgentTool, createSpawnSubagentTool,
createExecuteWorkspaceScriptTool, createUseSkillTool,
createRepoMapTool, createRetrieveContextTool, createGitDiffTool,
createDiagnosticsTool, createWriteFileTool, createApplyPatchTool, createRunCommandTool,
@@ -50,6 +60,7 @@ import { MemoryExtractor } from '../runtime/MemoryExtractor';
import { SubagentTracker } from '../runtime/SubagentTracker';
import { PassiveMemoryInjector } from '../memory/PassiveMemoryInjector';
import { MemoryHookService } from '../memory/MemoryHookService';
+import { AutoMemoryContextSource, AutoMemoryFileWriter } from '../memory/AutoMemoryFileWriter';
import { PostEditValidator } from '../apply/PostEditValidator';
import { McpManager } from '../mcp/McpManager';
import { ProjectRulesContextSource, ProjectRulesService } from '../rules/ProjectRulesService';
@@ -69,6 +80,8 @@ import { headlessDiscoverFiles } from './headlessDiscoverFiles';
import { defaultMcpToggles } from '../mcp/mcpToggles';
import type { ThunderConfig } from '../config/schema';
import { chunkContent } from '../llm/streamChunks';
+import { chunkReasoning } from '../llm/streamChunks';
+import { eventFromSessionLog, type MitiiApprovalDecision, type MitiiEvent } from './events';
const log = createLogger('HeadlessAgentHost');
@@ -93,6 +106,7 @@ export class HeadlessAgentHost {
private indexService?: IndexService;
private ignoreService = new IgnoreService();
+ private languageService?: WorkspaceLanguageService;
private scanner?: WorkspaceScanner;
private indexQueue?: IndexQueue;
private gitService?: GitService;
@@ -107,7 +121,11 @@ export class HeadlessAgentHost {
private toolRuntime = new ToolRuntime();
private toolExecutor?: ToolExecutor;
private chatOrchestrator?: ChatOrchestrator;
+ private retriever?: HybridRetriever;
+ private embeddingProvider?: EmbeddingProvider;
+ private vectorIndexService?: VectorIndexService;
private memoryExtractor?: MemoryExtractor;
+ private autoMemoryWriter?: AutoMemoryFileWriter;
private mcpManager = new McpManager();
private sessionLog = new SessionLogService();
private subagentTracker = new SubagentTracker();
@@ -127,6 +145,11 @@ export class HeadlessAgentHost {
apiKey: options.apiKey ?? resolveApiKey(options.providerType ?? this.config.provider.type),
approval: options.approval ?? 'manual',
});
+ if (options.onEvent) {
+ this.sessionLog.onEvent((event) => {
+ options.onEvent?.(eventFromSessionLog(event, this.options.cwd, this.session?.mode));
+ });
+ }
}
get isRealRuntime(): boolean {
@@ -168,12 +191,20 @@ export class HeadlessAgentHost {
respectGitignore: this.config.indexing.respectGitignore,
respectThunderignore: this.config.indexing.respectThunderignore,
});
+ this.languageService = getOrCreateLanguageService(workspace, this.ignoreService, this.config.indexing);
this.scanner = new WorkspaceScanner(db, workspace);
+ this.embeddingProvider = createEmbeddingProvider(this.config.indexing);
+ this.vectorIndexService = new VectorIndexService(
+ createVectorIndex(db, workspace, this.config.indexing),
+ this.embeddingProvider
+ );
this.indexQueue = new IndexQueue(db, {
maxConcurrency: this.config.indexing.maxConcurrency,
maxFileSizeBytes: this.config.indexing.maxFileSizeBytes,
+ deferVectorWrites: true,
});
+ this.indexQueue.setVectorService(workspace, this.vectorIndexService);
this.gitService = new GitService(workspace);
await this.gitService.initialize();
@@ -188,6 +219,10 @@ export class HeadlessAgentHost {
maxItems: this.config.memory.maxItems,
hybridSearchEnabled: this.config.memory.hybridSearchEnabled,
});
+ this.autoMemoryWriter = new AutoMemoryFileWriter(workspace, {
+ enabled: this.config.memory.autoMemoryEnabled,
+ scope: this.config.memory.autoMemoryScope,
+ });
this.sessionService = new SessionService(db);
this.planPersistence = new PlanPersistence(db);
@@ -199,7 +234,8 @@ export class HeadlessAgentHost {
this.policyEngine = new ToolPolicyEngine(
effectiveSafety,
(path) => this.ignoreService.isIgnored(path),
- () => true
+ () => true,
+ (path) => resolveWorkspaceRelPath(workspace, path)
);
this.toolRuntime.setSessionLog(this.sessionLog);
@@ -218,23 +254,26 @@ export class HeadlessAgentHost {
);
const retriever = this.buildRetriever(db, workspace);
+ this.retriever = retriever;
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);
+ this.registerTools(workspace, db, repoMap, fts, retriever, budgeter);
const mcpToggles = {
...defaultMcpToggles(),
puppeteer: this.config.mcp.builtinServers.puppeteer ?? false,
+ agentmemory: this.config.mcp.builtinServers.agentmemory ?? false,
};
await this.mcpManager.reload(this.config.mcp, workspace, this.toolRuntime, mcpToggles);
this.memoryExtractor = new MemoryExtractor(
this.memoryService,
- this.config.memory.summarizeAfterTask
+ this.config.memory.summarizeAfterTask,
+ this.autoMemoryWriter
);
if (this.config.indexing.autoIndexOnOpen) {
@@ -251,6 +290,13 @@ export class HeadlessAgentHost {
return this.runMode('ask', prompt);
}
+ /** Raw retrieval results for a query, bypassing the LLM entirely. Used by retrieval evals/diagnostics. */
+ async retrieveContext(query: ContextQuery): Promise {
+ await this.initialize();
+ if (!this.retriever) throw new Error('Retriever unavailable (stub runtime)');
+ return this.retriever.retrieve(query);
+ }
+
async plan(prompt: string): Promise> {
await this.initialize();
if (!this.isRealRuntime) return this.stubRunner.plan(prompt);
@@ -262,23 +308,58 @@ export class HeadlessAgentHost {
}
}
- async *agent(prompt: string): AsyncIterable<{ type: string; message?: string; plan?: HeadlessPlan; content?: string }> {
+ async *agent(prompt: string, signal?: AbortSignal): AsyncIterable {
await this.initialize();
if (!this.isRealRuntime) {
- yield* this.stubRunner.agent(prompt);
+ for await (const event of this.stubRunner.agent(prompt)) {
+ if (signal?.aborted) break;
+ yield event as MitiiEvent;
+ }
return;
}
- yield { type: 'start', message: 'headless agent started' };
+ const started = Date.now();
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 };
+ const pendingLogEvents: MitiiEvent[] = [];
+ const unsubscribe = this.sessionLog.onEvent((event) => {
+ pendingLogEvents.push(eventFromSessionLog(event, this.options.cwd, this.session?.mode));
+ });
+ const drain = function* (): Iterable {
+ while (pendingLogEvents.length > 0) {
+ const event = pendingLogEvents.shift();
+ if (event) yield event;
+ }
+ };
+
+ try {
+ if (signal) {
+ signal.addEventListener('abort', () => this.cancel(), { once: true });
+ }
+ for await (const chunk of this.streamMode('agent', prompt)) {
+ if (signal?.aborted) break;
+ yield* drain();
+ const text = chunkContent(chunk);
+ const reasoning = chunkReasoning(chunk);
+ if (reasoning) {
+ const event: MitiiEvent = { type: 'reasoning_delta', content: reasoning };
+ this.options.onEvent?.(event);
+ yield event;
+ }
+ if (text) {
+ content += text;
+ const event: MitiiEvent = { type: 'assistant_delta', content: text };
+ this.options.onEvent?.(event);
+ yield event;
+ }
}
+ yield* drain();
+ } finally {
+ unsubscribe();
}
- yield { type: 'end', message: 'headless agent completed', content };
+ const metrics = this.buildMetrics(started, []);
+ const done: MitiiEvent = { type: 'done', content, metrics };
+ this.options.onEvent?.(done);
+ yield done;
}
async runWithMetrics(mode: ThunderMode, prompt: string): Promise<{ output: string; metrics: HeadlessRunMetrics }> {
@@ -294,7 +375,7 @@ export class HeadlessAgentHost {
} else {
const parts: string[] = [];
for await (const event of this.agent(prompt)) {
- if (event.content) parts.push(event.content);
+ if (event.type === 'assistant_delta') parts.push(event.content);
}
output = parts.join('');
}
@@ -315,9 +396,25 @@ export class HeadlessAgentHost {
};
}
- dispose(): void {
+ async dispose(): Promise {
+ this.cancel();
+ await this.indexQueue?.waitForVectorIndexing();
this.indexService?.dispose();
+ disposeLanguageService(this.options.cwd);
+ this.languageService = undefined;
this.initialized = false;
+ await this.mcpManager.closeAll();
+ }
+
+ cancel(): void {
+ this.chatOrchestrator?.stop();
+ }
+
+ resolveApproval(id: string, decision: MitiiApprovalDecision): boolean {
+ if (!this.approvalQueue) return false;
+ this.approvalQueue.resolve(id, decision);
+ this.sessionLog.append('approval_decision', `${decision}: ${id}`, { id, decision });
+ return true;
}
private configureOrchestrator(workspace: string): void {
@@ -359,19 +456,22 @@ export class HeadlessAgentHost {
private registerTools(
workspace: string,
+ db: import('../indexing/ThunderDb').ThunderDb,
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(createReadFileTool(workspace, this.ignoreService, db));
+ this.toolRuntime.register(createReadFilesTool(workspace, this.ignoreService, db));
this.toolRuntime.register(createListFilesTool(workspace, this.ignoreService));
+ this.toolRuntime.register(createResolvePathTool(workspace, this.ignoreService, db));
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(createSpawnSubagentTool());
this.toolRuntime.register(createSpawnResearchAgentTool());
this.toolRuntime.register(createRepoMapTool(repoMap));
this.toolRuntime.register(createRetrieveContextTool(retriever, budgeter));
@@ -429,8 +529,18 @@ export class HeadlessAgentHost {
if (this.gitService) sources.push(new GitDiffContextSource(this.gitService));
sources.push(new HeadlessDiagnosticsContextSource(this.diagnosticsService));
if (this.memoryService) sources.push(new MemoryContextSource(this.memoryService));
+ if (this.autoMemoryWriter) sources.push(new AutoMemoryContextSource(this.autoMemoryWriter));
+ if (this.config.indexing.vectorsEnabled && this.vectorIndexService) {
+ sources.push(new VectorContextSource(this.vectorIndexService, workspace));
+ }
+ if (this.languageService) {
+ sources.push(new CallGraphContextSource(db, workspace, this.languageService));
+ }
- const reranker = createContextReranker(undefined, false);
+ const reranker = createContextReranker(
+ this.embeddingProvider,
+ this.config.indexing.vectorsEnabled && this.config.indexing.embeddingProvider === 'minilm'
+ );
return new HybridRetriever(sources, reranker, {
enabled: this.config.context.rerankerEnabled,
candidatePool: this.config.context.rerankerCandidatePool,
@@ -458,6 +568,7 @@ export class HeadlessAgentHost {
if (Date.now() > deadline) break;
await sleep(250);
}
+ await this.indexQueue.waitForVectorIndexing();
this.sessionLog.append('index_complete', 'Headless indexing finished', {
workspace,
jobCount: jobs.length,
@@ -479,7 +590,17 @@ export class HeadlessAgentHost {
}
this.session = new ThunderSession(this.options.cwd, mode);
+ if (this.options.sessionId) {
+ this.session = new ThunderSession(this.options.cwd, mode, { id: this.options.sessionId });
+ }
this.sessionLog.configure(this.options.cwd, this.session.id, true, this.config.telemetry.debugMetrics);
+ this.sessionLog.writeSessionHeader({
+ mode,
+ workspace: this.options.cwd,
+ provider: this.config.provider.type,
+ model: this.config.provider.model,
+ runtime: this.options.runtime ?? 'real',
+ });
this.toolRuntime.clearAuditLog();
this.agentTaskState.reset();
this.agentTaskState.setLimits({
@@ -496,6 +617,17 @@ export class HeadlessAgentHost {
yield* this.chatOrchestrator.send(this.session, this.provider, prompt, []);
}
+ private buildMetrics(started: number, errors: string[]): HeadlessRunMetrics {
+ const audit = this.getToolAudit();
+ return {
+ durationMs: Date.now() - started,
+ toolCalls: audit.length,
+ errors,
+ sessionLogPath: this.sessionLog.getLogPath() || undefined,
+ auditTools: audit.map((entry) => entry.toolName),
+ };
+ }
+
private autoResolvePendingApprovals(): void {
if (this.options.approval !== 'auto' || !this.approvalQueue || !this.session) return;
for (const request of this.approvalQueue.getPending()) {
diff --git a/src/core/headless/HeadlessConfig.ts b/src/core/headless/HeadlessConfig.ts
index 8e96e2a2..98da930c 100644
--- a/src/core/headless/HeadlessConfig.ts
+++ b/src/core/headless/HeadlessConfig.ts
@@ -3,6 +3,7 @@ import { join } from 'path';
import type { ProviderType, ThunderConfig } from '../config/schema';
import { defaultThunderConfig } from '../config/defaults';
import { resolveEffectiveSafety } from '../safety/autonomyPresets';
+import type { MitiiEvent } from './events';
export type HeadlessRuntime = 'real' | 'stub';
@@ -18,6 +19,8 @@ export interface HeadlessAgentOptions {
allowNetwork?: boolean;
enablePuppeteer?: boolean;
indexWorkspace?: boolean;
+ sessionId?: string;
+ onEvent?: (event: MitiiEvent) => void;
configOverrides?: Partial;
}
diff --git a/src/core/headless/events.ts b/src/core/headless/events.ts
new file mode 100644
index 00000000..a42d467f
--- /dev/null
+++ b/src/core/headless/events.ts
@@ -0,0 +1,104 @@
+import type { HeadlessPlan } from './AgentRunner';
+import type { SessionLogEvent } from '../telemetry/SessionLogService';
+
+export type MitiiMode = 'ask' | 'plan' | 'agent' | 'review';
+
+export type MitiiApprovalDecision = 'approved' | 'denied';
+
+export interface MitiiMetrics {
+ durationMs: number;
+ toolCalls: number;
+ errors?: string[];
+ sessionLogPath?: string;
+ auditTools?: string[];
+}
+
+export type MitiiEvent =
+ | { type: 'session_start'; sessionId: string; mode: string; cwd: string; data?: Record }
+ | { type: 'assistant_delta'; content: string }
+ | { type: 'reasoning_delta'; content: string }
+ | { type: 'tool_start'; tool: string; input?: Record; id?: string; message?: string }
+ | { type: 'tool_end'; tool: string; success: boolean; output?: string; error?: string; durationMs?: number; id?: string }
+ | { type: 'approval_required'; id: string; tool: string; input?: Record; message?: string }
+ | { type: 'approval_resolved'; id?: string; tool?: string; decision?: MitiiApprovalDecision | string }
+ | { type: 'plan'; plan: HeadlessPlan | Record }
+ | { type: 'metrics'; durationMs: number; toolCalls: number; sessionLogPath?: string; auditTools?: string[] }
+ | { type: 'error'; message: string; data?: Record }
+ | { type: 'done'; content: string; metrics?: MitiiMetrics }
+ | { type: 'log'; event: SessionLogEvent };
+
+export function eventFromSessionLog(event: SessionLogEvent, cwd = '', mode = ''): MitiiEvent {
+ const data = event.data ?? {};
+ if (event.type === 'session_start') {
+ return {
+ type: 'session_start',
+ sessionId: event.sessionId,
+ mode: String(data.mode ?? mode),
+ cwd: String(data.workspace ?? cwd),
+ data,
+ };
+ }
+ if (event.type === 'tool_start') {
+ return {
+ type: 'tool_start',
+ tool: String(data.toolName ?? data.tool ?? event.message),
+ id: stringValue(data.toolCallId),
+ input: compactInput(data),
+ message: event.message,
+ };
+ }
+ if (event.type === 'tool_end') {
+ return {
+ type: 'tool_end',
+ tool: String(data.toolName ?? data.tool ?? event.message),
+ id: stringValue(data.toolCallId),
+ success: data.success !== false,
+ output: stringValue(data.outputPreview),
+ error: stringValue(data.error),
+ durationMs: numberValue(data.durationMs),
+ };
+ }
+ if (event.type === 'approval_request') {
+ return {
+ type: 'approval_required',
+ id: String(data.id ?? ''),
+ tool: String(data.toolName ?? data.tool ?? event.message),
+ input: compactInput(data),
+ message: event.message,
+ };
+ }
+ if (event.type === 'approval_decision') {
+ return {
+ type: 'approval_resolved',
+ id: stringValue(data.id),
+ tool: stringValue(data.toolName ?? data.tool),
+ decision: stringValue(data.decision ?? event.message.split(':')[0]),
+ };
+ }
+ if (event.type === 'plan_created') {
+ return { type: 'plan', plan: data };
+ }
+ if (event.type === 'turn_complete' || event.type === 'token_usage' || event.type === 'timing') {
+ return { type: 'log', event };
+ }
+ if (event.type === 'error') {
+ return { type: 'error', message: event.message, data };
+ }
+ return { type: 'log', event };
+}
+
+function compactInput(data: Record): Record | undefined {
+ const input: Record = {};
+ for (const key of ['path', 'command', 'inputPreview', 'risk', 'reason', 'files', 'question', 'optionCount']) {
+ if (data[key] !== undefined) input[key] = data[key];
+ }
+ return Object.keys(input).length > 0 ? input : undefined;
+}
+
+function stringValue(value: unknown): string | undefined {
+ return typeof value === 'string' ? value : undefined;
+}
+
+function numberValue(value: unknown): number | undefined {
+ return typeof value === 'number' ? value : undefined;
+}
diff --git a/src/core/headless/index.ts b/src/core/headless/index.ts
index d28dc0ef..35ffd219 100644
--- a/src/core/headless/index.ts
+++ b/src/core/headless/index.ts
@@ -2,3 +2,4 @@ export * from './release';
export * from './AgentRunner';
export * from './HeadlessAgentHost';
export * from './HeadlessConfig';
+export * from './events';
diff --git a/src/core/indexing/ComponentHealth.ts b/src/core/indexing/ComponentHealth.ts
new file mode 100644
index 00000000..b19c8dcf
--- /dev/null
+++ b/src/core/indexing/ComponentHealth.ts
@@ -0,0 +1,17 @@
+/** Runtime health of an optional/degradable subsystem (embeddings, vector backend, …).
+ * Distinct from static config description: this reflects what actually happened at runtime,
+ * not just what was requested/available at startup. */
+export interface ComponentHealth {
+ status: 'unknown' | 'ready' | 'degraded';
+ detail?: string;
+}
+
+export const UNKNOWN_HEALTH: ComponentHealth = { status: 'unknown' };
+
+/** Native-loader errors (dlopen, etc.) dump multi-clause traces with local filesystem paths —
+ * fine for logs, not for a settings UI. Keep only the leading reason, capped in length. */
+export function summarizeHealthDetail(error: unknown, maxLen = 160): string {
+ const message = error instanceof Error ? error.message : String(error);
+ const firstLine = message.split('\n')[0].trim();
+ return firstLine.length > maxLen ? `${firstLine.slice(0, maxLen - 1)}…` : firstLine;
+}
diff --git a/src/core/indexing/EmbeddingProvider.ts b/src/core/indexing/EmbeddingProvider.ts
index 48afaed8..06efe165 100644
--- a/src/core/indexing/EmbeddingProvider.ts
+++ b/src/core/indexing/EmbeddingProvider.ts
@@ -1,6 +1,10 @@
+import type { ComponentHealth } from './ComponentHealth';
+
export interface EmbeddingProvider {
readonly id: string;
embed(texts: string[]): Promise;
+ /** Runtime health (has the model actually loaded?). Providers that can't degrade may omit this. */
+ getHealth?(): ComponentHealth;
}
export class NoOpEmbeddingProvider implements EmbeddingProvider {
diff --git a/src/core/indexing/FileDiscoveryService.ts b/src/core/indexing/FileDiscoveryService.ts
index aa6295ef..7c2122aa 100644
--- a/src/core/indexing/FileDiscoveryService.ts
+++ b/src/core/indexing/FileDiscoveryService.ts
@@ -1,6 +1,6 @@
import * as vscode from 'vscode';
-import { readdirSync, statSync } from 'fs';
-import { join, relative } from 'path';
+import { existsSync, promises as fs, readdirSync, statSync } from 'fs';
+import { join, relative, resolve } from 'path';
import { IgnoreService } from './IgnoreService';
import { isBinaryByExtension, detectLanguage } from './fileUtils';
import type { IndexingConfig } from '../config/schema';
@@ -13,6 +13,12 @@ export interface DiscoveredFile {
language: string | null;
}
+export interface DiscoveryOptions {
+ roots?: string[];
+ limit?: number;
+ yieldEvery?: number;
+}
+
export class FileDiscoveryService {
constructor(
private readonly workspacePath: string,
@@ -82,6 +88,70 @@ export class FileDiscoveryService {
return results;
}
+ async discoverAsync(options: DiscoveryOptions = {}): Promise {
+ const results: DiscoveredFile[] = [];
+ const exclude = this.getVsCodeExcludes();
+ const roots = options.roots?.length ? options.roots : ['.'];
+ const limit = options.limit && options.limit > 0 ? options.limit : Number.POSITIVE_INFINITY;
+ const yieldEvery = options.yieldEvery && options.yieldEvery > 0 ? options.yieldEvery : 100;
+ let visited = 0;
+
+ const walk = async (inputPath: string): Promise => {
+ if (results.length >= limit) return;
+
+ const absInput = resolve(this.workspacePath, inputPath);
+ if (!isInsideWorkspace(absInput, this.workspacePath) || !existsSync(absInput)) return;
+
+ const relInput = relative(this.workspacePath, absInput).replace(/\\/g, '/') || '.';
+ if (this.ignoreService.isIgnored(relInput) || this.isVsCodeExcluded(relInput, exclude)) return;
+
+ let stat;
+ try {
+ stat = await fs.stat(absInput);
+ } catch {
+ return;
+ }
+
+ visited += 1;
+ if (visited % yieldEvery === 0) {
+ await new Promise((resolveYield) => setTimeout(resolveYield, 0));
+ }
+
+ if (stat.isDirectory()) {
+ let entries: string[];
+ try {
+ entries = await fs.readdir(absInput);
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ await walk(join(inputPath, entry));
+ if (results.length >= limit) return;
+ }
+ return;
+ }
+
+ if (!stat.isFile()) return;
+ if (stat.size > this.config.hardSkipSizeBytes) return;
+ if (isBinaryByExtension(relInput)) return;
+
+ results.push({
+ absPath: absInput,
+ relPath: relInput,
+ size: stat.size,
+ mtime: stat.mtimeMs,
+ language: detectLanguage(relInput),
+ });
+ };
+
+ for (const root of roots) {
+ await walk(root);
+ if (results.length >= limit) break;
+ }
+
+ return results;
+ }
+
private getVsCodeExcludes(): Record {
const filesExclude = vscode.workspace.getConfiguration('files').get>('exclude', {});
const searchExclude = vscode.workspace.getConfiguration('search').get>('exclude', {});
@@ -111,3 +181,8 @@ function globToRegex(glob: string): RegExp {
.replace(/\?/g, '.');
return new RegExp(`^${escaped}$`);
}
+
+function isInsideWorkspace(absPath: string, workspacePath: string): boolean {
+ const rel = relative(workspacePath, absPath);
+ return rel === '' || (!rel.startsWith('..') && !rel.includes('..\\'));
+}
diff --git a/src/core/indexing/IgnoreService.ts b/src/core/indexing/IgnoreService.ts
index e48cafe0..55d5df7e 100644
--- a/src/core/indexing/IgnoreService.ts
+++ b/src/core/indexing/IgnoreService.ts
@@ -9,12 +9,16 @@ const DEFAULT_IGNORES = [
'node_modules/',
'.git/',
'.mitii/',
+ '.mitti/',
'.thunder/',
'dist/',
'build/',
'out/',
'.next/',
'coverage/',
+ 'vendor/',
+ 'tmp/',
+ 'logs/',
'*.min.js',
'*.min.css',
'*.map',
@@ -64,6 +68,12 @@ export class IgnoreService {
if (options?.forRead && /^packages\/[^/]+\/dist\//.test(normalized)) {
return false;
}
+ // Session logs are written for the agent's own debugging/post-hoc analysis (see
+ // SessionLogService) — reading them back must not be blocked by the blanket .mitii/
+ // and logs/ ignores below, which exist to keep the indexer/search out of internal state.
+ if (options?.forRead && /^\.mitii\/logs(\/[^/]+\.jsonl)?$/.test(normalized)) {
+ return false;
+ }
return this.ig.ignores(normalized);
}
diff --git a/src/core/indexing/IndexMaintenanceService.ts b/src/core/indexing/IndexMaintenanceService.ts
new file mode 100644
index 00000000..f612d713
--- /dev/null
+++ b/src/core/indexing/IndexMaintenanceService.ts
@@ -0,0 +1,122 @@
+import { existsSync, statSync } from 'fs';
+import type { ThunderDb } from './ThunderDb';
+import { checkDbHealth, type DbHealthReport } from './health';
+import { FtsIndex } from './FtsIndex';
+
+export interface IndexStatusReport {
+ workspace: string;
+ filesIndexed: number;
+ filesTotal: number;
+ chunks: number;
+ symbols: number;
+ queued: number;
+ failed: number;
+ running: boolean;
+ dbPath?: string;
+ dbSizeBytes?: number;
+ lastIndexedAt?: number;
+ health: DbHealthReport;
+}
+
+export interface IndexRepairReport {
+ removedFiles: number;
+ rebuiltFtsChunks: number;
+ vacuumed: boolean;
+ health: DbHealthReport;
+}
+
+export class IndexMaintenanceService {
+ constructor(
+ private readonly db: ThunderDb,
+ private readonly workspace: string,
+ private readonly dbPath?: string
+ ) {}
+
+ status(queue?: { queued?: number; failed?: number; running?: boolean }): IndexStatusReport {
+ const files = this.db.raw.prepare(`
+ SELECT
+ COUNT(*) as total,
+ SUM(CASE WHEN indexed_at IS NOT NULL THEN 1 ELSE 0 END) as indexed,
+ MAX(indexed_at) as lastIndexedAt
+ FROM files
+ WHERE workspace = ?
+ `).get(this.workspace) as { total: number; indexed: number | null; lastIndexedAt: number | null };
+ const chunks = this.count('chunks');
+ const symbols = this.count('symbols');
+ const dbSizeBytes = this.dbPath && existsSync(this.dbPath) ? statSync(this.dbPath).size : undefined;
+ return {
+ workspace: this.workspace,
+ filesIndexed: files.indexed ?? 0,
+ filesTotal: files.total,
+ chunks,
+ symbols,
+ queued: queue?.queued ?? 0,
+ failed: queue?.failed ?? 0,
+ running: queue?.running ?? false,
+ dbPath: this.dbPath,
+ dbSizeBytes,
+ lastIndexedAt: files.lastIndexedAt ?? undefined,
+ health: checkDbHealth(this.db),
+ };
+ }
+
+ removeFile(relPath: string): boolean {
+ const row = this.db.raw
+ .prepare('SELECT id FROM files WHERE workspace = ? AND rel_path = ?')
+ .get(this.workspace, relPath) as { id: number } | undefined;
+ new FtsIndex(this.db).deleteByFile(relPath);
+ if (!row) return false;
+ this.db.raw.prepare('DELETE FROM files WHERE id = ?').run(row.id);
+ return true;
+ }
+
+ repair(): IndexRepairReport {
+ let removedFiles = 0;
+ let rebuiltFtsChunks = 0;
+ this.db.transaction(() => {
+ const rows = this.db.raw
+ .prepare('SELECT id, path, rel_path FROM files WHERE workspace = ?')
+ .all(this.workspace) as Array<{ id: number; path: string; rel_path: string }>;
+ for (const row of rows) {
+ if (!existsSync(row.path)) {
+ new FtsIndex(this.db).deleteByFile(row.rel_path);
+ this.db.raw.prepare('DELETE FROM files WHERE id = ?').run(row.id);
+ removedFiles += 1;
+ }
+ }
+
+ this.db.raw.prepare('DELETE FROM fts_chunks').run();
+ const chunks = this.db.raw.prepare(`
+ SELECT files.rel_path as relPath, chunks.content as content
+ FROM chunks
+ JOIN files ON files.id = chunks.file_id
+ WHERE files.workspace = ?
+ `).all(this.workspace) as Array<{ relPath: string; content: string }>;
+ const insert = this.db.raw.prepare('INSERT INTO fts_chunks (rel_path, content) VALUES (?, ?)');
+ for (const chunk of chunks) {
+ insert.run(chunk.relPath, chunk.content);
+ rebuiltFtsChunks += 1;
+ }
+ });
+
+ let vacuumed = false;
+ try {
+ this.db.raw.pragma('wal_checkpoint(TRUNCATE)');
+ this.db.raw.exec('VACUUM');
+ vacuumed = true;
+ } catch {
+ vacuumed = false;
+ }
+
+ return {
+ removedFiles,
+ rebuiltFtsChunks,
+ vacuumed,
+ health: checkDbHealth(this.db),
+ };
+ }
+
+ private count(table: string): number {
+ return (this.db.raw.prepare(`SELECT COUNT(*) as c FROM ${table}`).get() as { c: number }).c;
+ }
+}
diff --git a/src/core/indexing/IndexQueue.ts b/src/core/indexing/IndexQueue.ts
index 2489f21b..4d159e1f 100644
--- a/src/core/indexing/IndexQueue.ts
+++ b/src/core/indexing/IndexQueue.ts
@@ -33,6 +33,8 @@ export interface IndexingStatus {
export interface IndexQueueOptions {
maxConcurrency?: number;
maxFileSizeBytes?: number;
+ deferVectorWrites?: boolean;
+ vectorBackfillBatchSize?: number;
}
type ProgressCallback = (status: IndexingStatus) => void;
@@ -54,6 +56,10 @@ export class IndexQueue {
private onComplete?: () => void;
private vectorService: VectorIndexService | undefined;
private workspace = '';
+ private readonly pendingVectorWrites = new Set>();
+ private deferVectorWrites = true;
+ private vectorBackfillBatchSize = 25;
+ private vectorBackfillRunning = false;
constructor(
private readonly db: ThunderDb,
@@ -67,6 +73,12 @@ export class IndexQueue {
if (options?.maxFileSizeBytes) {
this.maxFileSizeBytes = options.maxFileSizeBytes;
}
+ if (options?.deferVectorWrites !== undefined) {
+ this.deferVectorWrites = options.deferVectorWrites;
+ }
+ if (options?.vectorBackfillBatchSize) {
+ this.vectorBackfillBatchSize = Math.max(1, options.vectorBackfillBatchSize);
+ }
}
onStatusChange(cb: ProgressCallback): void {
@@ -109,6 +121,15 @@ export class IndexQueue {
this.queue = [];
}
+ /** Waits for embedding writes still in flight — indexFile() kicks these off without awaiting
+ * them (so file scanning isn't bottlenecked on embedding latency), so callers that need
+ * embeddings to be durable before proceeding (e.g. shutdown) must drain this explicitly. */
+ async waitForVectorIndexing(): Promise {
+ while (this.pendingVectorWrites.size > 0) {
+ await Promise.all(this.pendingVectorWrites);
+ }
+ }
+
getStatus(): IndexingStatus {
const indexed = (this.db.raw
.prepare('SELECT COUNT(*) as c FROM files WHERE indexed_at IS NOT NULL')
@@ -140,6 +161,9 @@ export class IndexQueue {
this.running = false;
this.onProgress?.(this.getStatus());
this.onComplete?.();
+ if (this.deferVectorWrites && this.vectorService && this.workspace) {
+ void this.backfillDeferredVectors();
+ }
}
private async runWorker(): Promise {
@@ -182,7 +206,11 @@ export class IndexQueue {
: this.chunker.chunkFile(indexContent, job.language);
this.db.transaction(() => {
- this.vectorService?.deleteFileChunks(job.fileId);
+ if (this.vectorService) {
+ const del = this.vectorService.deleteFileChunks(job.fileId)
+ .finally(() => this.pendingVectorWrites.delete(del));
+ this.pendingVectorWrites.add(del);
+ }
this.db.raw.prepare('DELETE FROM chunks WHERE file_id = ?').run(job.fileId);
this.db.raw.prepare('DELETE FROM symbols WHERE file_id = ?').run(job.fileId);
this.db.raw.prepare('DELETE FROM symbol_refs WHERE file_id = ?').run(job.fileId);
@@ -200,13 +228,14 @@ export class IndexQueue {
chunk.content, chunk.tokenEstimate, chunk.hash
);
this.fts.insertChunk(job.relPath, chunk.content);
- if (this.vectorService && this.workspace) {
- void this.vectorService.indexChunk(
+ if (this.vectorService && this.workspace && !this.deferVectorWrites) {
+ const write = this.vectorService.indexChunk(
this.workspace,
Number(result.lastInsertRowid),
job.relPath,
chunk.content
- );
+ ).finally(() => this.pendingVectorWrites.delete(write));
+ this.pendingVectorWrites.add(write);
}
}
@@ -256,4 +285,57 @@ export class IndexQueue {
const rows = this.db.raw.prepare('SELECT DISTINCT name FROM symbols').all() as Array<{ name: string }>;
this.knownSymbols = new Set(rows.map((r) => r.name));
}
+
+ private async backfillDeferredVectors(): Promise {
+ if (!this.vectorService || !this.workspace || this.vectorBackfillRunning) return;
+ this.vectorBackfillRunning = true;
+
+ try {
+ await this.waitForVectorIndexing();
+ while (!this.cancelled) {
+ const rows = this.db.raw.prepare(`
+ SELECT c.id as chunk_id, c.content, f.rel_path
+ FROM chunks c
+ JOIN files f ON f.id = c.file_id
+ LEFT JOIN chunk_embeddings ve
+ ON ve.chunk_id = c.id AND ve.workspace = ?
+ WHERE f.workspace = ? AND ve.chunk_id IS NULL
+ ORDER BY
+ CASE
+ WHEN f.rel_path = 'package.json' THEN 0
+ WHEN f.rel_path LIKE '%/package.json' THEN 1
+ WHEN f.rel_path LIKE 'src/%' THEN 2
+ ELSE 3
+ END,
+ f.rel_path,
+ c.chunk_index
+ LIMIT ?
+ `).all(this.workspace, this.workspace, this.vectorBackfillBatchSize) as Array<{
+ chunk_id: number;
+ content: string;
+ rel_path: string;
+ }>;
+
+ if (rows.length === 0) break;
+
+ for (const row of rows) {
+ const write = this.vectorService.indexChunk(
+ this.workspace,
+ row.chunk_id,
+ row.rel_path,
+ row.content
+ ).finally(() => this.pendingVectorWrites.delete(write));
+ this.pendingVectorWrites.add(write);
+ await write;
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ }
+ }
+ } catch (error) {
+ log.warn('Deferred vector backfill failed', {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ } finally {
+ this.vectorBackfillRunning = false;
+ }
+ }
}
diff --git a/src/core/indexing/IndexWorkerService.ts b/src/core/indexing/IndexWorkerService.ts
new file mode 100644
index 00000000..1d2d275f
--- /dev/null
+++ b/src/core/indexing/IndexWorkerService.ts
@@ -0,0 +1,159 @@
+import { existsSync, readdirSync, statSync } from 'fs';
+import { join, relative, resolve } from 'path';
+import { IndexService } from './IndexService';
+import { IndexQueue, type IndexJob } from './IndexQueue';
+import { IndexMaintenanceService, type IndexRepairReport, type IndexStatusReport } from './IndexMaintenanceService';
+import { WorkspaceScanner } from './WorkspaceScanner';
+import { IgnoreService } from './IgnoreService';
+import { detectLanguage, isBinaryByExtension } from './fileUtils';
+import { defaultThunderConfig } from '../config/defaults';
+import type { IndexingConfig } from '../config/schema';
+import { resolveDbPath } from './paths';
+import { sortIndexCandidates } from './indexingPolicy';
+
+type WorkerDiscoveredFile = {
+ absPath: string;
+ relPath: string;
+ size: number;
+ mtime: number;
+ language: string | null;
+};
+
+export interface IndexWorkerOptions {
+ workspace: string;
+ indexing?: Partial;
+}
+
+export interface IndexEnqueueResult {
+ added: number;
+ changed: number;
+ deleted: number;
+ queued: number;
+}
+
+export class IndexWorkerService {
+ private indexService: IndexService;
+ private queue?: IndexQueue;
+ private maintenance?: IndexMaintenanceService;
+ private readonly workspace: string;
+ private readonly config: IndexingConfig;
+
+ constructor(options: IndexWorkerOptions) {
+ this.workspace = resolve(options.workspace);
+ this.config = { ...defaultThunderConfig().indexing, ...options.indexing };
+ this.indexService = new IndexService(this.workspace);
+ }
+
+ async initialize(): Promise {
+ await this.indexService.initialize();
+ const db = this.indexService.getDb();
+ if (!db) throw new Error('Index database failed to initialize');
+ this.queue = new IndexQueue(db, {
+ maxConcurrency: this.config.maxConcurrency,
+ maxFileSizeBytes: this.config.maxFileSizeBytes,
+ deferVectorWrites: true,
+ });
+ this.queue.setVectorService(this.workspace, undefined);
+ this.maintenance = new IndexMaintenanceService(db, this.workspace, resolveDbPath(this.workspace));
+ }
+
+ status(): IndexStatusReport {
+ const { queue, maintenance } = this.ready();
+ return maintenance.status(queue.getStatus());
+ }
+
+ async enqueue(paths?: string[], options?: { priorityPaths?: string[] }): Promise {
+ const { queue, maintenance } = this.ready();
+ const discovered = discoverFiles(this.workspace, this.config, paths);
+ const scanner = new WorkspaceScanner(this.indexService.getDb()!, this.workspace);
+ const diff = scanner.computeDiff(discovered);
+ scanner.persistScan(diff);
+ for (const relPath of diff.deleted) {
+ maintenance.removeFile(relPath);
+ }
+ const changed = sortIndexCandidates(
+ [...diff.added, ...diff.changed],
+ [...(options?.priorityPaths ?? []), ...this.config.priorityPaths]
+ );
+ const jobs: IndexJob[] = changed
+ .map((file) => {
+ const fileId = scanner.getFileId(file.relPath);
+ return fileId ? { fileId, relPath: file.relPath, absPath: file.absPath, language: file.language } : undefined;
+ })
+ .filter((job): job is IndexJob => Boolean(job));
+ queue.enqueue(jobs);
+ return {
+ added: diff.added.length,
+ changed: diff.changed.length,
+ deleted: diff.deleted.length,
+ queued: jobs.length,
+ };
+ }
+
+ delete(relPath: string): boolean {
+ const { maintenance } = this.ready();
+ return maintenance.removeFile(relPath);
+ }
+
+ repair(): IndexRepairReport {
+ const { maintenance } = this.ready();
+ return maintenance.repair();
+ }
+
+ dispose(): void {
+ this.queue?.cancel();
+ this.indexService.dispose();
+ }
+
+ private ready(): { queue: IndexQueue; maintenance: IndexMaintenanceService } {
+ if (!this.queue || !this.maintenance) {
+ throw new Error('Index worker is not initialized');
+ }
+ return { queue: this.queue, maintenance: this.maintenance };
+ }
+}
+
+function discoverFiles(workspace: string, config: IndexingConfig, paths?: string[]): WorkerDiscoveredFile[] {
+ const ignore = new IgnoreService();
+ ignore.load(workspace, {
+ respectGitignore: config.respectGitignore,
+ respectThunderignore: config.respectThunderignore,
+ });
+ const roots = paths && paths.length > 0 ? paths : ['.'];
+ const files: WorkerDiscoveredFile[] = [];
+ for (const input of roots) {
+ const absPath = resolve(workspace, input);
+ if (!absPath.startsWith(workspace) || !existsSync(absPath)) continue;
+ walk(absPath, workspace, ignore, config, files);
+ }
+ return files;
+}
+
+function walk(
+ absPath: string,
+ workspace: string,
+ ignore: IgnoreService,
+ config: IndexingConfig,
+ files: WorkerDiscoveredFile[]
+): void {
+ const relPath = relative(workspace, absPath).replace(/\\/g, '/') || '.';
+ if (ignore.isIgnored(relPath)) return;
+ let stat;
+ try {
+ stat = statSync(absPath);
+ } catch {
+ return;
+ }
+ if (stat.isDirectory()) {
+ for (const entry of readdirSync(absPath)) walk(join(absPath, entry), workspace, ignore, config, files);
+ return;
+ }
+ if (!stat.isFile() || stat.size > config.hardSkipSizeBytes || isBinaryByExtension(relPath)) return;
+ files.push({
+ absPath,
+ relPath,
+ size: stat.size,
+ mtime: stat.mtimeMs,
+ language: detectLanguage(relPath),
+ });
+}
diff --git a/src/core/indexing/LanceDbVectorIndex.ts b/src/core/indexing/LanceDbVectorIndex.ts
index 71e5675d..bf4a8efe 100644
--- a/src/core/indexing/LanceDbVectorIndex.ts
+++ b/src/core/indexing/LanceDbVectorIndex.ts
@@ -5,6 +5,7 @@ import { cosineSimilarity } from './EmbeddingProvider';
import { createLogger } from '../telemetry/Logger';
import type { VectorIndex, VectorSearchResult } from './VectorIndex';
import { resolveThunderDir } from './paths';
+import { summarizeHealthDetail, type ComponentHealth } from './ComponentHealth';
const log = createLogger('LanceDbVectorIndex');
@@ -31,7 +32,11 @@ type LanceRow = {
const TABLE_NAME = 'chunk_embeddings';
export class LanceDbVectorIndex implements VectorIndex {
- private tablePromise: Promise | null = null;
+ private connectionPromise: Promise | null = null;
+ private creatingPromise: Promise | null = null;
+ private creatingChunkId: number | null = null;
+ private table: LanceTable | null = null;
+ private health: ComponentHealth = { status: 'unknown' };
constructor(
private readonly sqliteDb: ThunderDb,
@@ -44,35 +49,88 @@ export class LanceDbVectorIndex implements VectorIndex {
return base;
}
- private async getTable(): Promise {
- if (!this.tablePromise) {
- this.tablePromise = this.openTable();
+ private async getConnection(): Promise {
+ if (!this.connectionPromise) {
+ this.connectionPromise = this.connect();
}
- return this.tablePromise;
+ return this.connectionPromise;
}
- private async openTable(): Promise {
+ private async connect(): Promise {
try {
const lancedb = await import('@lancedb/lancedb');
const connect = (lancedb as unknown as { connect: (uri: string) => Promise }).connect;
const db = await connect(this.lanceDir());
- try {
- return await db.openTable(TABLE_NAME);
- } catch {
- return await db.createTable(TABLE_NAME, []);
- }
+ this.health = { status: 'ready' };
+ return db;
+ } catch (error) {
+ const fullDetail = error instanceof Error ? error.message : String(error);
+ this.health = { status: 'degraded', detail: summarizeHealthDetail(error) };
+ log.warn('LanceDB unavailable, falling back to SQLite vector scan for this session', { error: fullDetail });
+ return null;
+ }
+ }
+
+ /** Read-only lookup: returns null if the table hasn't been created yet (no chunks embedded so
+ * far) — that's a normal, non-degraded state, not an error. Creation happens lazily in
+ * upsertLanceRow() once we have a real row to infer the schema from (LanceDB's `createTable`
+ * needs a non-empty seed row or an explicit Arrow schema; it can't create a table from `[]`). */
+ private async getTable(): Promise {
+ if (this.table) return this.table;
+ const db = await this.getConnection();
+ if (!db) return null;
+
+ try {
+ this.table = await db.openTable(TABLE_NAME);
+ return this.table;
+ } catch {
+ return null;
+ }
+ }
+
+ /** Returns the table plus whether `seedRow` was the one used to create it (in which case it's
+ * already inserted — no separate add() needed) or another concurrent caller's row won the race
+ * to create the table first (in which case the caller still needs to add its own row). */
+ private async getOrCreateTable(seedRow: LanceRow): Promise<{ table: LanceTable; seeded: boolean } | null> {
+ const existing = await this.getTable();
+ if (existing) return { table: existing, seeded: false };
+
+ // Guard against concurrent first-inserts racing to create the table twice — only one
+ // creation attempt runs; concurrent callers await and share its result.
+ if (!this.creatingPromise) {
+ this.creatingChunkId = seedRow.chunk_id;
+ this.creatingPromise = this.createTableWithSeed(seedRow);
+ }
+ const table = await this.creatingPromise;
+ if (!table) return null;
+ return { table, seeded: this.creatingChunkId === seedRow.chunk_id };
+ }
+
+ private async createTableWithSeed(seedRow: LanceRow): Promise {
+ const db = await this.getConnection();
+ if (!db) return null;
+
+ try {
+ this.table = await db.createTable(TABLE_NAME, [seedRow]);
+ return this.table;
} catch (error) {
- log.warn('LanceDB unavailable, falling back to SQLite vectors', {
+ log.warn('LanceDB table creation failed', {
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
+ getHealth(): ComponentHealth {
+ return this.health;
+ }
+
+ /** Brute-force SQLite fallback: used only when the LanceDB native table failed to open
+ * (see getHealth()) or its own ANN search threw. Not the normal path when LanceDB is healthy. */
search(workspace: string, queryEmbedding: number[], limit = 10): VectorSearchResult[] {
if (queryEmbedding.length === 0) return [];
+ log.debug('sqlite-fallback:search', { workspace, backendHealth: this.health.status });
- // LanceDB search is async; run synchronously via sqlite mirror for HybridRetriever sync callers.
const rows = this.sqliteDb.raw.prepare(`
SELECT ve.chunk_id, c.content, f.rel_path, ve.embedding_json
FROM chunk_embeddings ve
@@ -108,17 +166,22 @@ export class LanceDbVectorIndex implements VectorIndex {
if (!table) return this.search(workspace, queryEmbedding, limit);
try {
+ // Over-fetch because LanceDB's ANN search isn't filtered by workspace server-side —
+ // we filter client-side below, so we need extra candidates to still hit `limit` after that.
+ // Once filtered, we trust LanceDB's own nearest-neighbor order (that's the whole point of
+ // using its native index) rather than recomputing cosine similarity and re-sorting in JS.
const rows = await table.search(queryEmbedding).limit(limit * 3).toArray();
- return rows
+ const results = rows
.filter((row) => row.workspace === workspace)
+ .slice(0, limit)
.map((row) => ({
chunkId: row.chunk_id,
relPath: row.rel_path,
content: row.content,
score: cosineSimilarity(queryEmbedding, row.vector),
- }))
- .sort((a, b) => b.score - a.score)
- .slice(0, limit);
+ }));
+ log.debug('lancedb:search', { workspace, candidateCount: rows.length, resultCount: results.length });
+ return results;
} catch (error) {
log.warn('LanceDB search failed', {
error: error instanceof Error ? error.message : String(error),
@@ -127,7 +190,10 @@ export class LanceDbVectorIndex implements VectorIndex {
}
}
- upsertChunk(workspace: string, chunkId: number, relPath: string, embedding: number[]): void {
+ /** Writes SQLite first as a durable safety net (used if LanceDB is unavailable), then awaits
+ * the LanceDB write itself so callers can rely on the embedding actually being searchable via
+ * LanceDB once this resolves, rather than racing a fire-and-forget background write. */
+ async upsertChunk(workspace: string, chunkId: number, relPath: string, embedding: number[]): Promise {
this.sqliteDb.raw.prepare(`
INSERT INTO chunk_embeddings (chunk_id, workspace, embedding_json, updated_at)
VALUES (?, ?, ?, ?)
@@ -136,7 +202,7 @@ export class LanceDbVectorIndex implements VectorIndex {
updated_at = excluded.updated_at
`).run(chunkId, workspace, JSON.stringify(embedding), Date.now());
- void this.upsertLanceRow(workspace, chunkId, relPath, embedding);
+ await this.upsertLanceRow(workspace, chunkId, relPath, embedding);
}
private async upsertLanceRow(
@@ -145,22 +211,33 @@ export class LanceDbVectorIndex implements VectorIndex {
relPath: string,
embedding: number[]
): Promise {
- const table = await this.getTable();
- if (!table) return;
-
try {
const contentRow = this.sqliteDb.raw
.prepare('SELECT content FROM chunks WHERE id = ?')
.get(chunkId) as { content: string } | undefined;
- await table.delete(`chunk_id = ${chunkId}`);
- await table.add([{
+ const row: LanceRow = {
chunk_id: chunkId,
workspace,
rel_path: relPath,
content: contentRow?.content ?? '',
vector: embedding,
- }]);
+ };
+
+ const existing = await this.getTable();
+ if (existing) {
+ await existing.delete(`chunk_id = ${chunkId}`);
+ await existing.add([row]);
+ return;
+ }
+
+ // No table yet: this row seeds it (createTable() inserts it directly, no separate add()) —
+ // unless a concurrent upsert's row won the race to create the table first, in which case
+ // this row still needs to be added.
+ const result = await this.getOrCreateTable(row);
+ if (result && !result.seeded) {
+ await result.table.add([row]);
+ }
} catch (error) {
log.warn('LanceDB upsert failed', {
chunkId,
@@ -169,7 +246,7 @@ export class LanceDbVectorIndex implements VectorIndex {
}
}
- deleteFileChunks(fileId: number): void {
+ async deleteFileChunks(fileId: number): Promise {
const chunkIds = this.sqliteDb.raw
.prepare('SELECT id FROM chunks WHERE file_id = ?')
.all(fileId) as Array<{ id: number }>;
@@ -179,17 +256,15 @@ export class LanceDbVectorIndex implements VectorIndex {
WHERE chunk_id IN (SELECT id FROM chunks WHERE file_id = ?)
`).run(fileId);
- void (async () => {
- const table = await this.getTable();
- if (!table) return;
- for (const row of chunkIds) {
- try {
- await table.delete(`chunk_id = ${row.id}`);
- } catch {
- // Non-fatal
- }
+ const table = await this.getTable();
+ if (!table) return;
+ for (const row of chunkIds) {
+ try {
+ await table.delete(`chunk_id = ${row.id}`);
+ } catch {
+ // Non-fatal
}
- })();
+ }
}
count(workspace: string): number {
diff --git a/src/core/indexing/ThunderDb.ts b/src/core/indexing/ThunderDb.ts
index 5873b02c..a1342a79 100644
--- a/src/core/indexing/ThunderDb.ts
+++ b/src/core/indexing/ThunderDb.ts
@@ -14,6 +14,7 @@ export class ThunderDb {
}
this.db = new Database(this.dbPath);
this.db.pragma('journal_mode = WAL');
+ this.db.pragma('busy_timeout = 5000');
this.db.pragma('foreign_keys = ON');
log.info('Database opened', { path: this.dbPath });
}
diff --git a/src/core/indexing/TransformersEmbeddingProvider.ts b/src/core/indexing/TransformersEmbeddingProvider.ts
index c8b94bbc..bef7a08e 100644
--- a/src/core/indexing/TransformersEmbeddingProvider.ts
+++ b/src/core/indexing/TransformersEmbeddingProvider.ts
@@ -1,5 +1,6 @@
import { createLogger } from '../telemetry/Logger';
-import type { EmbeddingProvider } from './EmbeddingProvider';
+import { HashEmbeddingProvider, type EmbeddingProvider } from './EmbeddingProvider';
+import { summarizeHealthDetail, type ComponentHealth } from './ComponentHealth';
const log = createLogger('TransformersEmbedding');
@@ -9,6 +10,8 @@ type FeatureExtractor = (
) => Promise<{ tolist(): number[][] }>;
let pipelinePromise: Promise | null = null;
+let health: ComponentHealth = { status: 'unknown' };
+const hashFallback = new HashEmbeddingProvider();
async function loadPipeline(): Promise {
if (pipelinePromise) return pipelinePromise;
@@ -20,11 +23,12 @@ async function loadPipeline(): Promise {
env.allowLocalModels = true;
env.allowRemoteModels = true;
const extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
+ health = { status: 'ready' };
return extractor as FeatureExtractor;
} catch (error) {
- log.warn('MiniLM embeddings unavailable', {
- error: error instanceof Error ? error.message : String(error),
- });
+ const fullDetail = error instanceof Error ? error.message : String(error);
+ health = { status: 'degraded', detail: `${summarizeHealthDetail(error)}; using hash fallback` };
+ log.warn('MiniLM embeddings unavailable, using hash embeddings for this session', { error: fullDetail });
return null;
}
})();
@@ -41,7 +45,8 @@ export class TransformersEmbeddingProvider implements EmbeddingProvider {
const extractor = await loadPipeline();
if (!extractor) {
- return texts.map(() => []);
+ log.debug('embed:fallback, MiniLM pipeline unavailable', { textCount: texts.length, detail: health.detail });
+ return hashFallback.embed(texts);
}
const outputs: number[][] = [];
@@ -54,14 +59,19 @@ export class TransformersEmbeddingProvider implements EmbeddingProvider {
// Yield to event loop so the extension host stays responsive.
await new Promise((resolve) => setTimeout(resolve, 5));
} catch (error) {
- log.warn('MiniLM embed failed', {
- error: error instanceof Error ? error.message : String(error),
- });
- outputs.push([]);
+ const fullDetail = error instanceof Error ? error.message : String(error);
+ health = { status: 'degraded', detail: `${summarizeHealthDetail(error)}; using hash fallback` };
+ log.warn('MiniLM embed failed, using hash embedding for this input', { error: fullDetail });
+ const [fallback] = await hashFallback.embed([text]);
+ outputs.push(fallback);
}
}
return outputs;
}
+
+ getHealth(): ComponentHealth {
+ return health;
+ }
}
export function isTransformersEmbeddingAvailable(): boolean {
diff --git a/src/core/indexing/VectorIndex.ts b/src/core/indexing/VectorIndex.ts
index 3264e692..ed7922b1 100644
--- a/src/core/indexing/VectorIndex.ts
+++ b/src/core/indexing/VectorIndex.ts
@@ -2,6 +2,7 @@ import type { ThunderDb } from './ThunderDb';
import { cosineSimilarity, type EmbeddingProvider } from './EmbeddingProvider';
import type { LanceDbVectorIndex } from './LanceDbVectorIndex';
import { createLogger } from '../telemetry/Logger';
+import type { ComponentHealth } from './ComponentHealth';
const log = createLogger('VectorIndex');
@@ -14,9 +15,11 @@ export interface VectorSearchResult {
export interface VectorIndex {
search(workspace: string, queryEmbedding: number[], limit?: number): VectorSearchResult[];
- upsertChunk(workspace: string, chunkId: number, relPath: string, embedding: number[]): void;
- deleteFileChunks(fileId: number): void;
+ upsertChunk(workspace: string, chunkId: number, relPath: string, embedding: number[]): void | Promise