From eef546abe3bb9d7048b153131252ccc2f4d8f2f6 Mon Sep 17 00:00:00 2001
From: codewithshinde
Date: Fri, 3 Jul 2026 19:01:21 -0500
Subject: [PATCH 01/21] feat: enhance memory management and CLI functionality
- Exported `filterSecrets` function from MemoryService for broader access.
- Added `AutoMemoryFileWriter` export in memory index.
- Improved `ProjectRulesService` to support layered rule files and expand file references.
- Integrated `AutoMemoryFileWriter` into `MemoryExtractor` for automatic memory writing.
- Enhanced `SessionLogService` with event listeners for better telemetry.
- Updated CLI to support interactive mode and improved command handling.
- Implemented memory management commands in CLI for better user experience.
- Added new settings for memory summarization and auto-memory in the settings panel.
- Created tests for new features, including SDK event streaming and rule loading.
- Updated benchmark verification to accommodate new event types.
---
README.md | 53 ++-
docs/integrations/agentmemory.md | 42 +++
package.json | 27 +-
packages/sdk/README.md | 43 +++
packages/sdk/package.json | 42 +++
packages/sdk/src/client.ts | 125 +++++++
packages/sdk/src/events.ts | 9 +
packages/sdk/src/index.d.ts | 29 ++
packages/sdk/src/index.ts | 12 +
packages/sdk/src/types.d.ts | 47 +++
packages/sdk/src/types.ts | 47 +++
packages/sdk/tsconfig.json | 15 +
pnpm-workspace.yaml | 1 +
scripts/connect-agentmemory.sh | 33 ++
scripts/sync-sdk-version.mjs | 11 +
src/core/app/ThunderController.ts | 23 +-
src/core/config/schema.ts | 3 +
src/core/config/ui/mappers.ts | 5 +
src/core/config/ui/payloads.ts | 8 +
src/core/config/vscode/read.ts | 2 +
src/core/config/vscode/write.ts | 12 +
src/core/headless/AgentRunner.ts | 3 +-
src/core/headless/HeadlessAgentHost.ts | 96 +++++-
src/core/headless/HeadlessConfig.ts | 3 +
src/core/headless/events.ts | 104 ++++++
src/core/headless/index.ts | 1 +
src/core/mcp/builtinServers.ts | 19 ++
src/core/mcp/mcpToggles.ts | 3 +
src/core/mcp/mcpWorkspaceConfig.ts | 19 ++
src/core/mcp/scaffoldMitiiWorkspace.ts | 15 +
src/core/memory/AutoMemoryFileWriter.ts | 155 +++++++++
src/core/memory/MemoryService.ts | 2 +-
src/core/memory/index.ts | 1 +
src/core/rules/ProjectRulesService.ts | 122 ++++++-
src/core/runtime/MemoryExtractor.ts | 13 +-
src/core/telemetry/SessionLogService.ts | 27 ++
src/node/cli.ts | 312 +++++++++++++++---
src/vscode/webview/messages.ts | 8 +
.../src/components/SettingsPanel.tsx | 50 +++
test/benchmark/benchmark.harness.test.ts | 2 +-
test/benchmark/headless-agent-host.test.ts | 2 +-
test/phase1.test.ts | 100 ++++++
test/unit.test.ts | 19 +-
tools/benchmark/verify.mjs | 3 +-
44 files changed, 1565 insertions(+), 103 deletions(-)
create mode 100644 docs/integrations/agentmemory.md
create mode 100644 packages/sdk/README.md
create mode 100644 packages/sdk/package.json
create mode 100644 packages/sdk/src/client.ts
create mode 100644 packages/sdk/src/events.ts
create mode 100644 packages/sdk/src/index.d.ts
create mode 100644 packages/sdk/src/index.ts
create mode 100644 packages/sdk/src/types.d.ts
create mode 100644 packages/sdk/src/types.ts
create mode 100644 packages/sdk/tsconfig.json
create mode 100755 scripts/connect-agentmemory.sh
create mode 100644 scripts/sync-sdk-version.mjs
create mode 100644 src/core/headless/events.ts
create mode 100644 src/core/memory/AutoMemoryFileWriter.ts
create mode 100644 test/phase1.test.ts
diff --git a/README.md b/README.md
index b5e186d0..207ea36b 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
-
+
@@ -196,13 +196,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 `thunder.memory.autoMemoryEnabled` plus `thunder.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 +242,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 `thunder.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
@@ -472,6 +475,9 @@ Use the Echo provider for UI testing without an LLM. API keys are stored through
"thunder.indexing.vectorBackend": "sqlite",
"thunder.context.rerankerEnabled": true,
"thunder.memory.enabled": true,
+ "thunder.memory.summarizeAfterTask": true,
+ "thunder.memory.autoMemoryEnabled": true,
+ "thunder.memory.autoMemoryScope": "user",
"thunder.mcp.enabled": true,
"thunder.github.issueFetchEnabled": true,
"thunder.github.issueCommentLimit": 8,
@@ -493,19 +499,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 +580,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
```
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/package.json b/package.json
index 7131002c..70bec209 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.31",
"publisher": "mitii",
"icon": "media/mitii-short-logo.png",
"license": "AGPL-3.0-or-later",
@@ -388,6 +388,26 @@
"default": true,
"description": "Use FTS5 + optional vector hybrid search for observations"
},
+ "thunder.memory.summarizeAfterTask": {
+ "type": "boolean",
+ "default": true,
+ "description": "Summarize completed tasks into durable local memory."
+ },
+ "thunder.memory.autoMemoryEnabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Write post-task memories to human-readable markdown files in addition to SQLite observations."
+ },
+ "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."
+ },
"thunder.scm.commitMessageEnabled": {
"type": "boolean",
"default": true,
@@ -582,7 +602,8 @@
"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."
},
@@ -617,6 +638,8 @@
"postinstall": "node -e \"console.log('Run pnpm run rebuild:native before F5 if better-sqlite3 fails 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",
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/package.json b/packages/sdk/package.json
new file mode 100644
index 00000000..ccfd70bd
--- /dev/null
+++ b/packages/sdk/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "@mitii/sdk",
+ "version": "2.7.30",
+ "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"
+ },
+ "files": [
+ "dist",
+ "README.md"
+ ],
+ "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 && cp src/index.d.ts dist/index.d.ts && cp src/types.d.ts dist/types.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..f2ce6429
--- /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;
+ }
+
+ dispose(): void {
+ 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 {
+ 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/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..f6ed5474
--- /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(): void;
+}
+
+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..afdb222f
--- /dev/null
+++ b/packages/sdk/src/index.ts
@@ -0,0 +1,12 @@
+export { MitiiClient, createClient, query } from './client';
+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-workspace.yaml b/pnpm-workspace.yaml
index 2cda91e2..f08bb9c7 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,2 +1,3 @@
packages:
- 'tools/*'
+ - 'packages/*'
diff --git a/scripts/connect-agentmemory.sh b/scripts/connect-agentmemory.sh
new file mode 100755
index 00000000..75a5f7d8
--- /dev/null
+++ b/scripts/connect-agentmemory.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="${1:-$(pwd)}"
+
+if ! command -v node >/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/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/src/core/app/ThunderController.ts b/src/core/app/ThunderController.ts
index 116f19e8..e118feef 100644
--- a/src/core/app/ThunderController.ts
+++ b/src/core/app/ThunderController.ts
@@ -58,6 +58,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 { VectorContextSource } from '../context/sources/VectorContextSource';
import { VectorIndexService } from '../indexing/VectorIndex';
@@ -140,6 +141,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;
@@ -516,6 +518,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);
}
@@ -659,7 +665,8 @@ export class ThunderController {
this.memoryExtractor = new MemoryExtractor(
this.memoryService,
- config.memory.summarizeAfterTask
+ config.memory.summarizeAfterTask,
+ this.autoMemoryWriter
);
this.setupFileWatcher(workspace);
@@ -825,6 +832,7 @@ 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));
}
@@ -995,6 +1003,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,
@@ -2433,12 +2444,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 +2604,7 @@ export class ThunderController {
memory: builtin.memory,
sequentialThinking: builtin.sequentialThinking,
puppeteer: builtin.puppeteer ?? false,
+ agentmemory: builtin.agentmemory ?? false,
};
}
diff --git a/src/core/config/schema.ts b/src/core/config/schema.ts
index 194d35ac..bb4aed85 100644
--- a/src/core/config/schema.ts
+++ b/src/core/config/schema.ts
@@ -81,6 +81,8 @@ 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({
@@ -141,6 +143,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({
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/read.ts b/src/core/config/vscode/read.ts
index caa06cd3..64bf49fd 100644
--- a/src/core/config/vscode/read.ts
+++ b/src/core/config/vscode/read.ts
@@ -57,6 +57,8 @@ 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'),
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/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..8436c932 100644
--- a/src/core/headless/HeadlessAgentHost.ts
+++ b/src/core/headless/HeadlessAgentHost.ts
@@ -50,6 +50,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 +70,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');
@@ -108,6 +111,7 @@ export class HeadlessAgentHost {
private toolExecutor?: ToolExecutor;
private chatOrchestrator?: ChatOrchestrator;
private memoryExtractor?: MemoryExtractor;
+ private autoMemoryWriter?: AutoMemoryFileWriter;
private mcpManager = new McpManager();
private sessionLog = new SessionLogService();
private subagentTracker = new SubagentTracker();
@@ -127,6 +131,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 {
@@ -188,6 +197,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);
@@ -229,12 +242,14 @@ export class HeadlessAgentHost {
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) {
@@ -262,23 +277,53 @@ export class HeadlessAgentHost {
}
}
- async *agent(prompt: string): AsyncIterable<{ type: string; message?: string; plan?: HeadlessPlan; content?: string }> {
+ async *agent(prompt: string): AsyncIterable {
await this.initialize();
if (!this.isRealRuntime) {
- yield* this.stubRunner.agent(prompt);
+ for await (const event of this.stubRunner.agent(prompt)) {
+ 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 {
+ for await (const chunk of this.streamMode('agent', prompt)) {
+ 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 +339,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('');
}
@@ -320,6 +365,13 @@ export class HeadlessAgentHost {
this.initialized = false;
}
+ 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 {
if (!this.chatOrchestrator || !this.toolExecutor) return;
@@ -429,6 +481,7 @@ 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));
const reranker = createContextReranker(undefined, false);
return new HybridRetriever(sources, reranker, {
@@ -479,7 +532,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 +559,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/mcp/builtinServers.ts b/src/core/mcp/builtinServers.ts
index b5e1b67b..9885e628 100644
--- a/src/core/mcp/builtinServers.ts
+++ b/src/core/mcp/builtinServers.ts
@@ -8,6 +8,7 @@ export const BUILTIN_MCP_SERVER_NAMES = [
'memory',
'sequential-thinking',
'puppeteer',
+ 'agentmemory',
] as const;
export type BuiltinMcpServerName = (typeof BUILTIN_MCP_SERVER_NAMES)[number];
@@ -49,9 +50,27 @@ export function buildBuiltinMcpServers(workspace: string): Record {
+ try {
+ const response = await fetch(url, { method: 'GET' });
+ return response.ok;
+ } catch {
+ return false;
+ }
+}
diff --git a/src/core/mcp/mcpToggles.ts b/src/core/mcp/mcpToggles.ts
index 9b4caf93..bbf9cd03 100644
--- a/src/core/mcp/mcpToggles.ts
+++ b/src/core/mcp/mcpToggles.ts
@@ -5,6 +5,7 @@ export interface McpToggles {
memory: boolean;
sequentialThinking: boolean;
puppeteer: boolean;
+ agentmemory?: boolean;
}
export const defaultMcpToggles = (): McpToggles => ({
@@ -12,6 +13,7 @@ export const defaultMcpToggles = (): McpToggles => ({
memory: true,
sequentialThinking: true,
puppeteer: false,
+ agentmemory: false,
});
export function mcpToggleKeyToServerName(key: keyof McpToggles): string {
@@ -24,6 +26,7 @@ export function mcpServerNameToToggleKey(name: string): keyof McpToggles | undef
if (name === 'memory') return 'memory';
if (name === 'sequential-thinking') return 'sequentialThinking';
if (name === 'puppeteer') return 'puppeteer';
+ if (name === 'agentmemory') return 'agentmemory';
return undefined;
}
diff --git a/src/core/mcp/mcpWorkspaceConfig.ts b/src/core/mcp/mcpWorkspaceConfig.ts
index cc8291c6..128efe14 100644
--- a/src/core/mcp/mcpWorkspaceConfig.ts
+++ b/src/core/mcp/mcpWorkspaceConfig.ts
@@ -75,6 +75,25 @@ export function saveCustomMcpServers(
return payload;
}
+export function connectAgentMemoryMcp(workspace: string): Record {
+ const servers = loadWorkspaceMcpServers(workspace);
+ const next: Record = {
+ ...servers,
+ agentmemory: {
+ disabled: false,
+ type: 'streamable-http',
+ command: '',
+ args: [],
+ env: {},
+ url: 'http://localhost:3111/mcp',
+ headers: {},
+ timeoutMs: 30_000,
+ },
+ };
+ writeWorkspaceMcpServers(workspace, next);
+ return next;
+}
+
export function loadWorkspaceMcpServers(workspace: string): Record {
if (!workspace.trim()) return {};
const merged: Record = {};
diff --git a/src/core/mcp/scaffoldMitiiWorkspace.ts b/src/core/mcp/scaffoldMitiiWorkspace.ts
index 00302c32..180cab87 100644
--- a/src/core/mcp/scaffoldMitiiWorkspace.ts
+++ b/src/core/mcp/scaffoldMitiiWorkspace.ts
@@ -45,6 +45,16 @@ Example:
- \`logs/\` — session logs (when enabled)
- \`tasks/\` — saved plans
- \`skills/\` — bundled workspace skill playbooks (copied from the extension on first init)
+- \`MITTII.local.md\` — optional personal project instructions; copy from \`MITTII.local.md.example\`
+`;
+
+const LOCAL_RULES_EXAMPLE = `# Local Mitii Instructions
+
+Personal notes for this workspace. This file is intentionally not meant for git.
+
+- Preferred verification command:
+- Local services or ports:
+- Project-specific cautions:
`;
export interface ScaffoldMitiiWorkspaceOptions {
@@ -70,6 +80,11 @@ export function scaffoldMitiiWorkspace(
writeFileSync(readmePath, README, 'utf-8');
}
+ const localRulesExamplePath = join(dir, 'MITTII.local.md.example');
+ if (!existsSync(localRulesExamplePath)) {
+ writeFileSync(localRulesExamplePath, LOCAL_RULES_EXAMPLE, 'utf-8');
+ }
+
if (options.extensionRoot?.trim()) {
installBundledSkills(workspace, options.extensionRoot, {
force: options.forceBundledSkills,
diff --git a/src/core/memory/AutoMemoryFileWriter.ts b/src/core/memory/AutoMemoryFileWriter.ts
new file mode 100644
index 00000000..77279089
--- /dev/null
+++ b/src/core/memory/AutoMemoryFileWriter.ts
@@ -0,0 +1,155 @@
+import { createHash } from 'crypto';
+import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, appendFileSync, rmSync } from 'fs';
+import { basename, join } from 'path';
+import { homedir } from 'os';
+import type { ContextItem, ContextQuery, ContextSource } from '../context/types';
+import type { Observation, ObservationType } from './MemoryService';
+import { filterSecrets } from './MemoryService';
+import { createLogger } from '../telemetry/Logger';
+
+const log = createLogger('AutoMemoryFileWriter');
+
+export type AutoMemoryScope = 'user' | 'workspace' | 'both';
+
+export interface AutoMemoryOptions {
+ enabled?: boolean;
+ scope?: AutoMemoryScope;
+ maxRecentFiles?: number;
+}
+
+export class AutoMemoryFileWriter {
+ constructor(private readonly workspace: string, private readonly options: AutoMemoryOptions = {}) {}
+
+ writeObservation(observation: Observation): string[] {
+ if (this.options.enabled === false) return [];
+ const cleanText = filterSecrets(observation.text);
+ if (!cleanText) return [];
+
+ const paths: string[] = [];
+ for (const dir of this.resolveDirs()) {
+ try {
+ mkdirSync(dir, { recursive: true });
+ const fileName = `${formatDate(observation.createdAt)}-${slugify(observation.type)}-${slugify(cleanText).slice(0, 48)}.md`;
+ const filePath = join(dir, fileName);
+ const body = [
+ `# ${titleForType(observation.type)}`,
+ '',
+ `- Type: ${observation.type}`,
+ `- Session: ${observation.sessionId}`,
+ `- Created: ${new Date(observation.createdAt).toISOString()}`,
+ observation.files?.length ? `- Files: ${observation.files.join(', ')}` : '',
+ '',
+ cleanText,
+ '',
+ ].filter(Boolean).join('\n');
+ writeFileSync(filePath, body, { encoding: 'utf-8', mode: 0o600 });
+ this.appendIndex(dir, fileName, observation.type, cleanText);
+ paths.push(filePath);
+ } catch (error) {
+ log.warn('Auto-memory markdown write failed', {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+ return paths;
+ }
+
+ readRecent(maxFiles = this.options.maxRecentFiles ?? 8): Array<{ relPath: string; content: string; mtimeMs: number }> {
+ if (this.options.enabled === false) return [];
+ const out: Array<{ relPath: string; content: string; mtimeMs: number }> = [];
+ for (const dir of this.resolveDirs()) {
+ if (!existsSync(dir)) continue;
+ for (const file of readdirSync(dir).filter((entry) => entry.endsWith('.md') && entry !== 'MEMORY.md')) {
+ const abs = join(dir, file);
+ try {
+ const st = statSync(abs);
+ if (!st.isFile() || st.size > 64_000) continue;
+ out.push({ relPath: displayPath(dir, file), content: readFileSync(abs, 'utf-8').slice(0, 4000), mtimeMs: st.mtimeMs });
+ } catch {
+ // Skip unreadable memory files.
+ }
+ }
+ }
+ return out.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, maxFiles);
+ }
+
+ prune(days = 30): number {
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
+ let removed = 0;
+ for (const dir of this.resolveDirs()) {
+ if (!existsSync(dir)) continue;
+ for (const file of readdirSync(dir).filter((entry) => entry.endsWith('.md') && entry !== 'MEMORY.md')) {
+ const abs = join(dir, file);
+ try {
+ const st = statSync(abs);
+ if (st.isFile() && st.mtimeMs < cutoff) {
+ rmSync(abs, { force: true });
+ removed += 1;
+ }
+ } catch {
+ // Skip unreadable entries.
+ }
+ }
+ }
+ return removed;
+ }
+
+ private appendIndex(dir: string, fileName: string, type: ObservationType, text: string): void {
+ const indexPath = join(dir, 'MEMORY.md');
+ if (!existsSync(indexPath)) {
+ writeFileSync(indexPath, '# Mitii Auto-Memory\n\n', { encoding: 'utf-8', mode: 0o600 });
+ }
+ appendFileSync(indexPath, `- ${new Date().toISOString()} [${type}](./${fileName}) - ${text.replace(/\s+/g, ' ').slice(0, 140)}\n`, 'utf-8');
+ }
+
+ private resolveDirs(): string[] {
+ const scope = this.options.scope ?? 'user';
+ const dirs: string[] = [];
+ if (scope === 'user' || scope === 'both') {
+ dirs.push(join(homedir(), '.mitii', 'projects', projectHash(this.workspace), 'memory'));
+ }
+ if (scope === 'workspace' || scope === 'both') {
+ dirs.push(join(this.workspace, '.mitii', 'auto-memory'));
+ }
+ return dirs;
+ }
+}
+
+export class AutoMemoryContextSource implements ContextSource {
+ readonly id = 'auto-memory';
+
+ constructor(private readonly writer: AutoMemoryFileWriter) {}
+
+ async retrieve(_query: ContextQuery): Promise {
+ return this.writer.readRecent().map((memory, index) => ({
+ id: `auto-memory-${index}-${basename(memory.relPath)}`,
+ source: 'auto-memory',
+ relPath: memory.relPath,
+ content: memory.content,
+ score: 7,
+ reason: 'Recent auto-memory markdown',
+ tokenEstimate: Math.ceil(memory.content.length / 4),
+ }));
+ }
+}
+
+function projectHash(workspace: string): string {
+ return createHash('sha1').update(workspace).digest('hex').slice(0, 16);
+}
+
+function formatDate(ts: number): string {
+ return new Date(ts).toISOString().slice(0, 10);
+}
+
+function slugify(value: string): string {
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'memory';
+}
+
+function titleForType(type: ObservationType): string {
+ return type.split('_').map((part) => part[0]?.toUpperCase() + part.slice(1)).join(' ');
+}
+
+function displayPath(dir: string, file: string): string {
+ if (dir.includes('/.mitii/auto-memory')) return `.mitii/auto-memory/${file}`;
+ return `~/.mitii/projects/.../memory/${file}`;
+}
diff --git a/src/core/memory/MemoryService.ts b/src/core/memory/MemoryService.ts
index c7231e0b..4f9b230f 100644
--- a/src/core/memory/MemoryService.ts
+++ b/src/core/memory/MemoryService.ts
@@ -258,7 +258,7 @@ function reciprocalRankFusion(lists: Observation[][], limit: number): Observatio
.map((e) => e.obs);
}
-function filterSecrets(text: string): string | null {
+export function filterSecrets(text: string): string | null {
for (const pattern of SECRET_PATTERNS) {
if (pattern.test(text)) return null;
}
diff --git a/src/core/memory/index.ts b/src/core/memory/index.ts
index 37d8a9cb..9fedf055 100644
--- a/src/core/memory/index.ts
+++ b/src/core/memory/index.ts
@@ -1 +1,2 @@
export * from './MemoryService';
+export * from './AutoMemoryFileWriter';
diff --git a/src/core/rules/ProjectRulesService.ts b/src/core/rules/ProjectRulesService.ts
index 74648d03..84c47cd4 100644
--- a/src/core/rules/ProjectRulesService.ts
+++ b/src/core/rules/ProjectRulesService.ts
@@ -1,11 +1,24 @@
-import { existsSync, readFileSync, statSync } from 'fs';
-import { join } from 'path';
+import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
+import { homedir } from 'os';
+import { dirname, join, relative, resolve } from 'path';
import type { ContextItem, ContextQuery, ContextSource } from '../context/types';
import { createLogger } from '../telemetry/Logger';
const log = createLogger('ProjectRulesService');
-const RULE_FILE = 'MITII.md';
+const MAX_RULE_FILE_BYTES = 256_000;
+const DEFAULT_TOTAL_CHARS = 20_000;
+
+const RULE_LAYERS = [
+ { relPath: 'MITII.md', label: 'workspace' },
+ { relPath: 'AGENTS.md', label: 'compatibility' },
+ { relPath: 'CLAUDE.md', label: 'compatibility' },
+] as const;
+
+const RULE_DIRS = [
+ '.mitii/rules',
+ '.cursor/rules',
+] as const;
export interface ProjectRuleFile {
relPath: string;
@@ -15,10 +28,21 @@ export interface ProjectRuleFile {
export class ProjectRulesService {
constructor(private readonly workspace: string) {}
- load(maxCharsPerFile = 5000): ProjectRuleFile[] {
+ load(maxCharsPerFile = 5000, maxTotalChars = DEFAULT_TOTAL_CHARS): ProjectRuleFile[] {
if (!this.workspace) return [];
const files: ProjectRuleFile[] = [];
- this.tryAddFile(files, RULE_FILE, maxCharsPerFile);
+ const budget = { remaining: Math.max(0, maxTotalChars) };
+
+ this.tryAddAbsFile(files, join(homedir(), '.mitii', 'MITTII.md'), '~/.mitii/MITTII.md', maxCharsPerFile, budget);
+ for (const layer of RULE_LAYERS) {
+ this.tryAddFile(files, layer.relPath, maxCharsPerFile, budget);
+ }
+ for (const dir of RULE_DIRS) {
+ for (const relPath of this.listMarkdownFiles(dir)) {
+ this.tryAddFile(files, relPath, maxCharsPerFile, budget);
+ }
+ }
+ this.tryAddFile(files, '.mitii/MITTII.local.md', maxCharsPerFile, budget);
return files;
}
@@ -26,15 +50,33 @@ export class ProjectRulesService {
return this.load(1).length;
}
- private tryAddFile(files: ProjectRuleFile[], relPath: string, maxChars: number): void {
+ private tryAddFile(
+ files: ProjectRuleFile[],
+ relPath: string,
+ maxChars: number,
+ budget: { remaining: number }
+ ): void {
+ this.tryAddAbsFile(files, join(this.workspace, relPath), relPath, maxChars, budget);
+ }
+
+ private tryAddAbsFile(
+ files: ProjectRuleFile[],
+ abs: string,
+ relPath: string,
+ maxChars: number,
+ budget: { remaining: number }
+ ): void {
if (files.some((f) => f.relPath === relPath)) return;
- const abs = join(this.workspace, relPath);
+ if (budget.remaining <= 0) return;
if (!existsSync(abs)) return;
try {
const st = statSync(abs);
- if (!st.isFile() || st.size > 256_000) return;
- const content = readFileSync(abs, 'utf-8').slice(0, maxChars).trim();
+ if (!st.isFile() || st.size > MAX_RULE_FILE_BYTES) return;
+ const raw = readFileSync(abs, 'utf-8').slice(0, maxChars).trim();
+ const expanded = this.expandFileReferences(raw, dirname(abs), relPath, Math.min(maxChars, budget.remaining));
+ const content = expanded.slice(0, Math.min(maxChars, budget.remaining)).trim();
if (content) files.push({ relPath, content });
+ budget.remaining -= content.length;
} catch (error) {
log.warn('Could not read project rules file', {
relPath,
@@ -42,6 +84,68 @@ export class ProjectRulesService {
});
}
}
+
+ private listMarkdownFiles(relDir: string): string[] {
+ const root = join(this.workspace, relDir);
+ if (!existsSync(root)) return [];
+ const out: string[] = [];
+ const visit = (dir: string): void => {
+ let entries: string[];
+ try {
+ entries = readdirSync(dir).sort();
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ const abs = join(dir, entry);
+ try {
+ const st = statSync(abs);
+ if (st.isDirectory()) {
+ visit(abs);
+ } else if (st.isFile() && /\.md$/i.test(entry)) {
+ out.push(relative(this.workspace, abs).replace(/\\/g, '/'));
+ }
+ } catch {
+ // skip unreadable entries
+ }
+ }
+ };
+ visit(root);
+ return out;
+ }
+
+ private expandFileReferences(content: string, baseDir: string, ownerRelPath: string, maxChars: number): string {
+ let remaining = maxChars;
+ return content.replace(/(^|\s)@([A-Za-z0-9_./-]+\.[A-Za-z0-9]+)/g, (match, prefix: string, ref: string) => {
+ if (remaining <= 0) return match;
+ const resolved = this.resolveReference(baseDir, ref);
+ if (!resolved) return match;
+ try {
+ const st = statSync(resolved.absPath);
+ if (!st.isFile() || st.size > MAX_RULE_FILE_BYTES) return match;
+ const text = readFileSync(resolved.absPath, 'utf-8')
+ .slice(0, Math.min(3000, remaining))
+ .trim();
+ if (!text) return match;
+ remaining -= text.length;
+ return `${prefix}@${ref}\n\n[Referenced file: ${resolved.relPath}]\n${text}\n[/Referenced file]\n`;
+ } catch (error) {
+ log.warn('Could not read referenced project rules file', {
+ ownerRelPath,
+ ref,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return match;
+ }
+ });
+ }
+
+ private resolveReference(baseDir: string, ref: string): { absPath: string; relPath: string } | null {
+ const absPath = resolve(baseDir, ref);
+ const relPath = relative(this.workspace, absPath).replace(/\\/g, '/');
+ if (!relPath || relPath.startsWith('..') || relPath === '.') return null;
+ return { absPath, relPath };
+ }
}
export class ProjectRulesContextSource implements ContextSource {
diff --git a/src/core/runtime/MemoryExtractor.ts b/src/core/runtime/MemoryExtractor.ts
index 309b6992..e592a7a1 100644
--- a/src/core/runtime/MemoryExtractor.ts
+++ b/src/core/runtime/MemoryExtractor.ts
@@ -2,6 +2,7 @@ import type { LlmProvider } from '../llm/types';
import type { MemoryService, ObservationType } from '../memory/MemoryService';
import type { ToolCallAudit } from '../tools/types';
import { PostTaskMemoryWorker } from '../memory/PostTaskMemoryWorker';
+import type { AutoMemoryFileWriter } from '../memory/AutoMemoryFileWriter';
import { createLogger } from '../telemetry/Logger';
const log = createLogger('MemoryExtractor');
@@ -11,7 +12,8 @@ export class MemoryExtractor {
constructor(
private readonly memoryService: MemoryService,
- private readonly summarizeAfterTask: boolean
+ private readonly summarizeAfterTask: boolean,
+ private readonly autoMemoryWriter?: AutoMemoryFileWriter
) {}
/** Queue post-task extraction asynchronously — does not block the UI. */
@@ -41,18 +43,20 @@ export class MemoryExtractor {
}
if (filesTouched.size > 0) {
- this.memoryService.write(
+ const observation = this.memoryService.write(
sessionId,
'file_fact',
`Modified files: ${[...filesTouched].join(', ')}`,
[...filesTouched]
);
+ if (observation) this.autoMemoryWriter?.writeObservation(observation);
}
const type = inferObservationType(userMessage, assistantResponse);
const heuristic = buildHeuristicSummary(userMessage, assistantResponse, toolAudit);
if (heuristic) {
- this.memoryService.write(sessionId, type, heuristic, [...filesTouched]);
+ const observation = this.memoryService.write(sessionId, type, heuristic, [...filesTouched]);
+ if (observation) this.autoMemoryWriter?.writeObservation(observation);
}
if (this.summarizeAfterTask && provider) {
@@ -91,7 +95,8 @@ export class MemoryExtractor {
if (delta.content) summary += delta.content;
}
if (summary.trim()) {
- this.memoryService.write(sessionId, 'decision', summary.trim(), files);
+ const observation = this.memoryService.write(sessionId, 'decision', summary.trim(), files);
+ if (observation) this.autoMemoryWriter?.writeObservation(observation);
}
} catch {
// Non-fatal
diff --git a/src/core/telemetry/SessionLogService.ts b/src/core/telemetry/SessionLogService.ts
index 2b17b68c..61e308ee 100644
--- a/src/core/telemetry/SessionLogService.ts
+++ b/src/core/telemetry/SessionLogService.ts
@@ -56,6 +56,7 @@ export class SessionLogService {
private logPath = '';
private logStartedAt = 0;
private webhookEmitter = new WebhookEmitter();
+ private listeners = new Set<(event: SessionLogEvent) => void>();
configure(workspace: string, sessionId: string, enabled = true, debugMetrics = false): void {
const sessionChanged = this.sessionId !== sessionId;
@@ -99,6 +100,11 @@ export class SessionLogService {
this.writeEvent(type, message, data);
}
+ onEvent(listener: (event: SessionLogEvent) => void): () => void {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+
/** Verbose diagnostics — only written when `telemetry.debugMetrics` is enabled. */
appendDebug(type: SessionLogEventType, message: string, data?: Record): void {
if (!this.isEnabled() || !this.debugMetrics) return;
@@ -135,6 +141,7 @@ export class SessionLogService {
try {
appendFileSync(this.logPath, `${JSON.stringify(event)}\n`, 'utf-8');
this.webhookEmitter.emit(event);
+ this.notifyListeners(event);
} catch (error) {
log.warn('Failed to append session log', {
error: error instanceof Error ? error.message : String(error),
@@ -177,6 +184,14 @@ export class SessionLogService {
message: 'Session started',
data: sanitizeLogData(header),
});
+ this.notifyListeners({
+ ts,
+ time: formatTimestampForLog(ts),
+ sessionId: this.sessionId,
+ type: 'session_start',
+ message: 'Session started',
+ data: sanitizeLogData(header),
+ });
} catch (error) {
log.warn('Failed to write session log header', {
error: error instanceof Error ? error.message : String(error),
@@ -184,6 +199,18 @@ export class SessionLogService {
}
}
+ private notifyListeners(event: SessionLogEvent): void {
+ for (const listener of this.listeners) {
+ try {
+ listener(event);
+ } catch (error) {
+ log.warn('Session log listener failed', {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+ }
+
exportForAnalysis(): string {
if (!this.logPath || !existsSync(this.logPath)) {
return '';
diff --git a/src/node/cli.ts b/src/node/cli.ts
index 57bcb0f0..335e2a6c 100644
--- a/src/node/cli.ts
+++ b/src/node/cli.ts
@@ -1,10 +1,17 @@
#!/usr/bin/env node
-import { existsSync, readFileSync, writeFileSync } from 'fs';
-import { join, resolve } from 'path';
+import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
+import { createInterface } from 'readline/promises';
+import { stdin as input, stdout as output } from 'process';
+import { homedir } from 'os';
+import { dirname, join, resolve } from 'path';
import { AuditPackBuilder, verifyAuditPack } from '../core/audit';
-import { HeadlessAgentHost, generateHeadlessChangelog, prepareHeadlessRelease } from '../core/headless';
+import { generateHeadlessChangelog, prepareHeadlessRelease } from '../core/headless';
import type { HeadlessRuntime } from '../core/headless/HeadlessConfig';
import type { ProviderType } from '../core/config/schema';
+import { createClient, query } from '../../packages/sdk/src';
+import type { MitiiMode, MitiiEvent } from '../../packages/sdk/src';
+import { connectAgentMemoryMcp } from '../core/mcp/mcpWorkspaceConfig';
+import { AutoMemoryFileWriter } from '../core/memory/AutoMemoryFileWriter';
async function main(argv: string[]): Promise {
const [command, ...args] = argv;
@@ -13,7 +20,17 @@ async function main(argv: string[]): Promise {
const json = args.includes('--json');
const prompt = positional(args).join(' ').trim();
- if (!command || command === '--help' || command === 'help') {
+ if (!command) {
+ if (process.stdin.isTTY) return interactive(cwd, args);
+ printHelp();
+ return 0;
+ }
+
+ if (command === '-i' || command === '--interactive') {
+ return interactive(cwd, args, prompt || valueOf(args, '-i') || valueOf(args, '--interactive'));
+ }
+
+ if (command === '--help' || command === 'help') {
printHelp();
return 0;
}
@@ -58,43 +75,27 @@ async function main(argv: string[]): Promise {
}
if (command === 'ask') {
- const host = createHost(cwd, args);
- try {
- const answer = await host.ask(prompt || readStdin());
- process.stdout.write(json ? JSON.stringify({ answer }, null, 2) + '\n' : `${answer}\n`);
- return 0;
- } finally {
- host.dispose();
- }
+ return runOneShot('ask', cwd, args, prompt || readStdin(), json);
}
if (command === 'plan') {
- const host = createHost(cwd, args);
- try {
- const plan = await host.plan(prompt || readStdin());
- process.stdout.write(JSON.stringify(plan, null, 2) + '\n');
- return 0;
- } finally {
- host.dispose();
- }
+ return runOneShot('plan', cwd, args, prompt || readStdin(), json);
}
if (command === 'agent') {
- const host = createHost(cwd, args);
- try {
- for await (const event of host.agent(prompt || readStdin())) {
- if (json) {
- process.stdout.write(JSON.stringify(event) + '\n');
- } else if (event.content) {
- process.stdout.write(`${event.content}\n`);
- } else if (event.message) {
- process.stderr.write(`${event.message}\n`);
- }
- }
- return 0;
- } finally {
- host.dispose();
- }
+ return runOneShot((valueOf(args, '--mode') as MitiiMode | undefined) ?? 'agent', cwd, args, prompt || readStdin(), json);
+ }
+
+ if (command === 'init') {
+ return initProjectInstructions(cwd, args, json);
+ }
+
+ if (command === 'auth') {
+ return authCommand(args, json);
+ }
+
+ if (command === 'memory') {
+ return memoryCommand(cwd, args, json);
}
if (command === 'commit-msg') {
@@ -107,20 +108,192 @@ async function main(argv: string[]): Promise {
return 1;
}
-function createHost(cwd: string, args: string[]): HeadlessAgentHost {
- const provider = (valueOf(args, '--provider') as ProviderType | undefined) ?? 'echo';
+async function runOneShot(mode: MitiiMode, cwd: string, args: string[], prompt: string, json: boolean): Promise {
+ const clientOptions = clientOptionsFromArgs(cwd, args);
+ if (mode === 'ask' && !json) {
+ const client = createClient(clientOptions);
+ try {
+ process.stdout.write(`${await client.ask(prompt)}\n`);
+ return 0;
+ } finally {
+ client.dispose();
+ }
+ }
+ if (mode === 'plan' && !json) {
+ const client = createClient(clientOptions);
+ try {
+ process.stdout.write(`${JSON.stringify(await client.plan(prompt), null, 2)}\n`);
+ return 0;
+ } finally {
+ client.dispose();
+ }
+ }
+
+ for await (const event of query({ ...clientOptions, mode, prompt, sessionId: valueOf(args, '--session-id') })) {
+ if (json) {
+ process.stdout.write(JSON.stringify(event) + '\n');
+ } else {
+ renderCliEvent(event);
+ }
+ }
+ return 0;
+}
+
+function clientOptionsFromArgs(cwd: string, args: string[]) {
+ const saved = loadDefaultCredentials();
+ const provider = (valueOf(args, '--provider') as ProviderType | undefined) ?? saved.provider ?? 'echo';
const runtime = (valueOf(args, '--runtime') as HeadlessRuntime | undefined)
?? (provider === 'echo' ? 'stub' : 'real');
- return new HeadlessAgentHost({
+ return {
cwd,
runtime,
- providerType: provider,
- baseUrl: valueOf(args, '--base-url'),
- model: valueOf(args, '--model'),
+ provider,
+ baseUrl: valueOf(args, '--base-url') ?? saved.baseUrl,
+ model: valueOf(args, '--model') ?? saved.model,
+ apiKey: valueOf(args, '--api-key') ?? saved.apiKey,
approval: (valueOf(args, '--approval') as 'auto' | 'manual' | undefined) ?? 'auto',
enablePuppeteer: args.includes('--enable-puppeteer'),
allowNetwork: args.includes('--allow-network'),
- });
+ vectors: args.includes('--vectors'),
+ };
+}
+
+function renderCliEvent(event: MitiiEvent): void {
+ if (event.type === 'assistant_delta') process.stdout.write(event.content);
+ if (event.type === 'reasoning_delta') process.stderr.write(event.content);
+ if (event.type === 'tool_start') process.stderr.write(`\n[tool] ${event.tool}\n`);
+ if (event.type === 'approval_required') process.stderr.write(`\n[approval required] ${event.tool}: ${event.message ?? event.id}\n`);
+ if (event.type === 'error') process.stderr.write(`\n[error] ${event.message}\n`);
+ if (event.type === 'done') process.stdout.write(event.content.endsWith('\n') ? '' : '\n');
+}
+
+async function interactive(cwd: string, args: string[], initialPrompt = ''): Promise {
+ const rl = createInterface({ input, output });
+ let mode: MitiiMode = (valueOf(args, '--mode') as MitiiMode | undefined) ?? 'agent';
+ process.stdout.write(`Mitii interactive (${mode}) | ${cwd}\n`);
+ process.stdout.write('Slash commands: /ask /plan /agent /review /exit\n\n');
+ try {
+ let nextPrompt = initialPrompt;
+ while (true) {
+ const line = nextPrompt || await rl.question(`${mode}> `);
+ nextPrompt = '';
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+ if (trimmed === '/exit' || trimmed === '/quit') break;
+ if (['/ask', '/plan', '/agent', '/review'].includes(trimmed)) {
+ mode = trimmed.slice(1) as MitiiMode;
+ continue;
+ }
+ await runOneShot(mode, cwd, args, trimmed, false);
+ }
+ return 0;
+ } finally {
+ rl.close();
+ }
+}
+
+async function initProjectInstructions(cwd: string, args: string[], json: boolean): Promise {
+ const local = args.includes('--local');
+ const force = args.includes('--force');
+ const target = join(cwd, local ? '.mitii/MITTII.local.md' : 'MITII.md');
+ if (existsSync(target) && !force) {
+ const message = `${target} already exists. Re-run with --force to overwrite.`;
+ process.stderr.write(`${message}\n`);
+ return 2;
+ }
+ mkdirSync(dirname(target), { recursive: true });
+ const template = buildInstructionsTemplate(cwd);
+ writeFileSync(target, template, 'utf-8');
+ process.stdout.write(json ? JSON.stringify({ path: target }) + '\n' : `Created ${target}\n`);
+ return 0;
+}
+
+function buildInstructionsTemplate(cwd: string): string {
+ const pkgPath = join(cwd, 'package.json');
+ let scripts: Record = {};
+ if (existsSync(pkgPath)) {
+ try {
+ scripts = JSON.parse(readFileSync(pkgPath, 'utf-8')).scripts ?? {};
+ } catch {
+ scripts = {};
+ }
+ }
+ const scriptLines = Object.entries(scripts)
+ .filter(([name]) => /^(build|test|lint|typecheck|compile|smoke)/.test(name))
+ .map(([name, command]) => `- ${name}: \`${command}\``);
+ return [
+ '# MITTII.md',
+ '',
+ '## Project Commands',
+ scriptLines.length ? scriptLines.join('\n') : '- Add build, test, and lint commands here.',
+ '',
+ '## Architecture Notes',
+ '- Describe key packages, runtime boundaries, and important data flows.',
+ '',
+ '## Conventions',
+ '- Keep changes scoped and run the most relevant verification before finishing.',
+ '',
+ '## Safety',
+ '- Do not commit secrets. Prefer local-first tooling unless the task explicitly needs network access.',
+ '',
+ ].join('\n');
+}
+
+async function authCommand(args: string[], json: boolean): Promise {
+ const sub = args[0];
+ if (sub === 'list' || sub === 'show') {
+ const saved = loadDefaultCredentials();
+ const shown = { ...saved, apiKey: saved.apiKey ? maskSecret(saved.apiKey) : undefined };
+ process.stdout.write(JSON.stringify(shown, null, 2) + '\n');
+ return 0;
+ }
+ const provider = valueOf(args, '--provider');
+ const apiKey = valueOf(args, '--apikey') ?? valueOf(args, '--api-key');
+ const model = valueOf(args, '--model');
+ const baseUrl = valueOf(args, '--base-url');
+
+ if (provider || apiKey || model || baseUrl) {
+ saveDefaultCredentials({ provider: provider as ProviderType | undefined, apiKey, model, baseUrl });
+ process.stdout.write(json ? JSON.stringify({ saved: true }) + '\n' : 'Saved credentials to ~/.mitii/credentials.json\n');
+ return 0;
+ }
+
+ const rl = createInterface({ input, output });
+ try {
+ saveDefaultCredentials({
+ provider: (await rl.question('Provider: ')) as ProviderType,
+ baseUrl: await rl.question('Base URL: '),
+ model: await rl.question('Model: '),
+ apiKey: await rl.question('API key: '),
+ });
+ process.stdout.write('Saved credentials to ~/.mitii/credentials.json\n');
+ return 0;
+ } finally {
+ rl.close();
+ }
+}
+
+function memoryCommand(cwd: string, args: string[], json: boolean): number {
+ const sub = args[0];
+ if (sub === 'connect' && args[1] === 'agentmemory') {
+ const servers = connectAgentMemoryMcp(cwd);
+ process.stdout.write(json ? JSON.stringify({ agentmemory: servers.agentmemory }, null, 2) + '\n' : 'Connected agentmemory MCP in .mitii/mcp.json\n');
+ return 0;
+ }
+ if (sub === 'status') {
+ const writer = new AutoMemoryFileWriter(cwd, { scope: 'both' });
+ const recent = writer.readRecent(20);
+ process.stdout.write(json ? JSON.stringify({ recentCount: recent.length, recent }, null, 2) + '\n' : `Auto-memory files: ${recent.length}\n`);
+ return 0;
+ }
+ if (sub === 'prune') {
+ const days = Number(valueOf(args, '--days') ?? 30);
+ const removed = new AutoMemoryFileWriter(cwd, { scope: 'both' }).prune(Number.isFinite(days) ? days : 30);
+ process.stdout.write(json ? JSON.stringify({ removed }) + '\n' : `Removed ${removed} auto-memory files.\n`);
+ return 0;
+ }
+ process.stderr.write('Usage: mitii memory status|prune|connect agentmemory\n');
+ return 2;
}
function valueOf(args: string[], name: string): string | undefined {
@@ -133,7 +306,11 @@ function positional(args: string[]): string[] {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg.startsWith('--')) {
- if (!['--json'].includes(arg)) index += 1;
+ if (!['--json', '--enable-puppeteer', '--allow-network', '--vectors', '--force', '--local'].includes(arg)) index += 1;
+ continue;
+ }
+ if (arg === '-i') {
+ index += 1;
continue;
}
out.push(arg);
@@ -174,17 +351,64 @@ function printHelp(): void {
'Mitii CLI',
'',
'Commands:',
+ ' mitii [--mode ask|plan|agent|review] Start interactive terminal session',
+ ' mitii -i "prompt" Start interactive session with an initial prompt',
+ ' mitii init [--local] [--force] [--cwd ] [--json]',
+ ' mitii auth [--provider --base-url --model --apikey ]',
+ ' mitii auth list',
+ ' mitii memory status|prune|connect agentmemory [--cwd ] [--json]',
' mitii changelog [--since ] [--cwd ] [--json]',
' mitii prepare-release [--since ] [--cwd ] [--json]',
' mitii export-audit [--session ] [--output ] [--cwd ] [--json]',
' mitii verify-audit [--cwd ] [--json]',
' mitii ask "question" [--runtime real|stub] [--provider echo|openai|...] [--model ] [--base-url ] [--cwd ] [--json]',
' mitii plan "goal" [--runtime real|stub] [--provider echo|openai|...] [--model ] [--approval auto|manual] [--cwd ]',
- ' mitii agent "goal" [--runtime real|stub] [--provider echo|openai|...] [--model ] [--approval auto|manual] [--enable-puppeteer] [--allow-network] [--json]',
+ ' mitii agent "goal" [--mode ask|plan|agent|review] [--runtime real|stub] [--provider echo|openai|...] [--model ] [--approval auto|manual] [--enable-puppeteer] [--allow-network] [--vectors] [--json]',
'',
].join('\n'));
}
+type SavedCredentials = {
+ provider?: ProviderType;
+ baseUrl?: string;
+ model?: string;
+ apiKey?: string;
+};
+
+function credentialsPath(): string {
+ return join(homedir(), '.mitii', 'credentials.json');
+}
+
+function loadDefaultCredentials(): SavedCredentials {
+ try {
+ const parsed = JSON.parse(readFileSync(credentialsPath(), 'utf-8')) as SavedCredentials;
+ return parsed && typeof parsed === 'object' ? parsed : {};
+ } catch {
+ return {};
+ }
+}
+
+function saveDefaultCredentials(next: SavedCredentials): void {
+ const path = credentialsPath();
+ mkdirSync(dirname(path), { recursive: true });
+ const merged = { ...loadDefaultCredentials(), ...compact(next) };
+ writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, { encoding: 'utf-8', mode: 0o600 });
+ try {
+ chmodSync(path, 0o600);
+ } catch {
+ // Best effort on filesystems that do not support chmod.
+ }
+}
+
+function compact>(value: T): Partial {
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => typeof v === 'string' ? v.trim().length > 0 : v !== undefined)) as Partial;
+}
+
+function maskSecret(value: string): string {
+ if (value.length <= 8) return '********';
+ return `${value.slice(0, 4)}...${value.slice(-4)}`;
+}
+
function formatAuditVerification(result: ReturnType): string {
if (result.ok) {
return `Audit pack verified (${result.entries.length} entries).\n`;
diff --git a/src/vscode/webview/messages.ts b/src/vscode/webview/messages.ts
index ac12c1a1..867cdfca 100644
--- a/src/vscode/webview/messages.ts
+++ b/src/vscode/webview/messages.ts
@@ -15,6 +15,7 @@ export type {
AgentDepthView,
ApprovalMode,
IndexingSettingsPayload,
+ MemorySettingsPayload,
McpCustomServerView,
McpSettingsPayload,
McpToggles,
@@ -271,6 +272,9 @@ export interface SettingsView {
requireApprovalWrites: boolean;
requireApprovalShell: boolean;
memoryEnabled: boolean;
+ summarizeAfterTask: boolean;
+ autoMemoryEnabled: boolean;
+ autoMemoryScope: 'user' | 'workspace' | 'both';
subagentsEnabled: boolean;
agentMaxSteps: number;
askDepth: AgentDepthView;
@@ -462,6 +466,7 @@ export const defaultMcpToggles = (): McpToggles => ({
memory: true,
sequentialThinking: true,
puppeteer: false,
+ agentmemory: false,
});
export const defaultContextToggles = (): ContextToggles => ({
@@ -486,6 +491,9 @@ export const defaultSettingsView = (): SettingsView => ({
requireApprovalWrites: true,
requireApprovalShell: true,
memoryEnabled: true,
+ summarizeAfterTask: true,
+ autoMemoryEnabled: true,
+ autoMemoryScope: 'user',
subagentsEnabled: true,
agentMaxSteps: 15,
askDepth: 'auto',
diff --git a/src/webview-ui/src/components/SettingsPanel.tsx b/src/webview-ui/src/components/SettingsPanel.tsx
index c5d31b95..d28f30db 100644
--- a/src/webview-ui/src/components/SettingsPanel.tsx
+++ b/src/webview-ui/src/components/SettingsPanel.tsx
@@ -140,6 +140,11 @@ const MCP_BUILTIN_TOGGLES: Array<{
label: 'Puppeteer browser',
description: 'Browser automation via @modelcontextprotocol/server-puppeteer for UI verification.',
},
+ {
+ key: 'agentmemory',
+ label: 'agentmemory',
+ description: 'Optional enterprise memory backend at http://localhost:3111/mcp.',
+ },
];
interface SettingsPanelProps {
@@ -261,6 +266,9 @@ export function SettingsPanel({
const [embeddingProvider, setEmbeddingProvider] = useState<'minilm' | 'hash'>(settings.embeddingProvider);
const [vectorBackend, setVectorBackend] = useState<'sqlite' | 'lancedb'>(settings.vectorBackend);
const [hybridMemorySearch, setHybridMemorySearch] = useState(settings.hybridMemorySearch);
+ const [summarizeAfterTask, setSummarizeAfterTask] = useState(settings.summarizeAfterTask);
+ const [autoMemoryEnabled, setAutoMemoryEnabled] = useState(settings.autoMemoryEnabled);
+ const [autoMemoryScope, setAutoMemoryScope] = useState(settings.autoMemoryScope);
useEffect(() => {
if (dirty && !settingsSaving) return;
@@ -294,6 +302,9 @@ export function SettingsPanel({
setEmbeddingProvider(settings.embeddingProvider);
setVectorBackend(settings.vectorBackend);
setHybridMemorySearch(settings.hybridMemorySearch);
+ setSummarizeAfterTask(settings.summarizeAfterTask);
+ setAutoMemoryEnabled(settings.autoMemoryEnabled);
+ setAutoMemoryScope(settings.autoMemoryScope);
setSelectedProfileId(settings.activeProviderProfileId);
if (wasSavingRef.current && !settingsSaving) {
setDirty(false);
@@ -356,6 +367,11 @@ export function SettingsPanel({
vectorBackend,
hybridMemorySearch,
},
+ memory: {
+ summarizeAfterTask,
+ autoMemoryEnabled,
+ autoMemoryScope,
+ },
telemetry: {
sessionLogging,
debugMetrics: settings.localDebugAvailable && sessionLogging ? debugMetrics : false,
@@ -1087,6 +1103,40 @@ export function SettingsPanel({
title={`Memory (${memories.length})`}
description="Review or clear saved observations that can be recalled in future chats."
>
+ {
+ setSummarizeAfterTask(v);
+ markDirty();
+ }}
+ />
+ {
+ setAutoMemoryEnabled(v);
+ markDirty();
+ }}
+ />
+
>
diff --git a/test/benchmark/benchmark.harness.test.ts b/test/benchmark/benchmark.harness.test.ts
index 81596463..76faa283 100644
--- a/test/benchmark/benchmark.harness.test.ts
+++ b/test/benchmark/benchmark.harness.test.ts
@@ -41,7 +41,7 @@ describe('benchmark verify rules', () => {
}).passed).toBe(true);
expect(verifyTask('jsonl_event:end', {
- stdout: ['{"type":"start"}', '{"type":"end"}'].join('\n'),
+ stdout: '{"type":"done"}',
stderr: '',
exitCode: 0,
cwd: process.cwd(),
diff --git a/test/benchmark/headless-agent-host.test.ts b/test/benchmark/headless-agent-host.test.ts
index 7b6e5667..b33ea42b 100644
--- a/test/benchmark/headless-agent-host.test.ts
+++ b/test/benchmark/headless-agent-host.test.ts
@@ -24,7 +24,7 @@ describe('HeadlessAgentHost', () => {
for await (const event of host.agent('review code')) {
events.push(event.type);
}
- expect(events).toContain('end');
+ expect(events).toContain('done');
host.dispose();
});
diff --git a/test/phase1.test.ts b/test/phase1.test.ts
new file mode 100644
index 00000000..2cf5ec51
--- /dev/null
+++ b/test/phase1.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, it } from 'vitest';
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+import { query } from '../packages/sdk/src';
+import { ProjectRulesService } from '../src/core/rules/ProjectRulesService';
+import { AutoMemoryFileWriter } from '../src/core/memory/AutoMemoryFileWriter';
+import { connectAgentMemoryMcp, loadWorkspaceMcpServers } from '../src/core/mcp/mcpWorkspaceConfig';
+
+describe('Phase 1 SDK and platform gaps', () => {
+ it('streams SDK events for a stub agent query', async () => {
+ const cwd = mkdtempSync(join(tmpdir(), 'mitii-sdk-test-'));
+ try {
+ const events = [];
+ for await (const event of query({
+ cwd,
+ runtime: 'stub',
+ provider: 'echo',
+ mode: 'agent',
+ prompt: 'hello',
+ approval: 'auto',
+ })) {
+ events.push(event.type);
+ }
+
+ expect(events).toContain('assistant_delta');
+ expect(events).toContain('metrics');
+ expect(events).toContain('done');
+ } finally {
+ rmSync(cwd, { recursive: true, force: true });
+ }
+ });
+
+ it('loads layered MITTII rules and expands @file references', () => {
+ const cwd = mkdtempSync(join(tmpdir(), 'mitii-rules-test-'));
+ try {
+ mkdirSync(join(cwd, '.mitii', 'rules'), { recursive: true });
+ mkdirSync(join(cwd, '.cursor', 'rules'), { recursive: true });
+ writeFileSync(join(cwd, 'README.md'), '# Project Readme\n');
+ writeFileSync(join(cwd, 'MITII.md'), 'Root rules @README.md');
+ writeFileSync(join(cwd, 'AGENTS.md'), 'Agents compatibility');
+ writeFileSync(join(cwd, '.mitii', 'rules', 'testing.md'), 'Always run tests');
+ writeFileSync(join(cwd, '.cursor', 'rules', 'style.md'), 'Cursor style');
+ writeFileSync(join(cwd, '.mitii', 'MITTII.local.md'), 'Local override');
+
+ const loaded = new ProjectRulesService(cwd).load(5000, 20_000);
+ const paths = loaded.map((rule) => rule.relPath);
+ expect(paths).toEqual(expect.arrayContaining([
+ 'MITII.md',
+ 'AGENTS.md',
+ '.mitii/rules/testing.md',
+ '.cursor/rules/style.md',
+ '.mitii/MITTII.local.md',
+ ]));
+ expect(loaded.find((rule) => rule.relPath === 'MITII.md')?.content).toContain('Project Readme');
+ expect(paths.indexOf('.mitii/MITTII.local.md')).toBe(paths.length - 1);
+ } finally {
+ rmSync(cwd, { recursive: true, force: true });
+ }
+ });
+
+ it('writes workspace auto-memory markdown and an index', () => {
+ const cwd = mkdtempSync(join(tmpdir(), 'mitii-memory-test-'));
+ try {
+ const writer = new AutoMemoryFileWriter(cwd, { enabled: true, scope: 'workspace' });
+ const paths = writer.writeObservation({
+ id: 1,
+ workspace: cwd,
+ sessionId: 's1',
+ type: 'decision',
+ text: 'Use pnpm for all verification commands.',
+ files: ['package.json'],
+ createdAt: Date.now(),
+ });
+
+ expect(paths).toHaveLength(1);
+ expect(existsSync(paths[0])).toBe(true);
+ expect(readFileSync(join(cwd, '.mitii', 'auto-memory', 'MEMORY.md'), 'utf-8')).toContain('decision');
+ expect(writer.readRecent(1)[0]?.content).toContain('Use pnpm');
+ } finally {
+ rmSync(cwd, { recursive: true, force: true });
+ }
+ });
+
+ it('merges agentmemory MCP config idempotently', () => {
+ const cwd = mkdtempSync(join(tmpdir(), 'mitii-agentmemory-test-'));
+ try {
+ connectAgentMemoryMcp(cwd);
+ connectAgentMemoryMcp(cwd);
+ const servers = loadWorkspaceMcpServers(cwd);
+ expect(servers.agentmemory).toMatchObject({
+ disabled: false,
+ type: 'streamable-http',
+ url: 'http://localhost:3111/mcp',
+ });
+ } finally {
+ rmSync(cwd, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/test/unit.test.ts b/test/unit.test.ts
index 2e28699e..c2b37582 100644
--- a/test/unit.test.ts
+++ b/test/unit.test.ts
@@ -551,18 +551,19 @@ describe('Builtin MCP servers', () => {
const { buildBuiltinMcpServers } = await import('../src/core/mcp/builtinServers');
const servers = buildBuiltinMcpServers('/tmp/my-project');
- expect(Object.keys(servers).sort()).toEqual(['filesystem', 'memory', 'puppeteer', 'sequential-thinking']);
+ expect(Object.keys(servers).sort()).toEqual(['agentmemory', 'filesystem', 'memory', 'puppeteer', 'sequential-thinking']);
expect(servers.filesystem.command).toBe(process.platform === 'win32' ? 'cmd' : 'npx');
expect(servers.filesystem.args).toContain('@modelcontextprotocol/server-filesystem');
expect(servers.filesystem.args.at(-1)).toBe(resolve('/tmp/my-project'));
expect(servers.memory.args).toContain('@modelcontextprotocol/server-memory');
expect(servers['sequential-thinking'].args).toContain('@modelcontextprotocol/server-sequential-thinking');
+ expect(servers.agentmemory).toMatchObject({ type: 'streamable-http', url: 'http://localhost:3111/mcp' });
});
it('omits filesystem when workspace is empty', async () => {
const { buildBuiltinMcpServers } = await import('../src/core/mcp/builtinServers');
const servers = buildBuiltinMcpServers('');
- expect(Object.keys(servers).sort()).toEqual(['memory', 'puppeteer', 'sequential-thinking']);
+ expect(Object.keys(servers).sort()).toEqual(['agentmemory', 'memory', 'puppeteer', 'sequential-thinking']);
});
it('lets user settings override built-in servers', async () => {
@@ -606,6 +607,7 @@ describe('Builtin MCP servers', () => {
memory: false,
sequentialThinking: true,
puppeteer: false,
+ agentmemory: false,
});
expect(servers.memory.disabled).toBe(true);
expect(servers.filesystem.disabled).toBe(false);
@@ -617,6 +619,7 @@ describe('Builtin MCP servers', () => {
memory: true,
sequentialThinking: true,
puppeteer: false,
+ agentmemory: false,
});
});
});
@@ -626,20 +629,22 @@ describe('ProjectRulesService', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'thunder-rules-test-'));
try {
writeFileSync(join(tempDir, 'MITII.md'), 'mitii instructions');
- writeFileSync(join(tempDir, 'AGENTS.md'), 'ignored agent instructions');
+ writeFileSync(join(tempDir, 'AGENTS.md'), 'agent instructions');
const rules = new ProjectRulesService(tempDir).load();
- expect(rules.map((rule) => rule.relPath)).toEqual(['MITII.md']);
+ expect(rules.map((rule) => rule.relPath)).toEqual(['MITII.md', 'AGENTS.md']);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
- it('returns empty when MITII.md is missing', () => {
+ it('loads compatibility files when MITII.md is missing', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'thunder-rules-test-'));
try {
- writeFileSync(join(tempDir, 'AGENTS.md'), 'ignored');
- expect(new ProjectRulesService(tempDir).load()).toEqual([]);
+ writeFileSync(join(tempDir, 'AGENTS.md'), 'agent instructions');
+ expect(new ProjectRulesService(tempDir).load()).toEqual([
+ { relPath: 'AGENTS.md', content: 'agent instructions' },
+ ]);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
diff --git a/tools/benchmark/verify.mjs b/tools/benchmark/verify.mjs
index a3c116fc..72db3e2c 100644
--- a/tools/benchmark/verify.mjs
+++ b/tools/benchmark/verify.mjs
@@ -54,9 +54,10 @@ function verifyRule(rule, ctx) {
}
if (rule.startsWith('jsonl_event:')) {
const type = rule.slice('jsonl_event:'.length);
+ const acceptedTypes = type === 'end' ? new Set(['end', 'done']) : new Set([type]);
const found = ctx.stdout.split(/\r?\n/).some((line) => {
try {
- return JSON.parse(line).type === type;
+ return acceptedTypes.has(JSON.parse(line).type);
} catch {
return false;
}
From 464a24b73e4805616f80dfec1271d8658bcf4d6d Mon Sep 17 00:00:00 2001
From: codewithshinde
Date: Fri, 3 Jul 2026 20:24:20 -0500
Subject: [PATCH 02/21] feat(subagents): introduce subagent architecture with
loading and registry feat(task): implement task board service and parallel
agent runner feat(tools): add subagent tools for task delegation and
execution feat(cli): enhance CLI with task management commands and daemon
support test: add tests for daemon and parallel agent functionality
---
README.md | 23 ++
docs/deploy/launchd.plist | 22 ++
docs/deploy/systemd-user.service | 11 +
docs/developers/mitii-serve-protocol.md | 75 ++++++
docs/enterprise/daemon-security.md | 12 +
docs/users/mitii-serve.md | 36 +++
docs/users/parallel-agents.md | 31 +++
package.json | 58 ++++-
packages/board/package.json | 11 +
packages/board/src/server.ts | 88 +++++++
packages/daemon/package.json | 22 ++
packages/daemon/src/authMiddleware.ts | 34 +++
packages/daemon/src/cli.ts | 37 +++
packages/daemon/src/server.ts | 223 ++++++++++++++++++
packages/daemon/src/sessionManager.ts | 210 +++++++++++++++++
packages/daemon/src/sseHub.ts | 81 +++++++
packages/daemon/src/workspaceBinding.ts | 18 ++
.../sdk/examples/daemon-client-quickstart.ts | 25 ++
packages/sdk/package.json | 9 +-
packages/sdk/src/daemon.d.ts | 72 ++++++
packages/sdk/src/daemon.ts | 216 +++++++++++++++++
packages/sdk/src/index.ts | 1 +
src/core/app/ThunderController.ts | 9 +-
src/core/config/schema.ts | 4 +
src/core/daemon/DaemonRuntimeAdapter.ts | 51 ++++
src/core/git/WorktreeService.ts | 104 ++++++++
src/core/git/index.ts | 3 +
src/core/git/worktreePaths.ts | 17 ++
src/core/git/worktreeTypes.ts | 14 ++
src/core/headless/HeadlessAgentHost.ts | 15 +-
src/core/orchestration/ChatOrchestrator.ts | 14 +-
src/core/runtime/SubagentTracker.ts | 12 +-
src/core/runtime/askMode.ts | 2 +
src/core/safety/ToolPolicyEngine.ts | 2 +-
src/core/subagents/BaseSubagent.ts | 113 +++++++++
src/core/subagents/SubagentDefinition.ts | 77 ++++++
src/core/subagents/SubagentRegistry.ts | 36 +++
src/core/subagents/index.ts | 5 +
src/core/subagents/loadWorkspaceAgents.ts | 90 +++++++
src/core/subagents/types.ts | 40 ++++
src/core/task/ParallelAgentRunner.ts | 83 +++++++
src/core/task/TaskBoardService.ts | 97 ++++++++
src/core/task/index.ts | 2 +
src/core/task/types.ts | 16 ++
src/core/tools/builtinTools.ts | 213 +++++++++++------
src/core/tools/planTools.ts | 1 +
src/node/cli.ts | 178 +++++++++++++-
src/vscode/webview/messages.ts | 3 +
test/daemon.test.ts | 67 ++++++
test/parallel-agents.test.ts | 54 +++++
50 files changed, 2550 insertions(+), 87 deletions(-)
create mode 100644 docs/deploy/launchd.plist
create mode 100644 docs/deploy/systemd-user.service
create mode 100644 docs/developers/mitii-serve-protocol.md
create mode 100644 docs/enterprise/daemon-security.md
create mode 100644 docs/users/mitii-serve.md
create mode 100644 docs/users/parallel-agents.md
create mode 100644 packages/board/package.json
create mode 100644 packages/board/src/server.ts
create mode 100644 packages/daemon/package.json
create mode 100644 packages/daemon/src/authMiddleware.ts
create mode 100644 packages/daemon/src/cli.ts
create mode 100644 packages/daemon/src/server.ts
create mode 100644 packages/daemon/src/sessionManager.ts
create mode 100644 packages/daemon/src/sseHub.ts
create mode 100644 packages/daemon/src/workspaceBinding.ts
create mode 100644 packages/sdk/examples/daemon-client-quickstart.ts
create mode 100644 packages/sdk/src/daemon.d.ts
create mode 100644 packages/sdk/src/daemon.ts
create mode 100644 src/core/daemon/DaemonRuntimeAdapter.ts
create mode 100644 src/core/git/WorktreeService.ts
create mode 100644 src/core/git/index.ts
create mode 100644 src/core/git/worktreePaths.ts
create mode 100644 src/core/git/worktreeTypes.ts
create mode 100644 src/core/subagents/BaseSubagent.ts
create mode 100644 src/core/subagents/SubagentDefinition.ts
create mode 100644 src/core/subagents/SubagentRegistry.ts
create mode 100644 src/core/subagents/index.ts
create mode 100644 src/core/subagents/loadWorkspaceAgents.ts
create mode 100644 src/core/subagents/types.ts
create mode 100644 src/core/task/ParallelAgentRunner.ts
create mode 100644 src/core/task/TaskBoardService.ts
create mode 100644 test/daemon.test.ts
create mode 100644 test/parallel-agents.test.ts
diff --git a/README.md b/README.md
index 207ea36b..aab72d5e 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,29 @@ 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`.
+
**Docs:** [docs.mitii.dev](https://docs.mitii.dev)
**Website:** [mitii.dev](https://mitii.dev)
**Discord:** [discord.gg/sa8rubf6HH](https://discord.gg/sa8rubf6HH)
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/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/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/package.json b/package.json
index 70bec209..e0d29cf0 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.31",
+ "version": "2.7.32",
"publisher": "mitii",
"icon": "media/mitii-short-logo.png",
"license": "AGPL-3.0-or-later",
@@ -207,6 +207,30 @@
"default": false,
"description": "Export a sanitized audit pack automatically to .mitii/audit/ when an agent turn completes."
},
+ "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."
+ },
+ "thunder.runtime.daemonUrl": {
+ "type": "string",
+ "default": "http://127.0.0.1:4310",
+ "description": "Mitii daemon URL used when thunder.runtime.mode is daemon."
+ },
+ "thunder.runtime.daemonToken": {
+ "type": "string",
+ "default": "",
+ "description": "Optional bearer token for mitii serve. Prefer SecretStorage or managed settings for enterprise use."
+ },
+ "thunder.runtime.autoStartDaemon": {
+ "type": "boolean",
+ "default": false,
+ "description": "Automatically start mitii serve when a workspace opens and daemon runtime mode is selected."
+ },
"thunder.provider.type": {
"type": "string",
"enum": [
@@ -433,7 +457,35 @@
"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."
+ },
+ "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."
+ },
+ "thunder.agent.maxConcurrentSubagents": {
+ "type": "number",
+ "default": 2,
+ "minimum": 1,
+ "maximum": 10,
+ "description": "Maximum subagents allowed to run concurrently in one session."
+ },
+ "thunder.agent.implementerRequiresScope": {
+ "type": "boolean",
+ "default": true,
+ "description": "Require implementer subagents to receive targetFiles or scopeRoot before write tools are available."
+ },
+ "thunder.agent.subagentDailyBudget": {
+ "type": "number",
+ "default": 0,
+ "minimum": 0,
+ "description": "Maximum subagent invocations per session day. 0 means unlimited."
},
"thunder.agent.maxSteps": {
"type": "number",
@@ -663,6 +715,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",
diff --git a/packages/board/package.json b/packages/board/package.json
new file mode 100644
index 00000000..70e4978e
--- /dev/null
+++ b/packages/board/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@mitii/board",
+ "version": "2.7.31",
+ "description": "Minimal task board server for Mitii parallel agents.",
+ "license": "AGPL-3.0-or-later",
+ "type": "module",
+ "main": "./dist/server.js",
+ "engines": {
+ "node": ">=20"
+ }
+}
diff --git a/packages/board/src/server.ts b/packages/board/src/server.ts
new file mode 100644
index 00000000..8f249442
--- /dev/null
+++ b/packages/board/src/server.ts
@@ -0,0 +1,88 @@
+import { createServer, type Server } from 'http';
+import { resolve } from 'path';
+import { TaskBoardService, ParallelAgentRunner } from '../../../src/core/task';
+import { writeJson } from '../../daemon/src/authMiddleware';
+
+export interface BoardServerOptions {
+ cwd: string;
+ hostname?: string;
+ port?: number;
+}
+
+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 server = createServer(async (req, res) => {
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
+ 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 runner = new ParallelAgentRunner({ workspace: cwd, parallel: Number(url.searchParams.get('parallel') ?? 2), runtime: 'stub' });
+ 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/daemon/package.json b/packages/daemon/package.json
new file mode 100644
index 00000000..5bccb8a4
--- /dev/null
+++ b/packages/daemon/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@mitii/daemon",
+ "version": "2.7.31",
+ "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"
+ ],
+ "engines": {
+ "node": ">=20"
+ }
+}
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..061fcfdf
--- /dev/null
+++ b/packages/daemon/src/server.ts
@@ -0,0 +1,223 @@
+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';
+
+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 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, 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 () => {
+ 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,
+ 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'],
+ maxSessions: options.maxSessions ?? 5,
+ supportedModes: ['ask', 'plan', 'agent', 'review'],
+ eventReplay: true,
+ auth: Boolean(options.token ?? process.env.MITII_SERVER_TOKEN),
+ });
+ 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..a1162c3c
--- /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();
+ 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/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
index ccfd70bd..9493cb80 100644
--- a/packages/sdk/package.json
+++ b/packages/sdk/package.json
@@ -12,7 +12,12 @@
"import": "./dist/index.js",
"default": "./dist/index.js"
},
- "./types": "./dist/types.d.ts"
+ "./types": "./dist/types.d.ts",
+ "./daemon": {
+ "types": "./dist/daemon.d.ts",
+ "import": "./dist/daemon.js",
+ "default": "./dist/daemon.js"
+ }
},
"files": [
"dist",
@@ -22,7 +27,7 @@
"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 && cp src/index.d.ts dist/index.d.ts && cp src/types.d.ts dist/types.d.ts",
+ "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": {
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/index.ts b/packages/sdk/src/index.ts
index afdb222f..2e26cf03 100644
--- a/packages/sdk/src/index.ts
+++ b/packages/sdk/src/index.ts
@@ -1,4 +1,5 @@
export { MitiiClient, createClient, query } from './client';
+export { DaemonClient, DaemonSessionClient, parseSseStream } from './daemon';
export { isMitiiEvent, isTerminalEvent } from './events';
export type {
MitiiApprovalDecision,
diff --git a/src/core/app/ThunderController.ts b/src/core/app/ThunderController.ts
index e118feef..6c65ca06 100644
--- a/src/core/app/ThunderController.ts
+++ b/src/core/app/ThunderController.ts
@@ -29,7 +29,7 @@ import { ChatOrchestrator } from '../orchestration/ChatOrchestrator';
import { ToolRuntime } from '../tools/ToolRuntime';
import {
createReadFileTool, createReadFilesTool, createListFilesTool, createSearchTool,
- createSearchBatchTool, createSearchScriptCatalogTool, createSpawnResearchAgentTool,
+ createSearchBatchTool, createSearchScriptCatalogTool, createSpawnResearchAgentTool, createSpawnSubagentTool,
createExecuteWorkspaceScriptTool, createUseSkillTool,
createRepoMapTool, createRetrieveContextTool, createGitDiffTool,
createDiagnosticsTool, createWriteFileTool, createApplyPatchTool, createRunCommandTool,
@@ -557,8 +557,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,
@@ -626,6 +629,7 @@ export class ThunderController {
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));
@@ -948,8 +952,11 @@ 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,
diff --git a/src/core/config/schema.ts b/src/core/config/schema.ts
index bb4aed85..0639673f 100644
--- a/src/core/config/schema.ts
+++ b/src/core/config/schema.ts
@@ -87,6 +87,10 @@ export const MemoryConfigSchema = z.object({
export const AgentConfigSchema = z.object({
subagentsEnabled: z.boolean().default(true),
+ 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'),
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/HeadlessAgentHost.ts b/src/core/headless/HeadlessAgentHost.ts
index 8436c932..aa7f300d 100644
--- a/src/core/headless/HeadlessAgentHost.ts
+++ b/src/core/headless/HeadlessAgentHost.ts
@@ -20,7 +20,7 @@ import { ChatOrchestrator } from '../orchestration/ChatOrchestrator';
import { ToolRuntime } from '../tools/ToolRuntime';
import {
createReadFileTool, createReadFilesTool, createListFilesTool, createSearchTool,
- createSearchBatchTool, createSearchScriptCatalogTool, createSpawnResearchAgentTool,
+ createSearchBatchTool, createSearchScriptCatalogTool, createSpawnResearchAgentTool, createSpawnSubagentTool,
createExecuteWorkspaceScriptTool, createUseSkillTool,
createRepoMapTool, createRetrieveContextTool, createGitDiffTool,
createDiagnosticsTool, createWriteFileTool, createApplyPatchTool, createRunCommandTool,
@@ -277,10 +277,11 @@ export class HeadlessAgentHost {
}
}
- async *agent(prompt: string): AsyncIterable {
+ async *agent(prompt: string, signal?: AbortSignal): AsyncIterable {
await this.initialize();
if (!this.isRealRuntime) {
for await (const event of this.stubRunner.agent(prompt)) {
+ if (signal?.aborted) break;
yield event as MitiiEvent;
}
return;
@@ -300,7 +301,11 @@ export class HeadlessAgentHost {
};
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);
@@ -361,10 +366,15 @@ export class HeadlessAgentHost {
}
dispose(): void {
+ this.cancel();
this.indexService?.dispose();
this.initialized = false;
}
+ cancel(): void {
+ this.chatOrchestrator?.stop();
+ }
+
resolveApproval(id: string, decision: MitiiApprovalDecision): boolean {
if (!this.approvalQueue) return false;
this.approvalQueue.resolve(id, decision);
@@ -424,6 +434,7 @@ export class HeadlessAgentHost {
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));
diff --git a/src/core/orchestration/ChatOrchestrator.ts b/src/core/orchestration/ChatOrchestrator.ts
index fdbe060f..2f572ec4 100644
--- a/src/core/orchestration/ChatOrchestrator.ts
+++ b/src/core/orchestration/ChatOrchestrator.ts
@@ -52,7 +52,7 @@ import {
isMdxRepairTask,
suggestDocsVerifyCommands,
} from '../runtime/mdxRepairRouting';
-import { setResearchAgentRuntime } from '../tools/builtinTools';
+import { setSubagentRuntime } from '../tools/builtinTools';
import type { SessionService } from '../session/SessionService';
import type { PlanPersistence } from '../plans/PlanPersistence';
import type { MemoryExtractor } from '../runtime/MemoryExtractor';
@@ -551,7 +551,7 @@ export class ChatOrchestrator {
: (actPlan?.route.shouldUseSubagents ?? taskAnalysis.shouldUseSubagents));
let tools = toolsEnabled
? toolsToDefinitions(this.deps.toolRuntime!.list()).filter((tool) =>
- subagentsEnabled || tool.function.name !== 'spawn_research_agent'
+ subagentsEnabled || !['spawn_research_agent', 'spawn_subagent'].includes(tool.function.name)
)
: [];
if (isAskMode) {
@@ -561,15 +561,18 @@ export class ChatOrchestrator {
}
if (toolsEnabled && this.deps.toolExecutor) {
- setResearchAgentRuntime({
+ setSubagentRuntime({
toolExecutor: this.deps.toolExecutor,
getProvider: () => this.deps.researchAgentProvider ?? provider,
getTools: () => tools,
maxSteps: agentConfig?.researchAgentMaxSteps,
timeoutMs: agentConfig?.researchAgentTimeoutMs,
+ enabledTypes: agentConfig?.subagentTypesEnabled,
+ maxConcurrent: agentConfig?.maxConcurrentSubagents,
+ workspace: this.deps.workspace,
});
} else {
- setResearchAgentRuntime(undefined);
+ setSubagentRuntime(undefined);
}
if (auditMode) {
@@ -1653,10 +1656,11 @@ function describeToolActivity(
case 'apply_patch':
return { kind: 'apply', liveLabel: 'Applying patch', message: `Patching ${path ?? 'file'}`, detail: path };
case 'spawn_research_agent':
+ case 'spawn_subagent':
return {
kind: 'tool',
liveLabel: 'Starting subagent',
- message: 'Starting research subagent',
+ message: name === 'spawn_subagent' ? `Starting ${String(input.type ?? 'typed')} subagent` : 'Starting research subagent',
detail: typeof input.task === 'string' ? input.task.slice(0, 180) : undefined,
};
case 'retrieve_context':
diff --git a/src/core/runtime/SubagentTracker.ts b/src/core/runtime/SubagentTracker.ts
index 6e37ecc5..37a99394 100644
--- a/src/core/runtime/SubagentTracker.ts
+++ b/src/core/runtime/SubagentTracker.ts
@@ -4,8 +4,11 @@ export type SubagentStatus = 'queued' | 'running' | 'done' | 'error';
export interface SubagentRun {
id: string;
+ type: string;
task: string;
focus?: string;
+ scope?: string;
+ progress?: number;
status: SubagentStatus;
startedAt: number;
finishedAt?: number;
@@ -28,11 +31,14 @@ export class SubagentTracker {
this.notify();
}
- start(task: string, focus?: string): string {
+ start(task: string, focus?: string, metadata: { type?: string; scope?: string; progress?: number } = {}): string {
const run: SubagentRun = {
id: randomUUID(),
+ type: metadata.type ?? 'research',
task,
focus,
+ scope: metadata.scope,
+ progress: metadata.progress,
status: 'running',
startedAt: Date.now(),
};
@@ -41,10 +47,10 @@ export class SubagentTracker {
return run.id;
}
- finish(id: string, summary: string): void {
+ finish(id: string, summary: string, metadata: { progress?: number } = {}): void {
this.runs = this.runs.map((r) =>
r.id === id
- ? { ...r, status: 'done' as const, finishedAt: Date.now(), summary: summary.slice(0, 300) }
+ ? { ...r, status: 'done' as const, finishedAt: Date.now(), progress: metadata.progress ?? r.progress, summary: summary.slice(0, 300) }
: r
);
this.notify();
diff --git a/src/core/runtime/askMode.ts b/src/core/runtime/askMode.ts
index 960aebfd..c7a2beee 100644
--- a/src/core/runtime/askMode.ts
+++ b/src/core/runtime/askMode.ts
@@ -20,6 +20,7 @@ export const ASK_ALLOWED_TOOLS = new Set([
'fetch_web',
'ask_question',
'spawn_research_agent',
+ 'spawn_subagent',
'project_catalog',
'analyze_change_impact',
]);
@@ -36,6 +37,7 @@ const GROUNDING_TOOLS = new Set([
'diagnostics',
'execute_workspace_script',
'spawn_research_agent',
+ 'spawn_subagent',
'project_catalog',
'analyze_change_impact',
]);
diff --git a/src/core/safety/ToolPolicyEngine.ts b/src/core/safety/ToolPolicyEngine.ts
index b464629d..be5a9f83 100644
--- a/src/core/safety/ToolPolicyEngine.ts
+++ b/src/core/safety/ToolPolicyEngine.ts
@@ -16,7 +16,7 @@ const DANGEROUS_COMMANDS = [
const READ_ONLY_TOOLS = new Set([
'read_file', 'read_files', 'list_files', 'search', 'search_batch', 'repo_map',
- 'retrieve_context', 'git_diff', 'diagnostics', 'memory_search', 'spawn_research_agent',
+ 'retrieve_context', 'git_diff', 'diagnostics', 'memory_search', 'spawn_research_agent', 'spawn_subagent',
'save_task_state', 'search_script_catalog', 'execute_workspace_script', 'use_skill',
'fetch_web', 'ask_question', 'mark_step_complete', 'propose_plan_mutation',
]);
diff --git a/src/core/subagents/BaseSubagent.ts b/src/core/subagents/BaseSubagent.ts
new file mode 100644
index 00000000..0222fe8d
--- /dev/null
+++ b/src/core/subagents/BaseSubagent.ts
@@ -0,0 +1,113 @@
+import { relative } from 'path';
+import { AgentLoop } from '../runtime/AgentLoop';
+import type { ChatMessage, LlmProvider } from '../llm/types';
+import type { ToolDefinition } from '../llm/toolTypes';
+import type { ToolExecutor, ToolExecutionResult, ToolExecuteContext } from '../safety/ToolExecutor';
+import type { SubagentDefinition, SubagentRunInput } from './types';
+
+export class BaseSubagent {
+ constructor(private readonly definition: SubagentDefinition, private readonly toolExecutor: ToolExecutor) {}
+
+ async run(provider: LlmProvider, input: SubagentRunInput, allTools: ToolDefinition[]): Promise {
+ if (this.definition.requiresScope && !input.scopeRoot && (!input.targetFiles || input.targetFiles.length === 0)) {
+ return `${this.definition.displayName} subagent refused: explicit targetFiles or scopeRoot is required.`;
+ }
+
+ const tools = this.filterTools(allTools);
+ const executor = this.definition.writable
+ ? new ScopedSubagentExecutor(this.toolExecutor, input.scopeRoot, input.targetFiles)
+ : new ReadOnlySubagentExecutor(this.toolExecutor, new Set(this.definition.allowedTools));
+ const loop = new AgentLoop(executor as unknown as ToolExecutor, this.definition.maxSteps);
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), this.definition.timeoutMs);
+ input.signal?.addEventListener('abort', () => controller.abort(), { once: true });
+
+ const messages: ChatMessage[] = [
+ { role: 'system', content: buildSystemPrompt(this.definition, input.personaInstructions) },
+ { role: 'user', content: buildUserPrompt(input) },
+ ];
+
+ try {
+ const result = await loop.runToCompletion(provider, messages, tools, controller.signal, undefined, false, {
+ maxSteps: this.definition.maxSteps,
+ });
+ return result.fullContent || '(no subagent report)';
+ } finally {
+ clearTimeout(timer);
+ }
+ }
+
+ private filterTools(allTools: ToolDefinition[]): ToolDefinition[] {
+ const allowed = new Set(this.definition.allowedTools);
+ const denied = new Set(this.definition.deniedTools ?? []);
+ return allTools.filter((tool) => {
+ const name = tool.function.name;
+ return allowed.has(name) && !denied.has(name) && !name.startsWith('mcp__');
+ });
+ }
+}
+
+class ReadOnlySubagentExecutor {
+ constructor(private readonly inner: ToolExecutor, private readonly allowed: Set) {}
+
+ clearPlanPhaseLock(): void {
+ this.inner.clearPlanPhaseLock?.();
+ }
+
+ execute(toolName: string, input: Record, context?: ToolExecuteContext): Promise {
+ if (!this.allowed.has(toolName)) {
+ return Promise.resolve({ success: false, output: '', error: `Tool ${toolName} is not allowed for this subagent` });
+ }
+ return this.inner.execute(toolName, input, { ...context, restrictRunCommandToReadOnly: true });
+ }
+}
+
+class ScopedSubagentExecutor {
+ constructor(
+ private readonly inner: ToolExecutor,
+ private readonly scopeRoot?: string,
+ private readonly targetFiles?: string[]
+ ) {}
+
+ clearPlanPhaseLock(): void {
+ this.inner.clearPlanPhaseLock?.();
+ }
+
+ execute(toolName: string, input: Record, context?: ToolExecuteContext): Promise {
+ if ((toolName === 'write_file' || toolName === 'apply_patch') && !this.isPathInScope(input.path)) {
+ return Promise.resolve({
+ success: false,
+ output: '',
+ error: `Write blocked: ${String(input.path ?? '')} is outside the subagent scope`,
+ });
+ }
+ return this.inner.execute(toolName, input, context);
+ }
+
+ private isPathInScope(path: unknown): boolean {
+ if (typeof path !== 'string') return false;
+ const normalized = path.replace(/\\/g, '/').replace(/^\.?\//, '');
+ if (this.targetFiles?.some((target) => normalized === target.replace(/\\/g, '/').replace(/^\.?\//, ''))) {
+ return true;
+ }
+ if (!this.scopeRoot) return false;
+ const rel = relative(this.scopeRoot, normalized).replace(/\\/g, '/');
+ return rel === '' || (!rel.startsWith('..') && !rel.startsWith('/'));
+ }
+}
+
+function buildSystemPrompt(definition: SubagentDefinition, personaInstructions?: string): string {
+ const persona = personaInstructions?.trim()
+ ? `\n\nAdditional workspace/persona instructions:\n${personaInstructions.trim().slice(0, 1600)}`
+ : '';
+ return `${definition.systemPrompt}${persona}`;
+}
+
+function buildUserPrompt(input: SubagentRunInput): string {
+ const parts = [`## Task\n${input.task}`];
+ if (input.focus) parts.push(`## Focus\n${input.focus}`);
+ if (input.scopeRoot) parts.push(`## Scope root\n${input.scopeRoot}`);
+ if (input.targetFiles?.length) parts.push(`## Target files\n${input.targetFiles.join('\n')}`);
+ if (input.commands?.length) parts.push(`## Commands\n${input.commands.join('\n')}`);
+ return parts.join('\n\n');
+}
diff --git a/src/core/subagents/SubagentDefinition.ts b/src/core/subagents/SubagentDefinition.ts
new file mode 100644
index 00000000..4f50b3f9
--- /dev/null
+++ b/src/core/subagents/SubagentDefinition.ts
@@ -0,0 +1,77 @@
+import type { SubagentDefinition } from './types';
+
+const READ_TOOLS = [
+ 'read_file',
+ 'read_files',
+ 'list_files',
+ 'search',
+ 'search_batch',
+ 'repo_map',
+ 'retrieve_context',
+ 'git_diff',
+ 'diagnostics',
+ 'memory_search',
+ 'run_command',
+];
+
+export const BUILTIN_SUBAGENTS: SubagentDefinition[] = [
+ {
+ id: 'research',
+ displayName: 'Research',
+ allowedTools: READ_TOOLS,
+ deniedTools: ['write_file', 'apply_patch', 'memory_write', 'spawn_subagent', 'spawn_research_agent'],
+ writable: false,
+ risk: 'low',
+ maxSteps: 6,
+ timeoutMs: 90_000,
+ systemPrompt: `You are a read-only research subagent. Investigate ONLY the assigned task.
+Use batched reads/searches when possible. Complete quickly and return a concise report with findings, file paths, and confidence.
+Do NOT edit files or explore unrelated areas.`,
+ },
+ {
+ id: 'implementer',
+ displayName: 'Implementer',
+ allowedTools: [
+ ...READ_TOOLS,
+ 'write_file',
+ 'apply_patch',
+ 'execute_workspace_script',
+ 'search_script_catalog',
+ ],
+ deniedTools: ['spawn_subagent', 'spawn_research_agent', 'memory_write'],
+ writable: true,
+ risk: 'high',
+ maxSteps: 8,
+ timeoutMs: 120_000,
+ requiresScope: true,
+ systemPrompt: `You are a scoped implementation subagent.
+Implement ONLY the assigned scope. Do not refactor unrelated code. Before writing, confirm the target files or scope root.
+Run diagnostics or targeted verification after edits. Return summary, files changed, and verification output.`,
+ },
+ {
+ id: 'reviewer',
+ displayName: 'Reviewer',
+ allowedTools: [...READ_TOOLS, 'analyze_change_impact'],
+ deniedTools: ['write_file', 'apply_patch', 'memory_write', 'spawn_subagent', 'spawn_research_agent'],
+ writable: false,
+ risk: 'low',
+ maxSteps: 8,
+ timeoutMs: 120_000,
+ systemPrompt: `You are a read-only reviewer subagent.
+Review the requested task or diff for bugs, regressions, missing tests, and maintainability risks.
+Return structured sections: Critical, Major, Minor, Suggestions. Include file paths and evidence.`,
+ },
+ {
+ id: 'verifier',
+ displayName: 'Verifier',
+ allowedTools: ['run_command', 'read_file', 'read_files', 'list_files', 'search', 'diagnostics', 'execute_workspace_script'],
+ deniedTools: ['write_file', 'apply_patch', 'memory_write', 'spawn_subagent', 'spawn_research_agent'],
+ writable: false,
+ risk: 'medium',
+ maxSteps: 6,
+ timeoutMs: 180_000,
+ systemPrompt: `You are a verification subagent.
+Run the requested test, lint, typecheck, build, or diagnostic commands. Interpret failures without making edits.
+Return pass/fail, key output excerpts, likely cause, and suggested fix surface in under 500 words.`,
+ },
+];
diff --git a/src/core/subagents/SubagentRegistry.ts b/src/core/subagents/SubagentRegistry.ts
new file mode 100644
index 00000000..ea62b5c4
--- /dev/null
+++ b/src/core/subagents/SubagentRegistry.ts
@@ -0,0 +1,36 @@
+import { BUILTIN_SUBAGENTS } from './SubagentDefinition';
+import type { SubagentDefinition, SubagentType } from './types';
+
+export class SubagentRegistry {
+ private readonly definitions = new Map();
+
+ constructor(definitions: SubagentDefinition[] = BUILTIN_SUBAGENTS) {
+ for (const definition of definitions) {
+ this.register(definition);
+ }
+ }
+
+ register(definition: SubagentDefinition): void {
+ this.definitions.set(definition.id, definition);
+ }
+
+ get(type: SubagentType): SubagentDefinition | undefined {
+ return this.definitions.get(type);
+ }
+
+ list(): SubagentDefinition[] {
+ return [...this.definitions.values()];
+ }
+
+ merge(definitions: SubagentDefinition[]): void {
+ for (const definition of definitions) {
+ this.register(definition);
+ }
+ }
+}
+
+export function createDefaultSubagentRegistry(extra: SubagentDefinition[] = []): SubagentRegistry {
+ const registry = new SubagentRegistry();
+ registry.merge(extra);
+ return registry;
+}
diff --git a/src/core/subagents/index.ts b/src/core/subagents/index.ts
new file mode 100644
index 00000000..f8edc9ad
--- /dev/null
+++ b/src/core/subagents/index.ts
@@ -0,0 +1,5 @@
+export { BaseSubagent } from './BaseSubagent';
+export { BUILTIN_SUBAGENTS } from './SubagentDefinition';
+export { SubagentRegistry, createDefaultSubagentRegistry } from './SubagentRegistry';
+export { loadWorkspaceAgents } from './loadWorkspaceAgents';
+export type { SubagentDefinition, SubagentRunInput, SubagentRuntime, SubagentType } from './types';
diff --git a/src/core/subagents/loadWorkspaceAgents.ts b/src/core/subagents/loadWorkspaceAgents.ts
new file mode 100644
index 00000000..cb9c3d8e
--- /dev/null
+++ b/src/core/subagents/loadWorkspaceAgents.ts
@@ -0,0 +1,90 @@
+import { existsSync, readFileSync, readdirSync } from 'fs';
+import { extname, join } from 'path';
+import { z } from 'zod';
+import type { SubagentDefinition } from './types';
+
+const AgentSchema = z.object({
+ id: z.string().min(1),
+ type: z.string().optional(),
+ displayName: z.string().optional(),
+ tools: z.array(z.string()).optional(),
+ allowedTools: z.array(z.string()).optional(),
+ deniedTools: z.array(z.string()).optional(),
+ maxSteps: z.number().int().min(1).max(50).optional(),
+ timeoutMs: z.number().int().min(10_000).max(600_000).optional(),
+ writable: z.boolean().optional(),
+ risk: z.enum(['low', 'medium', 'high']).optional(),
+ requiresScope: z.boolean().optional(),
+ systemPrompt: z.string().optional(),
+});
+
+export interface WorkspaceAgentLoadResult {
+ agents: SubagentDefinition[];
+ warnings: string[];
+}
+
+export function loadWorkspaceAgents(workspace: string): WorkspaceAgentLoadResult {
+ const dir = join(workspace, '.mitii', 'agents');
+ if (!existsSync(dir)) return { agents: [], warnings: [] };
+ const agents: SubagentDefinition[] = [];
+ const warnings: string[] = [];
+ for (const file of readdirSync(dir).filter((name) => /\.(md|json|ya?ml)$/i.test(name))) {
+ const path = join(dir, file);
+ try {
+ const raw = readFileSync(path, 'utf-8');
+ const parsed = extname(file) === '.json' ? JSON.parse(raw) : parseMarkdownAgent(raw);
+ const validated = AgentSchema.parse(parsed);
+ const prompt = validated.systemPrompt ?? parsed.body ?? '';
+ if (!prompt.trim()) throw new Error('Agent prompt/body is required');
+ agents.push({
+ id: validated.id,
+ displayName: validated.displayName ?? titleize(validated.id),
+ allowedTools: validated.allowedTools ?? validated.tools ?? ['read_file', 'read_files', 'search', 'search_batch', 'git_diff', 'diagnostics'],
+ deniedTools: validated.deniedTools,
+ systemPrompt: prompt,
+ maxSteps: validated.maxSteps ?? 8,
+ timeoutMs: validated.timeoutMs ?? 120_000,
+ writable: validated.writable ?? false,
+ risk: validated.risk ?? (validated.writable ? 'high' : 'low'),
+ requiresScope: validated.requiresScope ?? validated.writable ?? false,
+ });
+ } catch (error) {
+ warnings.push(`${file}: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+ return { agents, warnings };
+}
+
+function parseMarkdownAgent(raw: string): Record & { body?: string } {
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
+ if (!match) {
+ return { id: 'custom-agent', body: raw };
+ }
+ return { ...parseYamlLite(match[1] ?? ''), body: match[2] ?? '' };
+}
+
+function parseYamlLite(raw: string): Record {
+ const out: Record = {};
+ for (const line of raw.split(/\r?\n/)) {
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
+ if (!match) continue;
+ const [, key, value] = match;
+ out[key] = parseYamlValue(value);
+ }
+ return out;
+}
+
+function parseYamlValue(value: string): unknown {
+ const trimmed = value.trim();
+ if (trimmed === 'true') return true;
+ if (trimmed === 'false') return false;
+ if (/^\d+$/.test(trimmed)) return Number(trimmed);
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
+ return trimmed.slice(1, -1).split(',').map((item) => item.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
+ }
+ return trimmed.replace(/^['"]|['"]$/g, '');
+}
+
+function titleize(id: string): string {
+ return id.split(/[-_]/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' ');
+}
diff --git a/src/core/subagents/types.ts b/src/core/subagents/types.ts
new file mode 100644
index 00000000..6c99b65b
--- /dev/null
+++ b/src/core/subagents/types.ts
@@ -0,0 +1,40 @@
+import type { LlmProvider } from '../llm/types';
+import type { ToolDefinition } from '../llm/toolTypes';
+import type { ToolExecutor } from '../safety/ToolExecutor';
+
+export type SubagentType = 'research' | 'implementer' | 'reviewer' | 'verifier' | string;
+export type SubagentRisk = 'low' | 'medium' | 'high';
+
+export interface SubagentDefinition {
+ id: SubagentType;
+ displayName: string;
+ allowedTools: string[];
+ deniedTools?: string[];
+ systemPrompt: string;
+ maxSteps: number;
+ timeoutMs: number;
+ writable: boolean;
+ risk: SubagentRisk;
+ requiresScope?: boolean;
+}
+
+export interface SubagentRunInput {
+ task: string;
+ focus?: string;
+ targetFiles?: string[];
+ scopeRoot?: string;
+ commands?: string[];
+ personaInstructions?: string;
+ signal?: AbortSignal;
+}
+
+export interface SubagentRuntime {
+ toolExecutor: ToolExecutor;
+ getProvider: () => LlmProvider | undefined;
+ getTools: () => ToolDefinition[];
+ maxSteps?: number;
+ timeoutMs?: number;
+ enabledTypes?: string[];
+ maxConcurrent?: number;
+ workspace?: string;
+}
diff --git a/src/core/task/ParallelAgentRunner.ts b/src/core/task/ParallelAgentRunner.ts
new file mode 100644
index 00000000..008c7ea0
--- /dev/null
+++ b/src/core/task/ParallelAgentRunner.ts
@@ -0,0 +1,83 @@
+import { HeadlessAgentHost } from '../headless/HeadlessAgentHost';
+import { WorktreeService } from '../git';
+import { TaskBoardService } from './TaskBoardService';
+import type { MitiiTask } from './types';
+
+export interface ParallelAgentRunnerOptions {
+ workspace: string;
+ parallel?: number;
+ runtime?: 'real' | 'stub';
+ providerType?: ConstructorParameters[0]['providerType'];
+ baseUrl?: string;
+ model?: string;
+ apiKey?: string;
+}
+
+export interface ParallelAgentRunResult {
+ started: string[];
+ completed: string[];
+ failed: Array<{ id: string; error: string }>;
+}
+
+export class ParallelAgentRunner {
+ private readonly board: TaskBoardService;
+ private readonly worktrees: WorktreeService;
+
+ constructor(private readonly options: ParallelAgentRunnerOptions) {
+ this.board = new TaskBoardService(options.workspace);
+ this.worktrees = new WorktreeService(options.workspace);
+ }
+
+ async runRunnable(): Promise {
+ const queue = [...this.board.runnable()];
+ const parallel = Math.max(1, Math.min(this.options.parallel ?? 2, 8));
+ const result: ParallelAgentRunResult = { started: [], completed: [], failed: [] };
+ const workers = Array.from({ length: Math.min(parallel, queue.length) }, async () => {
+ while (queue.length) {
+ const task = queue.shift();
+ if (!task) return;
+ result.started.push(task.id);
+ try {
+ await this.runTask(task);
+ result.completed.push(task.id);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ this.board.update(task.id, { status: 'failed', error: message });
+ result.failed.push({ id: task.id, error: message });
+ }
+ }
+ });
+ await Promise.all(workers);
+ return result;
+ }
+
+ async runTask(task: MitiiTask): Promise {
+ this.board.transition(task.id, 'running');
+ const worktree = await this.worktrees.create({ taskId: task.id });
+ this.board.update(task.id, { worktreeId: worktree.taskId, branch: worktree.branch });
+ const host = new HeadlessAgentHost({
+ cwd: worktree.path,
+ runtime: this.options.runtime,
+ providerType: this.options.providerType,
+ baseUrl: this.options.baseUrl,
+ model: this.options.model,
+ apiKey: this.options.apiKey,
+ approval: 'auto',
+ indexWorkspace: false,
+ });
+ const chunks: string[] = [];
+ try {
+ for await (const event of host.agent(task.prompt)) {
+ if (event.type === 'assistant_delta') chunks.push(event.content);
+ if (event.type === 'error') throw new Error(event.message);
+ }
+ const summary = chunks.join('').trim() || 'Task completed.';
+ this.board.update(task.id, {
+ status: 'review',
+ result: { summary, filesChanged: [] },
+ });
+ } finally {
+ host.dispose();
+ }
+ }
+}
diff --git a/src/core/task/TaskBoardService.ts b/src/core/task/TaskBoardService.ts
new file mode 100644
index 00000000..2f2f3a53
--- /dev/null
+++ b/src/core/task/TaskBoardService.ts
@@ -0,0 +1,97 @@
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
+import { dirname, join } from 'path';
+import { randomUUID } from 'crypto';
+import type { MitiiTask, MitiiTaskStatus } from './types';
+
+export class TaskBoardService {
+ private readonly boardPath: string;
+
+ constructor(workspace: string) {
+ this.boardPath = join(workspace, '.mitii', 'tasks', 'board.json');
+ }
+
+ list(): MitiiTask[] {
+ return this.readBoard().tasks;
+ }
+
+ add(input: { title: string; prompt: string; dependsOn?: string[] }): MitiiTask {
+ const now = Date.now();
+ const task: MitiiTask = {
+ id: randomUUID().slice(0, 8),
+ title: input.title,
+ prompt: input.prompt,
+ status: 'backlog',
+ dependsOn: input.dependsOn ?? [],
+ createdAt: now,
+ updatedAt: now,
+ };
+ const board = this.readBoard();
+ this.writeBoard({ tasks: [...board.tasks, task] });
+ return task;
+ }
+
+ update(id: string, patch: Partial): MitiiTask {
+ const board = this.readBoard();
+ const current = board.tasks.find((task) => task.id === id);
+ if (!current) throw new Error(`Task not found: ${id}`);
+ const next = { ...current, ...patch, updatedAt: Date.now() };
+ const tasks = board.tasks.map((task) => task.id === id ? next : task);
+ assertNoCycles(tasks);
+ this.writeBoard({ tasks });
+ return next;
+ }
+
+ transition(id: string, status: MitiiTaskStatus): MitiiTask {
+ if (status === 'running') {
+ const task = this.list().find((item) => item.id === id);
+ const blockedBy = (task?.dependsOn ?? []).filter((dep) => this.list().find((item) => item.id === dep)?.status !== 'done');
+ if (blockedBy.length) throw new Error(`Task ${id} is blocked by: ${blockedBy.join(', ')}`);
+ }
+ return this.update(id, { status });
+ }
+
+ remove(id: string): boolean {
+ const board = this.readBoard();
+ const tasks = board.tasks.filter((task) => task.id !== id);
+ if (tasks.length === board.tasks.length) return false;
+ this.writeBoard({ tasks });
+ return true;
+ }
+
+ runnable(): MitiiTask[] {
+ const tasks = this.list();
+ return tasks.filter((task) =>
+ task.status === 'backlog' &&
+ (task.dependsOn ?? []).every((dep) => tasks.find((candidate) => candidate.id === dep)?.status === 'done')
+ );
+ }
+
+ private readBoard(): { tasks: MitiiTask[] } {
+ if (!existsSync(this.boardPath)) return { tasks: [] };
+ const parsed = JSON.parse(readFileSync(this.boardPath, 'utf-8')) as { tasks?: MitiiTask[] };
+ return { tasks: Array.isArray(parsed.tasks) ? parsed.tasks : [] };
+ }
+
+ private writeBoard(board: { tasks: MitiiTask[] }): void {
+ assertNoCycles(board.tasks);
+ mkdirSync(dirname(this.boardPath), { recursive: true });
+ writeFileSync(this.boardPath, `${JSON.stringify(board, null, 2)}\n`, 'utf-8');
+ }
+}
+
+function assertNoCycles(tasks: MitiiTask[]): void {
+ const byId = new Map(tasks.map((task) => [task.id, task]));
+ const visiting = new Set();
+ const visited = new Set();
+ const visit = (id: string) => {
+ if (visited.has(id)) return;
+ if (visiting.has(id)) throw new Error(`Circular task dependency at ${id}`);
+ visiting.add(id);
+ for (const dep of byId.get(id)?.dependsOn ?? []) {
+ if (byId.has(dep)) visit(dep);
+ }
+ visiting.delete(id);
+ visited.add(id);
+ };
+ for (const task of tasks) visit(task.id);
+}
diff --git a/src/core/task/index.ts b/src/core/task/index.ts
index 4b95aec8..615d1920 100644
--- a/src/core/task/index.ts
+++ b/src/core/task/index.ts
@@ -1,2 +1,4 @@
export * from './types';
export * from './enrichTask';
+export { TaskBoardService } from './TaskBoardService';
+export { ParallelAgentRunner } from './ParallelAgentRunner';
diff --git a/src/core/task/types.ts b/src/core/task/types.ts
index 248b7915..f21e7556 100644
--- a/src/core/task/types.ts
+++ b/src/core/task/types.ts
@@ -16,3 +16,19 @@ export interface EnrichedTask {
contextBlocks: string[];
signals: TaskSignals;
}
+export type MitiiTaskStatus = 'backlog' | 'running' | 'review' | 'done' | 'failed' | 'cancelled';
+
+export interface MitiiTask {
+ id: string;
+ title: string;
+ prompt: string;
+ status: MitiiTaskStatus;
+ worktreeId?: string;
+ sessionId?: string;
+ branch?: string;
+ dependsOn?: string[];
+ createdAt: number;
+ updatedAt: number;
+ result?: { summary: string; filesChanged: string[] };
+ error?: string;
+}
diff --git a/src/core/tools/builtinTools.ts b/src/core/tools/builtinTools.ts
index e94c188d..b8db6947 100644
--- a/src/core/tools/builtinTools.ts
+++ b/src/core/tools/builtinTools.ts
@@ -19,12 +19,9 @@ import { validateMdxContent } from '../apply/mdxValidation';
import { isDangerousCommand } from '../safety/ToolPolicyEngine';
import { isReadOnlyCommand, stripLeadingCd } from '../plans/PlanActEngine';
import { normalizeWorkspaceRoot, resolveWorkspaceRelPath, formatPathNotFoundHint } from '../util/paths';
-import { ResearchAgent } from '../runtime/ResearchAgent';
+import { BaseSubagent, createDefaultSubagentRegistry, loadWorkspaceAgents, type SubagentRuntime } from '../subagents';
import { isAuditSubagentBlocked, buildScriptFirstAuditMessage } from '../runtime/auditRouting';
import type { SubagentTracker } from '../runtime/SubagentTracker';
-import type { LlmProvider } from '../llm/types';
-import type { ToolDefinition } from '../llm/toolTypes';
-import type { ToolExecutor } from '../safety/ToolExecutor';
import type { SkillCatalogService } from '../skills/SkillCatalogService';
import { createLogger } from '../telemetry/Logger';
import { analyzeChangeImpact, discoverProjectCatalog, formatProjectCatalog, saveProjectCatalog } from '../modes/ask';
@@ -34,27 +31,22 @@ const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
const log = createLogger('BuiltinTools');
-export interface ResearchAgentRuntime {
- toolExecutor: ToolExecutor;
- getProvider: () => LlmProvider | undefined;
- getTools: () => ToolDefinition[];
- maxSteps?: number;
- timeoutMs?: number;
-}
+export type ResearchAgentRuntime = SubagentRuntime;
-let researchAgentRuntime: ResearchAgentRuntime | undefined;
-let researchAgent: ResearchAgent | undefined;
+let subagentRuntime: SubagentRuntime | undefined;
let subagentTracker: SubagentTracker | undefined;
+let activeSubagents = 0;
export function setSubagentTracker(tracker: SubagentTracker | undefined): void {
subagentTracker = tracker;
}
export function setResearchAgentRuntime(runtime: ResearchAgentRuntime | undefined): void {
- researchAgentRuntime = runtime;
- researchAgent = runtime
- ? new ResearchAgent(runtime.toolExecutor, runtime.maxSteps ?? 6, runtime.timeoutMs ?? 90_000)
- : undefined;
+ setSubagentRuntime(runtime);
+}
+
+export function setSubagentRuntime(runtime: SubagentRuntime | undefined): void {
+ subagentRuntime = runtime;
}
function blockedPath(relPath: string, ignoreService: IgnoreService, forRead = false): boolean {
@@ -991,64 +983,145 @@ export function createSpawnResearchAgentTool(): Tool<{
persona_instructions: z.string().optional(),
}),
async execute(input): Promise {
- const combinedTask = [input.task, input.focus, input.persona_instructions].filter(Boolean).join('\n');
- if (isAuditSubagentBlocked(combinedTask)) {
- log.warn('Blocked audit subagent', { task: input.task.slice(0, 120) });
- return { success: true, output: buildScriptFirstAuditMessage(input.task) };
- }
-
- if (!researchAgentRuntime || !researchAgent) {
- return { success: false, output: '', error: 'Research agent not configured' };
- }
+ return runSubagentTool({
+ type: 'research',
+ task: input.task,
+ focus: input.focus,
+ targetFiles: input.targetFiles,
+ chunkSize: input.chunkSize,
+ personaInstructions: input.persona_instructions,
+ });
+ },
+ };
+}
- const provider = researchAgentRuntime.getProvider();
- if (!provider) {
- return { success: false, output: '', error: 'No LLM provider available' };
- }
- const runId = subagentTracker?.start(input.task, input.focus);
- try {
- const targetFiles = input.targetFiles ?? [];
- let report: string;
- if (targetFiles.length > 10) {
- const chunkSize = input.chunkSize ?? 8;
- const chunks = chunkArray(targetFiles, chunkSize);
- const reports = await Promise.all(
- chunks.map((chunk, index) =>
- researchAgent!.run(
- provider,
- `${input.task}\n\nTarget file chunk ${index + 1}/${chunks.length}:\n${chunk.join('\n')}`,
- input.focus,
- researchAgentRuntime!.getTools(),
- undefined,
- input.persona_instructions
- )
- )
- );
- report = reports.map((r, i) => `## Chunk ${i + 1}\n${r}`).join('\n\n');
- } else {
- const task = targetFiles.length
- ? `${input.task}\n\nTarget files:\n${targetFiles.join('\n')}`
- : input.task;
- report = await researchAgent.run(
- provider,
- task,
- input.focus,
- researchAgentRuntime.getTools(),
- undefined,
- input.persona_instructions
- );
- }
- if (runId) subagentTracker?.finish(runId, report);
- return { success: true, output: report };
- } catch (e) {
- const err = String(e);
- if (runId) subagentTracker?.fail(runId, err);
- return { success: false, output: '', error: err };
- }
+export function createSpawnSubagentTool(): Tool<{
+ type: string;
+ task: string;
+ focus?: string;
+ targetFiles?: string[];
+ scopeRoot?: string;
+ commands?: string[];
+ chunkSize?: number;
+ persona_instructions?: string;
+}> {
+ return {
+ name: 'spawn_subagent',
+ description:
+ 'Delegate scoped work to a typed subagent: research, implementer, reviewer, verifier, or a workspace custom agent from .mitii/agents. Implementer requires targetFiles or scopeRoot.',
+ risk: 'medium',
+ inputSchema: z.object({
+ type: z.string(),
+ task: z.string(),
+ focus: z.string().optional(),
+ targetFiles: z.array(z.string()).optional(),
+ scopeRoot: z.string().optional(),
+ commands: z.array(z.string()).optional(),
+ chunkSize: z.number().int().min(1).max(10).optional(),
+ persona_instructions: z.string().optional(),
+ }),
+ async execute(input): Promise {
+ return runSubagentTool({
+ type: input.type,
+ task: input.task,
+ focus: input.focus,
+ targetFiles: input.targetFiles,
+ scopeRoot: input.scopeRoot,
+ commands: input.commands,
+ chunkSize: input.chunkSize,
+ personaInstructions: input.persona_instructions,
+ });
},
};
}
+async function runSubagentTool(input: {
+ type: string;
+ task: string;
+ focus?: string;
+ targetFiles?: string[];
+ scopeRoot?: string;
+ commands?: string[];
+ chunkSize?: number;
+ personaInstructions?: string;
+}): Promise {
+ const combinedTask = [input.task, input.focus, input.personaInstructions].filter(Boolean).join('\n');
+ if ((input.type === 'research' || input.type === 'reviewer') && isAuditSubagentBlocked(combinedTask)) {
+ log.warn('Blocked audit subagent', { task: input.task.slice(0, 120), type: input.type });
+ return { success: true, output: buildScriptFirstAuditMessage(input.task) };
+ }
+
+ if (!subagentRuntime) {
+ return { success: false, output: '', error: 'Subagent runtime not configured' };
+ }
+ const enabled = new Set(subagentRuntime.enabledTypes ?? ['research']);
+ if (!enabled.has(input.type)) {
+ return { success: false, output: '', error: `Subagent type ${input.type} is disabled by policy` };
+ }
+ const maxConcurrent = Math.max(1, subagentRuntime.maxConcurrent ?? 2);
+ if (activeSubagents >= maxConcurrent) {
+ return { success: false, output: '', error: `Subagent concurrency limit reached (${maxConcurrent})` };
+ }
+ const provider = subagentRuntime.getProvider();
+ if (!provider) {
+ return { success: false, output: '', error: 'No LLM provider available' };
+ }
+
+ const registry = createDefaultSubagentRegistry(
+ subagentRuntime.workspace ? loadWorkspaceAgents(subagentRuntime.workspace).agents : []
+ );
+ const definition = registry.get(input.type);
+ if (!definition) {
+ return { success: false, output: '', error: `Unknown subagent type: ${input.type}` };
+ }
+ const effectiveDefinition = {
+ ...definition,
+ maxSteps: input.type === 'research' && subagentRuntime.maxSteps ? subagentRuntime.maxSteps : definition.maxSteps,
+ timeoutMs: input.type === 'research' && subagentRuntime.timeoutMs ? subagentRuntime.timeoutMs : definition.timeoutMs,
+ };
+
+ const runId = subagentTracker?.start(input.task, input.focus, {
+ type: input.type,
+ scope: input.scopeRoot ?? input.targetFiles?.slice(0, 6).join(', '),
+ });
+ activeSubagents += 1;
+ try {
+ const subagent = new BaseSubagent(effectiveDefinition, subagentRuntime.toolExecutor);
+ const targetFiles = input.targetFiles ?? [];
+ let report: string;
+ if (input.type === 'research' && targetFiles.length > 10) {
+ const chunkSize = input.chunkSize ?? 8;
+ const chunks = chunkArray(targetFiles, chunkSize);
+ const reports = await Promise.all(chunks.map((chunk, index) =>
+ subagent.run(provider, {
+ task: `${input.task}\n\nTarget file chunk ${index + 1}/${chunks.length}`,
+ focus: input.focus,
+ targetFiles: chunk,
+ personaInstructions: input.personaInstructions,
+ }, subagentRuntime!.getTools())
+ ));
+ report = reports.map((r, i) => `## Chunk ${i + 1}\n${r}`).join('\n\n');
+ } else {
+ report = await subagent.run(provider, {
+ task: input.task,
+ focus: input.focus,
+ targetFiles,
+ scopeRoot: input.scopeRoot,
+ commands: input.commands,
+ personaInstructions: input.personaInstructions,
+ }, subagentRuntime.getTools());
+ }
+ if (runId) subagentTracker?.finish(runId, report, { progress: 100 });
+ return { success: true, output: report };
+ } catch (e) {
+ const err = String(e);
+ if (runId) subagentTracker?.fail(runId, err);
+ return { success: false, output: '', error: err };
+ } finally {
+ activeSubagents -= 1;
+ }
+}
+
function chunkArray(items: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < items.length; i += size) {
diff --git a/src/core/tools/planTools.ts b/src/core/tools/planTools.ts
index 0b1ede1d..89513aad 100644
--- a/src/core/tools/planTools.ts
+++ b/src/core/tools/planTools.ts
@@ -24,6 +24,7 @@ export const PLANNING_DISCOVERY_TOOLS = new Set([
'run_command',
'execute_workspace_script',
'spawn_research_agent',
+ 'spawn_subagent',
'fetch_web',
'ask_question',
]);
diff --git a/src/node/cli.ts b/src/node/cli.ts
index 335e2a6c..cb66b1f9 100644
--- a/src/node/cli.ts
+++ b/src/node/cli.ts
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
+import { execFile } from 'child_process';
import { createInterface } from 'readline/promises';
import { stdin as input, stdout as output } from 'process';
import { homedir } from 'os';
@@ -8,10 +9,17 @@ import { AuditPackBuilder, verifyAuditPack } from '../core/audit';
import { generateHeadlessChangelog, prepareHeadlessRelease } from '../core/headless';
import type { HeadlessRuntime } from '../core/headless/HeadlessConfig';
import type { ProviderType } from '../core/config/schema';
-import { createClient, query } from '../../packages/sdk/src';
+import { createClient, query, DaemonClient, DaemonSessionClient } from '../../packages/sdk/src';
import type { MitiiMode, MitiiEvent } from '../../packages/sdk/src';
import { connectAgentMemoryMcp } from '../core/mcp/mcpWorkspaceConfig';
import { AutoMemoryFileWriter } from '../core/memory/AutoMemoryFileWriter';
+import { serveCommand } from '../../packages/daemon/src/cli';
+import { startMitiiBoard } from '../../packages/board/src/server';
+import { TaskBoardService, ParallelAgentRunner } from '../core/task';
+import { WorktreeService } from '../core/git';
+import { promisify } from 'util';
+
+const execFileAsync = promisify(execFile);
async function main(argv: string[]): Promise {
const [command, ...args] = argv;
@@ -41,6 +49,22 @@ async function main(argv: string[]): Promise {
return 0;
}
+ if (command === 'serve') {
+ return serveCommand(args, cwd);
+ }
+
+ if (command === 'board') {
+ return boardCommand(cwd, args);
+ }
+
+ if (command === 'task') {
+ return taskCommand(cwd, args, json);
+ }
+
+ if (command === 'agents' && args[0] === 'init') {
+ return initAgentTemplate(cwd, json);
+ }
+
if (command === 'prepare-release') {
const result = await prepareHeadlessRelease(cwd, since);
process.stdout.write(json ? JSON.stringify(result, null, 2) + '\n' : result.releaseNotes);
@@ -109,6 +133,10 @@ async function main(argv: string[]): Promise {
}
async function runOneShot(mode: MitiiMode, cwd: string, args: string[], prompt: string, json: boolean): Promise {
+ const daemonUrl = valueOf(args, '--daemon-url');
+ if (daemonUrl) {
+ return runDaemonOneShot(mode, cwd, args, prompt, json, daemonUrl);
+ }
const clientOptions = clientOptionsFromArgs(cwd, args);
if (mode === 'ask' && !json) {
const client = createClient(clientOptions);
@@ -139,6 +167,26 @@ async function runOneShot(mode: MitiiMode, cwd: string, args: string[], prompt:
return 0;
}
+async function runDaemonOneShot(mode: MitiiMode, cwd: string, args: string[], prompt: string, json: boolean, daemonUrl: string): Promise {
+ const client = new DaemonClient({ baseUrl: daemonUrl, token: valueOf(args, '--daemon-token') ?? process.env.MITII_SERVER_TOKEN });
+ const session = await DaemonSessionClient.createOrAttach(client, {
+ cwd,
+ mode,
+ approval: (valueOf(args, '--approval') as 'auto' | 'manual' | undefined) ?? 'manual',
+ });
+ const events = session.events();
+ await session.prompt({ mode, message: prompt });
+ for await (const event of events) {
+ if (json) {
+ process.stdout.write(JSON.stringify(event) + '\n');
+ } else {
+ renderCliEvent(event);
+ }
+ if (event.type === 'done' || event.type === 'error') break;
+ }
+ return 0;
+}
+
function clientOptionsFromArgs(cwd: string, args: string[]) {
const saved = loadDefaultCredentials();
const provider = (valueOf(args, '--provider') as ProviderType | undefined) ?? saved.provider ?? 'echo';
@@ -296,6 +344,128 @@ function memoryCommand(cwd: string, args: string[], json: boolean): number {
return 2;
}
+async function taskCommand(cwd: string, args: string[], json: boolean): Promise {
+ const sub = args[0];
+ const board = new TaskBoardService(cwd);
+ if (sub === 'add') {
+ const title = positional(args.slice(1))[0] ?? 'Untitled task';
+ const prompt = valueOf(args, '--prompt') ?? title;
+ const dependsOn = (valueOf(args, '--depends-on') ?? '').split(',').map((item) => item.trim()).filter(Boolean);
+ const task = board.add({ title, prompt, dependsOn });
+ process.stdout.write(json ? JSON.stringify(task, null, 2) + '\n' : `Added ${task.id}: ${task.title}\n`);
+ return 0;
+ }
+ if (sub === 'list' || !sub) {
+ const tasks = board.list();
+ process.stdout.write(json ? JSON.stringify({ tasks }, null, 2) + '\n' : formatTasks(tasks));
+ return 0;
+ }
+ if (sub === 'start') {
+ const id = args[1];
+ if (!id) {
+ process.stderr.write('mitii task start requires a task id.\n');
+ return 2;
+ }
+ const task = board.transition(id, 'running');
+ process.stdout.write(json ? JSON.stringify(task, null, 2) + '\n' : `Started ${task.id}\n`);
+ return 0;
+ }
+ if (sub === 'run') {
+ const runner = new ParallelAgentRunner({
+ workspace: cwd,
+ parallel: Number(valueOf(args, '--parallel') ?? 2),
+ ...clientOptionsFromArgs(cwd, args),
+ });
+ const result = await runner.runRunnable();
+ process.stdout.write(json ? JSON.stringify(result, null, 2) + '\n' : `Started ${result.started.length}, completed ${result.completed.length}, failed ${result.failed.length}\n`);
+ return result.failed.length ? 1 : 0;
+ }
+ if (sub === 'worktrees') {
+ const worktrees = new WorktreeService(cwd).list();
+ process.stdout.write(json ? JSON.stringify({ worktrees }, null, 2) + '\n' : worktrees.map((w) => `${w.taskId}\t${w.status}\t${w.branch}\t${w.path}`).join('\n') + '\n');
+ return 0;
+ }
+ if (sub === 'merge') {
+ const id = args[1];
+ if (!id) {
+ process.stderr.write('mitii task merge requires a task id.\n');
+ return 2;
+ }
+ const result = await mergeTask(cwd, id, {
+ squash: args.includes('--squash'),
+ cleanup: args.includes('--merge-and-cleanup'),
+ forceCleanup: args.includes('--force'),
+ });
+ process.stdout.write(json ? JSON.stringify(result, null, 2) + '\n' : `${result.message}\n`);
+ return 0;
+ }
+ process.stderr.write('Usage: mitii task add|list|start|run|worktrees|merge\n');
+ return 2;
+}
+
+async function mergeTask(cwd: string, id: string, options: { squash: boolean; cleanup: boolean; forceCleanup: boolean }): Promise<{ id: string; branch: string; message: string }> {
+ const board = new TaskBoardService(cwd);
+ const task = board.list().find((item) => item.id === id);
+ if (!task) throw new Error(`Task not found: ${id}`);
+ if (task.status !== 'review' && task.status !== 'done') {
+ throw new Error(`Task ${id} must be in review or done status before merge`);
+ }
+ const worktrees = new WorktreeService(cwd);
+ const entry = worktrees.list().find((item) => item.taskId === id);
+ const branch = task.branch ?? entry?.branch;
+ if (!branch) throw new Error(`Task ${id} has no worktree branch`);
+ await execFileAsync('git', ['merge', '--no-commit', '--no-ff', branch], { cwd });
+ if (options.squash) {
+ await execFileAsync('git', ['merge', '--abort'], { cwd }).catch(() => undefined);
+ await execFileAsync('git', ['merge', '--squash', branch], { cwd });
+ }
+ board.update(id, { status: 'done' });
+ if (options.cleanup) {
+ await worktrees.remove(id, { force: options.forceCleanup });
+ }
+ return { id, branch, message: `Merged ${branch}. Review and commit the staged merge result.` };
+}
+
+async function boardCommand(cwd: string, args: string[]): Promise {
+ const hostname = valueOf(args, '--hostname') ?? '127.0.0.1';
+ const port = Number(valueOf(args, '--port') ?? 4311);
+ const board = await startMitiiBoard({ cwd, hostname, port: Number.isFinite(port) ? port : 4311 });
+ process.stderr.write(`Mitii board listening on ${board.url}\n`);
+ const shutdown = async () => {
+ await board.close();
+ process.exit(0);
+ };
+ process.once('SIGINT', () => void shutdown());
+ process.once('SIGTERM', () => void shutdown());
+ await new Promise(() => undefined);
+ return 0;
+}
+
+function initAgentTemplate(cwd: string, json: boolean): number {
+ const target = join(cwd, '.mitii', 'agents', 'security-reviewer.md');
+ mkdirSync(dirname(target), { recursive: true });
+ if (!existsSync(target)) {
+ writeFileSync(target, [
+ '---',
+ 'id: security-reviewer',
+ 'type: reviewer',
+ 'tools: [read_file, read_files, search, search_batch, git_diff, diagnostics, run_command]',
+ 'maxSteps: 10',
+ '---',
+ '',
+ 'You are a security-focused reviewer. Check for injection, auth bypass, secret leaks, unsafe file access, and missing validation.',
+ '',
+ ].join('\n'), 'utf-8');
+ }
+ process.stdout.write(json ? JSON.stringify({ path: target }) + '\n' : `Created ${target}\n`);
+ return 0;
+}
+
+function formatTasks(tasks: import('../core/task').MitiiTask[]): string {
+ if (tasks.length === 0) return 'No tasks.\n';
+ return tasks.map((task) => `${task.id}\t${task.status}\t${task.title}`).join('\n') + '\n';
+}
+
function valueOf(args: string[], name: string): string | undefined {
const idx = args.indexOf(name);
return idx >= 0 ? args[idx + 1] : undefined;
@@ -355,6 +525,11 @@ function printHelp(): void {
' mitii -i "prompt" Start interactive session with an initial prompt',
' mitii init [--local] [--force] [--cwd ] [--json]',
' mitii auth [--provider --base-url --model --apikey ]',
+ ' mitii serve [--hostname 127.0.0.1] [--port 4310] [--token ] [--max-sessions 5]',
+ ' mitii board [--hostname 127.0.0.1] [--port 4311]',
+ ' mitii agents init [--cwd ] [--json]',
+ ' mitii task add "title" --prompt "..." [--depends-on ] [--json]',
+ ' mitii task list|start |run [--parallel 2]|worktrees [--cwd ] [--json]',
' mitii auth list',
' mitii memory status|prune|connect agentmemory [--cwd ] [--json]',
' mitii changelog [--since ] [--cwd ] [--json]',
@@ -364,6 +539,7 @@ function printHelp(): void {
' mitii ask "question" [--runtime real|stub] [--provider echo|openai|...] [--model ] [--base-url ] [--cwd ] [--json]',
' mitii plan "goal" [--runtime real|stub] [--provider echo|openai|...] [--model ] [--approval auto|manual] [--cwd ]',
' mitii agent "goal" [--mode ask|plan|agent|review] [--runtime real|stub] [--provider echo|openai|...] [--model ] [--approval auto|manual] [--enable-puppeteer] [--allow-network] [--vectors] [--json]',
+ ' Add --daemon-url http://127.0.0.1:4310 to ask/plan/agent to attach to mitii serve.',
'',
].join('\n'));
}
diff --git a/src/vscode/webview/messages.ts b/src/vscode/webview/messages.ts
index 867cdfca..0f240029 100644
--- a/src/vscode/webview/messages.ts
+++ b/src/vscode/webview/messages.ts
@@ -151,8 +151,11 @@ export interface AgentLiveStatusView {
export interface SubagentStatusView {
id: string;
+ type?: string;
task: string;
focus?: string;
+ scope?: string;
+ progress?: number;
status: 'queued' | 'running' | 'done' | 'error';
startedAt: number;
finishedAt?: number;
diff --git a/test/daemon.test.ts b/test/daemon.test.ts
new file mode 100644
index 00000000..bebe40a1
--- /dev/null
+++ b/test/daemon.test.ts
@@ -0,0 +1,67 @@
+import { mkdtempSync, rmSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+import { afterEach, describe, expect, it } from 'vitest';
+import { startMitiiDaemon, type MitiiDaemonServerHandle } from '../packages/daemon/src/server';
+import { DaemonClient } from '../packages/sdk/src/daemon';
+
+const handles: MitiiDaemonServerHandle[] = [];
+const dirs: string[] = [];
+
+afterEach(async () => {
+ await Promise.all(handles.splice(0).map((handle) => handle.close()));
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
+});
+
+describe('mitii daemon', () => {
+ it('serves health, capabilities, session CRUD, prompt events, and replay', async () => {
+ const cwd = tempDir();
+ const handle = await startMitiiDaemon({ cwd, port: 0, maxSessions: 2 });
+ handles.push(handle);
+ const address = handle.server.address();
+ const port = typeof address === 'object' && address ? address.port : 0;
+ const client = new DaemonClient({ baseUrl: `http://127.0.0.1:${port}` });
+
+ await expect(client.health()).resolves.toMatchObject({ ok: true, cwd });
+ await expect(client.capabilities()).resolves.toMatchObject({ eventReplay: true });
+
+ const session = await client.createSession({ cwd, mode: 'agent', runtime: 'stub', approval: 'auto' });
+ await client.prompt(session.id, { message: 'hello daemon' });
+
+ const events = [];
+ for await (const event of client.events(session.id)) {
+ events.push(event);
+ if (event.data.type === 'done') break;
+ }
+ expect(events.some((event) => event.data.type === 'assistant_delta')).toBe(true);
+ const replayed = [];
+ for await (const event of client.events(session.id, 0)) {
+ replayed.push(event);
+ if (replayed.length >= events.length) break;
+ }
+ expect(replayed.length).toBeGreaterThan(0);
+ });
+
+ it('requires token for authenticated requests and enforces session limit', async () => {
+ const cwd = tempDir();
+ const handle = await startMitiiDaemon({ cwd, port: 0, token: 'secret', maxSessions: 1 });
+ handles.push(handle);
+ const address = handle.server.address();
+ const port = typeof address === 'object' && address ? address.port : 0;
+
+ await expect(fetch(`http://127.0.0.1:${port}/health`)).resolves.toMatchObject({ status: 401 });
+ const client = new DaemonClient({ baseUrl: `http://127.0.0.1:${port}`, token: 'secret' });
+ await client.createSession({ cwd, runtime: 'stub' });
+ await expect(client.createSession({ cwd, runtime: 'stub' })).rejects.toThrow(/Maximum sessions/);
+ });
+
+ it('rejects non-loopback bind without explicit insecure flag', async () => {
+ await expect(startMitiiDaemon({ cwd: tempDir(), hostname: '0.0.0.0', port: 0 })).rejects.toThrow(/Refusing non-loopback/);
+ });
+});
+
+function tempDir(): string {
+ const dir = mkdtempSync(join(tmpdir(), 'mitii-daemon-'));
+ dirs.push(dir);
+ return dir;
+}
diff --git a/test/parallel-agents.test.ts b/test/parallel-agents.test.ts
new file mode 100644
index 00000000..c1d29639
--- /dev/null
+++ b/test/parallel-agents.test.ts
@@ -0,0 +1,54 @@
+import { existsSync, mkdtempSync, rmSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+import { execFileSync } from 'child_process';
+import { afterEach, describe, expect, it } from 'vitest';
+import { TaskBoardService } from '../src/core/task';
+import { WorktreeService } from '../src/core/git';
+
+const dirs: string[] = [];
+
+afterEach(() => {
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
+});
+
+describe('parallel task foundation', () => {
+ it('persists tasks and blocks dependency start until predecessor is done', () => {
+ const cwd = tempDir();
+ const board = new TaskBoardService(cwd);
+ const first = board.add({ title: 'first', prompt: 'do first' });
+ const second = board.add({ title: 'second', prompt: 'do second', dependsOn: [first.id] });
+
+ expect(board.list()).toHaveLength(2);
+ expect(() => board.transition(second.id, 'running')).toThrow(/blocked/);
+ board.transition(first.id, 'done');
+ expect(board.transition(second.id, 'running').status).toBe('running');
+ });
+
+ it('creates, lists, prunes, and removes git worktrees', async () => {
+ const repo = tempGitRepo();
+ const service = new WorktreeService(repo);
+ const worktree = await service.create({ taskId: 'abc123' });
+
+ expect(existsSync(worktree.path)).toBe(true);
+ expect(service.getPath('abc123')).toBe(worktree.path);
+ expect(service.prune()).toHaveLength(1);
+ await expect(service.remove('abc123', { force: true, deleteBranch: true })).resolves.toBe(true);
+ expect(service.list().find((entry) => entry.taskId === 'abc123')?.status).toBe('removed');
+ });
+});
+
+function tempDir(): string {
+ const dir = mkdtempSync(join(tmpdir(), 'mitii-parallel-'));
+ dirs.push(dir);
+ return dir;
+}
+
+function tempGitRepo(): string {
+ const dir = tempDir();
+ execFileSync('git', ['init'], { cwd: dir });
+ execFileSync('git', ['config', 'user.email', 'mitii@example.test'], { cwd: dir });
+ execFileSync('git', ['config', 'user.name', 'Mitii Test'], { cwd: dir });
+ execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: dir });
+ return dir;
+}
From af4e6dd930766c6a900cc4cbfccbf93caab06103 Mon Sep 17 00:00:00 2001
From: codewithshinde
Date: Sat, 4 Jul 2026 12:26:35 -0500
Subject: [PATCH 03/21] feat: Introduce Team Management and Job Queue Services
- Added TeamService for managing teams, tasks, and mailbox messages.
- Implemented JobQueueService for job management and processing.
- Updated CLI to support team commands (create, status, task, send) and job commands (enqueue, list).
- Refactored existing code to replace 'thunder' references with 'mitii' for consistency.
- Enhanced LlmProviderRegistry to use new API key reference.
- Added migration command for transitioning settings from Thunder to Mitii.
- Introduced inline diff commands for better user experience in VSCode.
- Added tests for GitHub pull request creation, job queue persistence, and team management functionalities.
---
.github/workflows/mitii-agent.yml | 28 +
.github/workflows/native-binaries.yml | 38 +
.github/workflows/npm-publish.yml | 32 +
README.md | 149 ++-
docs/enterprise/PROCUREMENT_FAQ.md | 14 +-
docs/enterprise/README.md | 2 +-
docs/enterprise/SECURITY.md | 6 +-
docs/enterprise/channel-security.md | 15 +
docs/enterprise/managed-policy.md | 27 +
docs/integrations/github-actions.md | 25 +
docs/users/channels/slack.md | 5 +
docs/users/channels/telegram.md | 25 +
docs/users/jetbrains-workflow.md | 9 +
package.json | 966 ++++++++++++++++--
packages/board/package.json | 11 +-
packages/board/src/server.ts | 18 +-
packages/channels/package.json | 14 +
packages/channels/src/base.ts | 92 ++
packages/channels/src/index.ts | 2 +
packages/channels/src/telegram.ts | 79 ++
packages/cli/README.md | 3 +
packages/cli/bin/mitii | 28 +
.../mitii-darwin-arm64/package.json | 15 +
.../mitii-darwin-x64/package.json | 15 +
.../mitii-linux-x64/package.json | 15 +
.../mitii-win32-x64/package.json | 15 +
packages/cli/package.json | 24 +
packages/cli/scripts/build-platform.mjs | 16 +
packages/cli/scripts/publish-npm.mjs | 14 +
packages/daemon/package.json | 9 +-
packages/daemon/src/server.ts | 39 +-
packages/sdk/package.json | 5 +-
pnpm-lock.yaml | 31 +
scripts/build-airgap-bundle.sh | 31 +
scripts/dev-setup.sh | 8 +-
scripts/install.ps1 | 28 +
scripts/install.sh | 44 +
scripts/rebuild-native.mjs | 14 +-
scripts/sync-versions.mjs | 24 +
src/core/app/ThunderController.ts | 2 +-
src/core/config/keys.ts | 7 +-
src/core/config/schema.ts | 12 +-
src/core/config/settingPaths.ts | 92 ++
src/core/config/vscode/migrate.ts | 45 +
src/core/config/vscode/read.ts | 41 +-
src/core/indexing/IndexMaintenanceService.ts | 122 +++
src/core/indexing/IndexWorkerService.ts | 156 +++
src/core/indexing/ThunderDb.ts | 1 +
src/core/indexing/index.ts | 2 +
.../github/GitHubPullRequestService.ts | 89 ++
src/core/integrations/github/index.ts | 1 +
src/core/jobs/JobQueueService.ts | 110 ++
src/core/jobs/index.ts | 1 +
src/core/llm/LlmProviderRegistry.ts | 2 +-
src/core/mcp/scaffoldMitiiWorkspace.ts | 4 +-
.../browser-testing-with-devtools/SKILL.md | 2 +-
src/core/task/index.ts | 2 +-
src/core/teams/TeamService.ts | 149 +++
src/core/teams/index.ts | 1 +
src/node/cli.ts | 311 +++++-
src/vscode/commands.ts | 42 +-
src/vscode/inlineDiffManager.ts | 2 +
src/vscode/nativeModuleHealth.ts | 4 +-
src/vscode/scm/registerScmContributions.ts | 72 +-
test/phase3.test.ts | 104 ++
test/top10-features.test.ts | 2 +-
66 files changed, 3092 insertions(+), 221 deletions(-)
create mode 100644 .github/workflows/mitii-agent.yml
create mode 100644 .github/workflows/native-binaries.yml
create mode 100644 .github/workflows/npm-publish.yml
create mode 100644 docs/enterprise/channel-security.md
create mode 100644 docs/enterprise/managed-policy.md
create mode 100644 docs/integrations/github-actions.md
create mode 100644 docs/users/channels/slack.md
create mode 100644 docs/users/channels/telegram.md
create mode 100644 docs/users/jetbrains-workflow.md
create mode 100644 packages/channels/package.json
create mode 100644 packages/channels/src/base.ts
create mode 100644 packages/channels/src/index.ts
create mode 100644 packages/channels/src/telegram.ts
create mode 100644 packages/cli/README.md
create mode 100755 packages/cli/bin/mitii
create mode 100644 packages/cli/optional-packages/mitii-darwin-arm64/package.json
create mode 100644 packages/cli/optional-packages/mitii-darwin-x64/package.json
create mode 100644 packages/cli/optional-packages/mitii-linux-x64/package.json
create mode 100644 packages/cli/optional-packages/mitii-win32-x64/package.json
create mode 100644 packages/cli/package.json
create mode 100644 packages/cli/scripts/build-platform.mjs
create mode 100644 packages/cli/scripts/publish-npm.mjs
create mode 100755 scripts/build-airgap-bundle.sh
create mode 100644 scripts/install.ps1
create mode 100755 scripts/install.sh
create mode 100644 scripts/sync-versions.mjs
create mode 100644 src/core/config/settingPaths.ts
create mode 100644 src/core/config/vscode/migrate.ts
create mode 100644 src/core/indexing/IndexMaintenanceService.ts
create mode 100644 src/core/indexing/IndexWorkerService.ts
create mode 100644 src/core/integrations/github/GitHubPullRequestService.ts
create mode 100644 src/core/jobs/JobQueueService.ts
create mode 100644 src/core/jobs/index.ts
create mode 100644 src/core/teams/TeamService.ts
create mode 100644 src/core/teams/index.ts
create mode 100644 test/phase3.test.ts
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/README.md b/README.md
index aab72d5e..7f005524 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
-
+
@@ -56,6 +56,26 @@ 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)
@@ -175,7 +195,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
@@ -226,7 +246,7 @@ Mitii stores useful state locally so every serious task does not start from zero
| 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. Markdown auto-memory is optional, secret-filtered, and controlled by `thunder.memory.autoMemoryEnabled` plus `thunder.memory.autoMemoryScope`.
+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 `.
@@ -268,7 +288,7 @@ Mitii can preload keyless MCP servers:
| `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. See [docs/integrations/agentmemory.md](docs/integrations/agentmemory.md) for the optional agentmemory setup.
+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
@@ -287,9 +307,9 @@ The sidebar is a React webview with:
| Indexing status | Know when the workspace brain is ready |
| Context warnings | See when context may be thin or over budget |
-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
@@ -297,13 +317,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) |
@@ -422,6 +442,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 |
@@ -441,9 +481,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.
@@ -454,8 +494,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 |
@@ -478,6 +518,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
+```
---
@@ -485,30 +539,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.memory.summarizeAfterTask": true,
- "thunder.memory.autoMemoryEnabled": true,
- "thunder.memory.autoMemoryScope": "user",
- "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": "sqlite",
+ "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
}
```
@@ -641,14 +702,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.
@@ -674,7 +735,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/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/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/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/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/package.json b/package.json
index e0d29cf0..34a3c542 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.32",
+ "version": "2.7.33",
"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,72 @@
"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",
@@ -214,22 +304,26 @@
"daemon"
],
"default": "embedded",
- "description": "Run the VS Code chat in-process or attach to a running mitii serve daemon."
+ "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."
+ "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."
+ "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."
+ "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",
@@ -247,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",
@@ -326,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",
@@ -335,41 +445,48 @@
"lancedb"
],
"default": "sqlite",
- "description": "Vector index storage: sqlite (.mitii/mitii.sqlite) or lancedb (.mitii/lance/) via @lancedb/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",
@@ -388,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",
@@ -400,27 +518,32 @@
"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."
+ "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."
+ "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",
@@ -430,34 +553,40 @@
"both"
],
"default": "user",
- "description": "Where markdown auto-memory files are written: user profile, workspace .mitii/auto-memory, or both."
+ "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 subagent tools for typed delegation."
+ "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",
@@ -467,39 +596,45 @@
"items": {
"type": "string"
},
- "description": "Allowed subagent types. Built-ins are research, implementer, reviewer, and verifier; custom .mitii/agents ids may also be listed."
+ "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."
+ "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."
+ "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."
+ "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",
@@ -512,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",
@@ -525,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",
@@ -538,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",
@@ -597,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",
@@ -632,22 +785,26 @@
"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",
@@ -657,24 +814,658 @@
"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": "sqlite",
+ "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."
}
}
}
@@ -683,7 +1474,7 @@
"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",
@@ -745,7 +1536,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
index 70e4978e..8082dbf4 100644
--- a/packages/board/package.json
+++ b/packages/board/package.json
@@ -1,11 +1,20 @@
{
"name": "@mitii/board",
- "version": "2.7.31",
+ "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
index 8f249442..6cc761b9 100644
--- a/packages/board/src/server.ts
+++ b/packages/board/src/server.ts
@@ -1,12 +1,14 @@
import { createServer, type Server } from 'http';
import { resolve } from 'path';
-import { TaskBoardService, ParallelAgentRunner } from '../../../src/core/task';
+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 }> {
@@ -14,8 +16,13 @@ export async function startMitiiBoard(options: BoardServerOptions): Promise<{ se
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()));
@@ -38,7 +45,14 @@ export async function startMitiiBoard(options: BoardServerOptions): Promise<{ se
return;
}
if (req.method === 'POST' && url.pathname === '/tasks/run') {
- const runner = new ParallelAgentRunner({ workspace: cwd, parallel: Number(url.searchParams.get('parallel') ?? 2), runtime: 'stub' });
+ 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;
}
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
index 5bccb8a4..a64a66f6 100644
--- a/packages/daemon/package.json
+++ b/packages/daemon/package.json
@@ -1,6 +1,6 @@
{
"name": "@mitii/daemon",
- "version": "2.7.31",
+ "version": "2.7.32",
"description": "HTTP/SSE daemon runtime for Mitii sessions.",
"license": "AGPL-3.0-or-later",
"type": "module",
@@ -14,9 +14,14 @@
}
},
"files": [
- "dist"
+ "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/server.ts b/packages/daemon/src/server.ts
index 061fcfdf..efe9981d 100644
--- a/packages/daemon/src/server.ts
+++ b/packages/daemon/src/server.ts
@@ -7,6 +7,7 @@ import { SessionConflictError, SessionLimitError, SessionManager, SessionNotFoun
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;
@@ -43,6 +44,8 @@ export async function startMitiiDaemon(options: MitiiDaemonServerOptions): Promi
packageRoot: options.packageRoot,
sseHub,
});
+ const indexWorker = new IndexWorkerService({ workspace: resolve(options.cwd) });
+ await indexWorker.initialize();
const server = createServer(async (req, res) => {
try {
@@ -56,7 +59,7 @@ export async function startMitiiDaemon(options: MitiiDaemonServerOptions): Promi
writeUnauthorized(res);
return;
}
- await route(req, res, sessions, sseHub, options);
+ 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) } });
@@ -69,6 +72,7 @@ export async function startMitiiDaemon(options: MitiiDaemonServerOptions): Promi
});
const close = async () => {
+ indexWorker.dispose();
await sessions.dispose();
await new Promise((resolveClose) => server.close(() => resolveClose()));
};
@@ -81,6 +85,7 @@ async function route(
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'}`);
@@ -99,7 +104,7 @@ async function route(
if (req.method === 'GET' && url.pathname === '/capabilities') {
writeJson(res, 200, {
- features: ['sessions', 'sse', 'permissions', 'cancel', 'subagents', 'worktrees'],
+ features: ['sessions', 'sse', 'permissions', 'cancel', 'subagents', 'worktrees', 'index-worker'],
maxSessions: options.maxSessions ?? 5,
supportedModes: ['ask', 'plan', 'agent', 'review'],
eventReplay: true,
@@ -108,6 +113,36 @@ async function route(
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;
diff --git a/packages/sdk/package.json b/packages/sdk/package.json
index 9493cb80..b3c42c2c 100644
--- a/packages/sdk/package.json
+++ b/packages/sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@mitii/sdk",
- "version": "2.7.30",
+ "version": "2.7.32",
"description": "Node SDK for running Mitii headless ask, plan, and agent sessions.",
"license": "AGPL-3.0-or-later",
"type": "module",
@@ -21,7 +21,8 @@
},
"files": [
"dist",
- "README.md"
+ "README.md",
+ "LICENSE"
],
"engines": {
"node": ">=20"
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/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" < pnpm run rebuild:native
+Set MITII_ELECTRON_VERSION for your editor, then run:
+ MITII_ELECTRON_VERSION=