diff --git a/README.md b/README.md
index 4e1a084e..788f3ac4 100644
--- a/README.md
+++ b/README.md
@@ -202,8 +202,10 @@ pnpm dist # macOS + Windows + Linux
## TODO
-- [ ] Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
-- [ ] 2 modes: current (agent teams), and a new mode: regular subagents (no communication between them)
+- [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
+- [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop
+- [ ] Context management: control and curate what context each agent sees (files, docs, MCP servers, skills)
+- [ ] Multi-model support: proxy layer to use other popular LLMs (GPT, Gemini, DeepSeek, Llama, etc.), including offline/local models
---
diff --git a/docs/diff-view-implementation-plan.md b/docs/iterations/diff-view/diff-view-implementation-plan.md
similarity index 100%
rename from docs/diff-view-implementation-plan.md
rename to docs/iterations/diff-view/diff-view-implementation-plan.md
diff --git a/docs/research/competitors.md b/docs/research/competitors.md
new file mode 100644
index 00000000..ea8c23ea
--- /dev/null
+++ b/docs/research/competitors.md
@@ -0,0 +1,332 @@
+# Конкуренты: платформы для оркестрации AI-агентов
+
+> Дата: 2026-03-04
+> Статус: Исследование завершено
+
+## Цель
+
+Оценить конкурентный ландшафт для концепции **"self-hosted web dashboard для оркестрации команд AI coding-агентов с workflow automation"**.
+
+---
+
+## Часть 1: Multi-Agent фреймворки
+
+### CrewAI
+
+| Параметр | Данные |
+|---|---|
+| **GitHub** | [crewAIInc/crewAI](https://github.com/crewAIInc/crewAI) |
+| **Stars** | ~44,600 |
+| **Open Source** | Да, MIT |
+| **Web UI** | CrewAI Studio (cloud, визуальный no-code билдер) |
+| **MCP** | Да — нативный (`mcps` на агентах, `MCPServerAdapter`, экспорт crew как MCP-сервер) |
+| **Multi-agent** | Да — основная концепция. Role-based crews, sequential/parallel/conditional архитектуры, A2A делегирование |
+| **Self-hosted** | Да — on-premise, AWS/Azure/GCP VPC |
+| **Модели** | Любые LLM через LiteLLM |
+| **Pricing** | Free (self-host), $99/мес (Basic), до $120K/год (Ultra) |
+
+**Дифференциатор:** Самый популярный multi-agent фреймворк. 60% Fortune 500. Мощная memory-система.
+**Слабость:** Python-only. Studio — cloud, не полноценный self-hosted web UI. Нет IDE. Нет фокуса на coding.
+
+---
+
+### LangGraph Studio (LangChain)
+
+| Параметр | Данные |
+|---|---|
+| **GitHub** | [langchain-ai/langgraph](https://github.com/langchain-ai/langgraph) |
+| **Stars** | ~25,500 |
+| **Open Source** | Да, MIT |
+| **Web UI** | LangGraph Studio v2 (браузер), Open Agent Platform (open-source no-code) |
+| **MCP** | Да — `langchain-mcp-adapters`, каждый deployed agent = MCP endpoint |
+| **Multi-agent** | Да — Swarm, Supervisor, handoff паттерны |
+| **Self-hosted** | Частично (Developer tier free, Enterprise — full self-hosted) |
+| **Модели** | Любые через LangChain |
+| **Pricing** | Developer: free. Plus: $39/seat/мес. Enterprise: от $100K+/год |
+
+**Дифференциатор:** Graph-based DAG архитектура, durable execution, глубокая интеграция с LangSmith.
+**Слабость:** Высокий порог входа (code-first). Сложная ценовая структура. Studio больше для дебага.
+
+---
+
+### AutoGen Studio (Microsoft)
+
+| Параметр | Данные |
+|---|---|
+| **GitHub** | [microsoft/autogen](https://github.com/microsoft/autogen) |
+| **Stars** | ~50,400 |
+| **Open Source** | Да, MIT |
+| **Web UI** | Да — drag-and-drop web UI для multi-agent workflows |
+| **MCP** | Да — `autogen_ext.tools.mcp` (Stdio, SSE, Streamable HTTP) |
+| **Multi-agent** | Да — async event-driven архитектура |
+| **Self-hosted** | Да — полностью (Python package) |
+| **Модели** | Любые LLM |
+| **Pricing** | Полностью бесплатно |
+
+**Дифференциатор:** Microsoft Research, 50K+ stars, полностью бесплатно.
+**Слабость:** ⚠️ **В РЕЖИМЕ MAINTENANCE!** Мержится с Semantic Kernel в "Microsoft Agent Framework". Новые фичи не будут. Миграция неизбежна.
+
+---
+
+## Часть 2: Coding Agent платформы
+
+### ⭐ OpenHands (formerly OpenDevin) — БЛИЖАЙШИЙ КОНКУРЕНТ
+
+| Параметр | Данные |
+|---|---|
+| **GitHub** | [All-Hands-AI/OpenHands](https://github.com/All-Hands-AI/OpenHands) |
+| **Stars** | ~64,000+ |
+| **Open Source** | Да, MIT |
+| **Web UI** | Да — полноценный: shell, browser, VSCode-editor, planner, VNC desktop |
+| **MCP** | Да — typed tool system с MCP integration |
+| **Multi-agent** | Да — hierarchical agent delegation, AgentHub (CodeActAgent, BrowserAgent, Micro-agents) |
+| **Self-hosted** | Да — Docker (MIT, free). Enterprise — VPC/Kubernetes |
+| **Модели** | Model-agnostic: Claude, GPT, open-source через litellm |
+| **Pricing** | Open Source: free. Cloud Growth: $500/мес. Enterprise: custom |
+
+**Дифференциатор:** Самый близкий по духу. Web UI для coding agents, multi-agent, sandbox execution, REST+WebSocket API. MIT лицензия. $18.8M funding.
+
+**Слабость:** Нет визуального workflow editor. Нет Kanban/task management. Нет team provisioning UI. UI для individual sessions, не для управления командами.
+
+---
+
+### Cursor
+
+| Параметр | Данные |
+|---|---|
+| **Stars** | N/A (closed-source, VS Code fork) |
+| **Open Source** | Нет |
+| **Web UI** | Нет — desktop IDE |
+| **MCP** | Да — first-class MCP, Apps, Marketplace |
+| **Multi-agent** | Да — до 8 parallel agents через git worktrees, Background Agents, BugBot |
+| **Self-hosted** | Нет |
+| **Pricing** | Free (50 req/мес), Pro $20/мес, Ultra $200/мес |
+
+**Дифференциатор:** $500M ARR, $10B valuation. Единственный с multi-agent parallel execution для coding.
+**Слабость:** Closed-source, desktop-only, нет self-hosted, vendor lock-in. Это IDE, не платформа оркестрации.
+
+---
+
+### Windsurf (formerly Codeium → Cognition AI)
+
+| Параметр | Данные |
+|---|---|
+| **Open Source** | Нет (только Vim/Neovim плагины) |
+| **Web UI** | Нет — desktop IDE |
+| **MCP** | Да — Stdio и HTTP MCP, Marketplace |
+| **Multi-agent** | Нет |
+| **Self-hosted** | Enterprise: cloud/hybrid/self-hosted |
+| **Pricing** | Free (25 credits/мес), Pro $15/мес, Teams $30/user/мес |
+
+**Дифференциатор:** #1 в LogRocket AI Dev Tool Rankings (Feb 2026). Куплен Cognition AI ($250M) → интеграция с Devin.
+**Слабость:** Closed-source, desktop-only, нет multi-agent. Acquisition создаёт неопределённость.
+
+---
+
+### Cody / Sourcegraph Amp
+
+| Параметр | Данные |
+|---|---|
+| **Open Source** | Был open-source (Apache), теперь private repo. Open-core |
+| **Web UI** | Частично — web-search по коду, Batch Changes UI |
+| **MCP** | Да — MCP tools (GitHub, Sentry, Linear) |
+| **Multi-agent** | Нет |
+| **Pricing** | Free. Enterprise: $19-59/user/мес |
+
+**Дифференциатор:** Deep Search по огромным кодобазам. 10 лет code intelligence.
+**Слабость:** Уже не полностью open-source. Не orchestration-платформа.
+
+---
+
+## Часть 3: Workflow/AI App платформы
+
+### Dify
+
+| Параметр | Данные |
+|---|---|
+| **GitHub** | [langgenius/dify](https://github.com/langgenius/dify) |
+| **Stars** | ~119K |
+| **Open Source** | Модифицированная Apache 2.0 |
+| **Web UI** | Да — полноценный drag-and-drop visual builder, playground, LLMOps дашборд |
+| **MCP** | Да — двусторонний (v1.6.0) |
+| **Multi-agent** | Частично — workflows с Agent Nodes |
+| **Self-hosted** | Да — Docker/K8s |
+| **Модели** | Сотни LLM |
+
+**Дифференциатор:** Самая зрелая LLMOps платформа. 100K+ stars. Визуальные workflows, RAG, MCP.
+**Слабость:** Не заточен на coding-агентов. Ограничения лицензии (no multi-tenant без коммерческой).
+
+---
+
+### n8n
+
+| Параметр | Данные |
+|---|---|
+| **GitHub** | [n8n-io/n8n](https://github.com/n8n-io/n8n) |
+| **Stars** | ~177K |
+| **Open Source** | Sustainable Use License |
+| **Web UI** | Да — лучший визуальный workflow editor в категории |
+| **MCP** | Да — двусторонний (MCP Client + MCP Server Trigger) |
+| **Multi-agent** | Скоро — "advanced multi-agent" в анонсах |
+| **Self-hosted** | Да — Docker/K8s, бесплатно |
+| **Модели** | OpenAI, Claude, Gemini через интеграции |
+
+**Дифференциатор:** 177K stars, 500+ интеграций, $180M Series C ($2.5B оценка). Лучший workflow editor.
+**Слабость:** Workflow automation, не AI agent orchestration. AI — дополнение, не ядро. SUL лицензия.
+
+---
+
+### Langflow
+
+| Параметр | Данные |
+|---|---|
+| **GitHub** | [langflow-ai/langflow](https://github.com/langflow-ai/langflow) |
+| **Stars** | ~130-140K |
+| **Open Source** | MIT |
+| **Web UI** | Да — drag-and-drop builder |
+| **MCP** | Да — flows как MCP server |
+| **Self-hosted** | Да — Docker/pip |
+
+**Дифференциатор:** Самый низкий порог входа, MIT, построен на LangChain.
+**Слабость:** General-purpose, не coding-specific.
+
+---
+
+### Flowise
+
+| Параметр | Данные |
+|---|---|
+| **GitHub** | [FlowiseAI/Flowise](https://github.com/FlowiseAI/Flowise) |
+| **Stars** | ~43K |
+| **Open Source** | MIT |
+| **Web UI** | Да — drag-and-drop, Agentflow V2 (multi-agent) |
+| **MCP** | Не подтверждено |
+| **Self-hosted** | Да — Docker |
+
+**Дифференциатор:** Чистая MIT лицензия. Enterprise-фичи (RBAC, SSO). Клиенты — Accenture, Deloitte.
+**Слабость:** Меньше чем Dify. Не coding-specific.
+
+---
+
+### Activepieces
+
+| Параметр | Данные |
+|---|---|
+| **GitHub** | [activepieces/activepieces](https://github.com/activepieces/activepieces) |
+| **Stars** | ~20K |
+| **Open Source** | MIT |
+| **Web UI** | Да — flow builder |
+| **MCP** | Да — 400+ MCP серверов |
+| **Self-hosted** | Да — Docker |
+
+**Дифференциатор:** MIT, 280+ pieces как MCP, TypeScript-based.
+**Слабость:** Zapier-альтернатива для бизнес-автоматизации, не для coding.
+
+---
+
+## Часть 4: Single-agent coding tools (не прямые конкуренты)
+
+| Инструмент | Stars | Open Source | Web UI | Multi-Agent | Комментарий |
+|---|---|---|---|---|---|
+| **Aider** | 34K | Apache 2.0 | Минимальный (browser mode) | Нет | Лучший CLI pair programmer. Git-native |
+| **SWE-agent** | 14K | MIT | Нет | Нет | Академический, SoTA бенчмарки. CLI only |
+| **bolt.diy** | 15K | MIT* | Да (browser IDE) | Нет | Full-stack в браузере. WebContainers ограничения |
+| **Continue.dev** | 26K | Apache 2.0 | Нет (IDE ext) | Нет | VS Code/JetBrains assistant. MCP support |
+| **Devon** | 3.5K | AGPL | Нет | Нет | Ранняя стадия, малая популярность |
+| **v0.dev** | N/A | Нет | Да | Нет | Vercel SaaS. Prompt-to-React. Закрытый |
+
+---
+
+## Часть 5: Мёртвые/замороженные проекты
+
+| Проект | Stars | Статус | Причина |
+|---|---|---|---|
+| **AgentGPT** | 32K | ⚠️ Архивирован (янв 2026) | Reworkd сменил фокус. GPT-only, no multi-agent |
+| **AutoGen** | 50.4K | ⚠️ Maintenance mode | Мержится с Semantic Kernel. Миграция неизбежна |
+
+---
+
+## Сводная таблица
+
+| Платформа | Stars | Web UI | MCP | Multi-Agent | Self-Host | Open Source | Coding | Workflow Editor |
+|---|---|---|---|---|---|---|---|---|
+| **n8n** | 177K | ✅ Лучший | ✅ | Скоро | ✅ | SUL | ❌ | ✅ |
+| **Langflow** | 130K | ✅ | ✅ | Частично | ✅ | MIT | ❌ | ✅ |
+| **Dify** | 119K | ✅ | ✅ | Частично | ✅ | Apache* | ❌ | ✅ |
+| **OpenHands** | 64K | ✅ | ✅ | ✅ | ✅ | MIT | ✅ | ❌ |
+| **AutoGen** | 50K | ✅ | ✅ | ✅ | ✅ | MIT | ❌ | ❌ (⚠️ maint.) |
+| **CrewAI** | 45K | Cloud | ✅ | ✅ | ✅ | MIT | ❌ | Cloud |
+| **Flowise** | 43K | ✅ | ? | ✅ | ✅ | MIT | ❌ | ✅ |
+| **Aider** | 34K | Мин. | Community | ❌ | ✅ | Apache | ✅ | ❌ |
+| **LangGraph** | 26K | ✅ | ✅ | ✅ | Частично | MIT | ❌ | ❌ |
+| **Continue** | 26K | ❌ (IDE) | ✅ | ❌ | ✅ | Apache | ✅ | ❌ |
+| **Cursor** | N/A | ❌ (IDE) | ✅ | ✅ (8 par.) | ❌ | ❌ | ✅ | ❌ |
+| **Windsurf** | N/A | ❌ (IDE) | ✅ | ❌ | Enterprise | ❌ | ✅ | ❌ |
+
+---
+
+## Выводы: наше позиционирование
+
+### Прямых конкурентов НЕТ
+
+Ни одна существующая платформа не совмещает всё это:
+- ✅ Self-hosted web UI
+- ✅ Фокус на coding-агентах
+- ✅ Multi-agent team orchestration
+- ✅ Visual workflow editor
+- ✅ Task management (Kanban)
+- ✅ MCP/Skills поддержка
+
+### Ближайшие конкуренты по отдельным аспектам
+
+```
+ Coding Focus
+ ↑
+ │
+ OpenHands ────┤──── Cursor
+ (Web UI, │ (Multi-agent,
+ MIT, │ но closed,
+ multi-agent) │ desktop-only)
+ │
+ ─────────────────┼──────────────────→ Workflow Editor
+ │
+ CrewAI ───────┤──── Dify / n8n
+ (Multi-agent │ (Workflow,
+ framework) │ Web UI,
+ │ MCP)
+ │
+```
+
+### Наш проект = пересечение трёх категорий
+
+1. **OpenHands** → coding agent + Web UI + multi-agent → но нет workflow editor, нет kanban
+2. **Dify / n8n** → workflow orchestration + визуальный editor + MCP → но не coding-specific
+3. **Claude Code Teams** → native team orchestration → но нет web UI, нет workflow automation
+
+**Уникальная ценность:** "OpenHands для команд с workflow automation" или "Dify/n8n, специализированный на coding-агентах"
+
+### Ключевые дифференциаторы
+
+- Web UI специально для Claude Code teams (не generic builder)
+- Визуализация реальных coding sessions (timeline, chunks, tool calls) — уже есть
+- Team provisioning и task management (Kanban) — уже есть
+- Visual workflow editor для сборки agent pipelines
+- Self-hosted с полным контролем данных
+- Фокус на наблюдаемость (context tracking, token usage, cost)
+
+Sources:
+- [CrewAI](https://crewai.com/) | [GitHub](https://github.com/crewAIInc/crewAI) | [MCP Docs](https://docs.crewai.com/en/mcp/overview)
+- [LangGraph](https://www.langchain.com/langgraph) | [GitHub](https://github.com/langchain-ai/langgraph)
+- [AutoGen](https://microsoft.github.io/autogen) | [GitHub](https://github.com/microsoft/autogen) | [Merger Discussion](https://github.com/microsoft/autogen/discussions/7066)
+- [OpenHands](https://openhands.dev/) | [GitHub](https://github.com/All-Hands-AI/OpenHands) | [SDK Paper](https://arxiv.org/html/2511.03690v1)
+- [Dify](https://dify.ai/) | [GitHub](https://github.com/langgenius/dify) | [MCP Blog](https://dify.ai/blog/v1-6-0-built-in-two-way-mcp-support)
+- [n8n](https://n8n.io/) | [GitHub](https://github.com/n8n-io/n8n)
+- [Langflow](https://www.langflow.org/) | [GitHub](https://github.com/langflow-ai/langflow)
+- [Flowise](https://flowiseai.com/) | [GitHub](https://github.com/FlowiseAI/Flowise)
+- [Cursor](https://cursor.com/) | [Features](https://cursor.com/features)
+- [Windsurf](https://windsurf.com/)
+- [Aider](https://aider.chat/) | [GitHub](https://github.com/Aider-AI/aider)
+- [bolt.diy](https://github.com/stackblitz-labs/bolt.diy)
+- [Continue.dev](https://www.continue.dev/) | [GitHub](https://github.com/continuedev/continue)
+- [AgentGPT](https://github.com/reworkd/AgentGPT) (архивирован)
+- [Activepieces](https://github.com/activepieces/activepieces)
diff --git a/docs/diff-view-research.md b/docs/research/diff-view-research.md
similarity index 100%
rename from docs/diff-view-research.md
rename to docs/research/diff-view-research.md
diff --git a/docs/research/local-image-storage.md b/docs/research/local-image-storage.md
new file mode 100644
index 00000000..b7c8e5c3
--- /dev/null
+++ b/docs/research/local-image-storage.md
@@ -0,0 +1,164 @@
+# Local Image Storage in Electron — Research & Recommendations
+
+## Context
+
+This document evaluates approaches for storing images/attachments locally in our Electron app (draft attachments in task descriptions, team-related images). The app uses Electron 28.x, React 18, and the main process already manages file I/O via IPC.
+
+---
+
+## Approach 1: Filesystem + SQLite Metadata (Recommended)
+
+**How it works:** Store image files on disk under `app.getPath('userData')/attachments/`, serve them to the renderer via a custom `protocol.handle` scheme (`app://attachments/...`), and track metadata (path, original name, size, hash, created date, linked entity) in a `better-sqlite3` table.
+
+### Pros
+- Best I/O performance — direct filesystem reads, no serialization overhead.
+- `protocol.handle` (Electron 28+) is the modern, secure way to serve local files without disabling `webSecurity`.
+- `better-sqlite3` is synchronous, zero-dependency after rebuild, and already proven in the Electron ecosystem.
+- Thumbnails generated once by `sharp` and stored alongside originals — no re-computation.
+- Simple garbage collection: query metadata for orphaned entries, `fs.unlink` the files.
+- No practical per-file size limit (limited only by disk space).
+
+### Cons
+- Requires `electron-rebuild` for `better-sqlite3` native bindings.
+- Two storage systems to maintain (fs + sqlite).
+- Path traversal must be prevented in the custom protocol handler (standard pattern, well-documented).
+
+### Key implementation details
+
+**Custom protocol (secure file serving):**
+```ts
+protocol.registerSchemesAsPrivileged([{
+ scheme: 'app-img',
+ privileges: { standard: true, secure: true, supportFetchAPI: true }
+}]);
+
+app.whenReady().then(() => {
+ protocol.handle('app-img', (req) => {
+ const { pathname } = new URL(req.url);
+ const base = path.join(app.getPath('userData'), 'attachments');
+ const resolved = path.resolve(base, pathname.slice(1));
+ const rel = path.relative(base, resolved);
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
+ return new Response('Forbidden', { status: 403 });
+ }
+ return net.fetch(pathToFileURL(resolved).toString());
+ });
+});
+```
+
+**Thumbnail generation (sharp):**
+```ts
+import sharp from 'sharp';
+
+async function createThumbnail(inputPath: string, outputPath: string) {
+ await sharp(inputPath)
+ .resize(300, 300, { fit: 'inside', withoutEnlargement: true })
+ .webp({ quality: 80 })
+ .toFile(outputPath);
+}
+```
+
+**Metadata schema (better-sqlite3):**
+```sql
+CREATE TABLE attachments (
+ id TEXT PRIMARY KEY, -- uuid
+ original_name TEXT NOT NULL,
+ mime_type TEXT NOT NULL,
+ size_bytes INTEGER NOT NULL,
+ hash TEXT NOT NULL, -- sha256 for dedup
+ thumb_path TEXT,
+ entity_type TEXT, -- 'task' | 'team' | 'draft'
+ entity_id TEXT,
+ created_at INTEGER NOT NULL,
+ accessed_at INTEGER
+);
+```
+
+**Garbage collection:** Periodic sweep (on app start or every N hours) — find rows where `entity_id` no longer exists in the app state, delete file + row. Also enforce a configurable max total size (e.g. 500 MB) with LRU eviction based on `accessed_at`.
+
+---
+
+## Approach 2: Filesystem Only (Simplest)
+
+**How it works:** Store images and a sidecar `.json` metadata file per attachment under `userData/attachments/{uuid}/`. No database.
+
+### Pros
+- Zero native dependencies — no `better-sqlite3` rebuild.
+- Trivially portable (copy the folder).
+- Simple to implement and debug.
+
+### Cons
+- Querying metadata (e.g. "all images for task X") requires scanning directory + reading JSON files — O(n).
+- No transactional integrity between metadata and files.
+- Deduplication harder without indexed hashes.
+- Garbage collection requires full directory walk.
+
+### When to choose this
+If the total number of attachments is expected to stay under ~200 and complex queries are not needed. Good enough for an MVP.
+
+---
+
+## Approach 3: IndexedDB (Renderer-Side)
+
+**How it works:** Store images as Blobs in IndexedDB (via Dexie.js wrapper) directly in the renderer process.
+
+### Pros
+- No native dependencies at all.
+- Built-in browser API, well-documented.
+- Transactional, supports indexes and queries.
+
+### Cons
+- **Performance:** Every read/write goes through Chromium's abstraction layers — significantly slower than direct fs I/O for large files.
+- **Renderer-only:** Cannot be accessed from the main process without IPC round-trips.
+- **Quota:** Chromium imposes storage quotas (varies, but often ~60% of disk on desktop).
+- **Backup risk:** Data lives inside Chromium's internal LevelDB files — not human-readable, not easily portable.
+- **Multi-window:** Concurrent access from multiple BrowserWindows can cause issues.
+
+### When to choose this
+Only if the app is being designed as a pure web app with Electron as a thin shell, and images are small (< 5 MB each).
+
+---
+
+## Comparison Matrix
+
+| Criterion | FS + SQLite | FS Only | IndexedDB |
+|---------------------------|-----------------|----------------|----------------|
+| Read/write performance | Excellent | Excellent | Moderate |
+| Query capability | Full SQL | Manual scan | IndexedDB API |
+| Native deps required | better-sqlite3 | None | None |
+| Max file size | Disk limit | Disk limit | ~2 GB (blob) |
+| Garbage collection | SQL query + unlink | Dir walk | Cursor iterate |
+| Security (serving to renderer) | protocol.handle | protocol.handle | N/A (in-process) |
+| Deduplication | Hash index | Manual | Hash index |
+| Complexity | Medium | Low | Medium |
+| Multi-window safe | Yes (main proc) | Yes (main proc)| Risky |
+
+---
+
+## Recommendation
+
+**Use Approach 1 (Filesystem + SQLite Metadata)** for this project.
+
+Rationale:
+1. The app already runs significant logic in the main process (file watchers, JSONL parsing, IPC handlers) — adding `better-sqlite3` fits the existing architecture.
+2. `protocol.handle` is the Electron 28+ standard for secure local file serving; we should adopt it.
+3. `sharp` handles thumbnailing efficiently (sub-millisecond per image) and outputs WebP for smaller sizes.
+4. SQL metadata enables fast lookups by entity, dedup by hash, and clean GC queries.
+5. The FS-only approach (Approach 2) is a valid MVP fallback if we want to avoid native deps initially, with a clear migration path to Approach 1 later.
+
+### Size Limits & Quotas (Suggested Defaults)
+- Max single file: 20 MB (covers high-res screenshots).
+- Max total storage: 500 MB (configurable in settings).
+- Thumbnail size: 300px max dimension, WebP quality 80.
+- GC trigger: on app start + every 6 hours while running.
+- Orphan grace period: 24 hours (allows undo of deletions).
+
+### Libraries to Use
+| Library | Version (as of 2026) | Purpose |
+|---------|---------------------|---------|
+| `better-sqlite3` | 11.x | Metadata storage |
+| `sharp` | 0.33.x | Thumbnail generation, format conversion |
+| `uuid` | 10.x (or `crypto.randomUUID()`) | Attachment IDs |
+
+### Migration Path
+Start with Approach 2 (FS-only) if speed matters. Add `better-sqlite3` when we need querying or the attachment count grows. The file layout (`userData/attachments/{uuid}.{ext}`) stays the same — only the metadata layer changes.
diff --git a/docs/research/markdown-rendering-pipeline.md b/docs/research/markdown-rendering-pipeline.md
new file mode 100644
index 00000000..66e4a670
--- /dev/null
+++ b/docs/research/markdown-rendering-pipeline.md
@@ -0,0 +1,143 @@
+# Message Text Pipeline & Markdown Rendering — Research
+
+## Context
+
+Investigating why markdown formatting may appear as plain text in certain UI surfaces.
+
+---
+
+## 1. Full Text Pipeline: MessageComposer -> Inbox -> ActivityItem
+
+### Step-by-step flow
+
+```
+MessageComposer.tsx (renderer)
+ |-- User types into MentionableTextarea (plain text + chip tokens)
+ |-- On send: serializeChipsWithText(text, chips) converts chip tokens to markdown fences
+ |-- Calls onSend(recipient, serialized, serialized, attachments?)
+ |
+teamSlice.ts (renderer store)
+ |-- sendTeamMessage() -> api.teams.sendMessage(teamName, request)
+ |
+TeamInboxWriter.ts (main process)
+ |-- Writes InboxMessage to JSON file at teams/{teamName}/inboxes/{member}.json
+ |-- `text` field stored verbatim — NO sanitization, NO escaping
+ |-- JSON.stringify with null,2 (pretty-print) — safe for any string content
+ |
+FileWatcher detects change -> store loads inbox data
+ |
+ActivityItem.tsx (renderer)
+ |-- stripAgentBlocks(message.text) removes ```info_for_agent``` blocks
+ |-- linkifyTaskIdsInMarkdown() converts #123 to [#123](task://123)
+ |-- Passes result to
+```
+
+### Conclusion: Markdown IS preserved end-to-end
+
+The text field is stored as-is in JSON. No sanitization or escaping strips markdown formatting. `serializeChipsWithText()` even enriches plain text with markdown code fences for code chips.
+
+---
+
+## 2. TaskDetailDialog Description Rendering
+
+### Current implementation (TaskDetailDialog.tsx, lines 539-566)
+
+**Read mode (not editing):**
+```tsx
+
+```
+
+**Edit preview mode (lines 494-501):**
+```tsx
+
+```
+
+### Conclusion: TaskDetailDialog DOES use MarkdownViewer
+
+The description is rendered with `` in read mode. Markdown should render correctly here. If it appears as plain text, the issue is upstream — the description content itself may not contain markdown formatting (e.g., the task was created with plain text by CLI tooling, not from the UI).
+
+---
+
+## 3. Sanitization/Escaping Analysis
+
+| Layer | Sanitization | Impact on Markdown |
+|-------|-------------|-------------------|
+| `serializeChipsWithText()` | Replaces chip tokens with markdown — additive | **None** (enriches) |
+| `TeamInboxWriter.sendMessage()` | None — stores `request.text` verbatim | **None** |
+| `JSON.stringify/parse` | Standard JSON encoding | **None** (reversible) |
+| `stripAgentBlocks()` | Removes ````info_for_agent``` blocks only | **None** (targeted) |
+| `linkifyTaskIdsInMarkdown()` | Converts `#123` to `[#123](task://123)` | **None** (additive) |
+| `ReactMarkdown` | Parses markdown to HTML | **This IS the rendering** |
+
+**No layer strips or escapes markdown formatting.** The pipeline is clean.
+
+---
+
+## 4. Effect of MarkdownViewer `bare` Prop
+
+From `MarkdownViewer.tsx` (lines 561-571):
+
+```tsx
+
+```
+
+**`bare` only affects the wrapper div styling:**
+- `bare={true}`: No background, no border, no shadow — for embedding inside cards.
+- `bare={false}` (default): Adds `CODE_BG` background, `CODE_BORDER` border, `rounded-lg shadow-sm`.
+
+**`bare` does NOT affect markdown parsing or rendering.** The same `ReactMarkdown` component with `remarkGfm` and `rehype-highlight` is used regardless.
+
+---
+
+## 5. Where Markdown Might Appear as Plain Text
+
+### Identified scenarios
+
+1. **Task descriptions from CLI tooling (teamctl.js / Claude agents)**
+ Task descriptions are set via `node teamctl.js task create` or `TaskCreate` tool calls. Agents typically write plain text descriptions, not markdown. The description content itself lacks formatting — MarkdownViewer renders it correctly, but there's nothing to format.
+
+2. **Task comments from agents**
+ Same issue — `task comment --text "..."` passes plain text. However, TaskCommentsSection.tsx (line 246) correctly uses ``.
+
+3. **Structured JSON messages in ActivityItem**
+ When `parseStructuredAgentMessage()` returns a match, the structured path renders `autoSummary` as a `
` tag and shows raw JSON in a `` block — no MarkdownViewer. This is intentional for JSON protocol messages.
+
+4. **Summary text in ActivityItem header**
+ Line 375-377: `summaryText` is rendered as plain `` in the header row. This is by design — summaries are short single-line previews.
+
+5. **ReplyQuoteBlock path in ActivityItem**
+ When `parsedReply` is detected, it renders `` instead of MarkdownViewer. The quote block may not support full markdown — worth checking.
+
+### NOT an issue
+
+- Description in TaskDetailDialog: correctly uses MarkdownViewer.
+- Activity feed messages: correctly uses MarkdownViewer for displayText.
+- Task comments: correctly uses MarkdownViewer.
+
+---
+
+## 6. Proposed Fixes
+
+### Fix 1: No code change needed for the rendering pipeline
+The pipeline is correct. All text display surfaces that show long-form content already use MarkdownViewer.
+
+### Fix 2: If specific content appears unformatted, the fix is upstream
+Ensure that agents/tooling that create tasks or comments use markdown formatting in their text. For example, the `teamctl.js` task create command could document that `--description` supports markdown.
+
+### Fix 3 (Optional): ReplyQuoteBlock markdown support
+`ReplyQuoteBlock` renders the reply body. If it currently shows plain text, wrap body content in `` for consistency. (Needs verification — separate from this research scope.)
+
+---
+
+## Summary
+
+| Question | Answer |
+|----------|--------|
+| Is markdown preserved in the pipeline? | Yes — no sanitization strips it |
+| Does TaskDetailDialog use MarkdownViewer? | Yes — both read mode and edit preview |
+| Does any escaping strip formatting? | No — all transformations are additive or targeted |
+| Does `bare` affect rendering? | No — only wrapper styling (bg/border/shadow) |
+| Why might text appear unformatted? | Source content (from agents/CLI) is plain text, not markdown |
diff --git a/docs/research/web-deployment.md b/docs/research/web-deployment.md
new file mode 100644
index 00000000..fe6e0bfe
--- /dev/null
+++ b/docs/research/web-deployment.md
@@ -0,0 +1,183 @@
+# Web Deployment: Electron → Web Hybrid
+
+> Дата: 2026-03-04
+> Статус: Исследование завершено, рекомендация выбрана
+
+## Цель
+
+Запускать приложение на удалённом сервере с доступом через веб-интерфейс (браузер), сохраняя при этом десктопную Electron-версию.
+
+## Исследованные варианты
+
+---
+
+### 1. `electron-to-web` — Drop-in замена IPC → WebSocket
+
+- **npm**: [electron-to-web](https://libraries.io/npm/electron-to-web)
+- **GitHub**: [lsadehaan/electron-to-web](https://github.com/lsadehaan/electron-to-web)
+- **Версия**: 0.2.0 (первый релиз — январь 2026)
+- **Загрузки**: 0/неделю
+- **Зависимости**: `ws`, `json-rpc-2.0`
+- **Лицензия**: MIT
+
+**Как работает**: Меняешь 2 импорта (`'electron'` → `'electron-to-web/main'`), и IPC автоматически конвертируется в JSON-RPC over WebSocket. Маппинг: `ipcRenderer.invoke()` → JSON-RPC request, `webContents.send()` → JSON-RPC notification.
+
+**Плюсы**: минимальный объём изменений.
+**Минусы**: пакет совсем сырой (v0.2.0, один автор, 0 загрузок). Для продакшна не готов.
+
+| Надёжность | Уверенность |
+|------------|-------------|
+| 3/10 | 8/10 |
+
+---
+
+### 2. `electron-common-ipc` — IPC-шина с WebSocket
+
+- **GitHub**: [emmkimme/electron-common-ipc](https://github.com/emmkimme/electron-common-ipc)
+- **Версия**: 16.0.4 (зрелый проект)
+- **Загрузки**: ~35/неделю
+- **Суб-пакет**: `@electron-common-ipc/web-socket-browser`
+
+**Как работает**: EventEmitter-like API для обмена данными между любыми процессами (Node, Electron Main/Renderer). Есть WebSocket-расширение для браузера.
+
+**Плюсы**: зрелый пакет, много версий.
+**Минусы**: низкоуровневая шина, не drop-in замена Electron IPC. Придётся адаптировать все handlers вручную.
+
+| Надёжность | Уверенность |
+|------------|-------------|
+| 5/10 | 7/10 |
+
+---
+
+### 3. Neutralino.js — встроенный cloud mode
+
+- **Сайт**: [neutralino.js.org](https://neutralino.js.org/)
+- **GitHub**: [neutralinojs/neutralinojs](https://github.com/neutralinojs/neutralinojs) (~22k stars)
+- **Документация**: [Modes](https://neutralino.js.org/docs/configuration/modes/)
+
+**Как работает**: Альтернативный фреймворк с 4 режимами из коробки:
+
+| Режим | Описание |
+|------------|---------------------------------------|
+| `window` | Нативное окно (как Electron) |
+| `browser` | Открывает в дефолтном браузере |
+| `chrome` | Chrome App Mode |
+| `cloud` | Запускает как веб-сервер по сети |
+
+Cloud mode — именно то, что нужно для серверного деплоя. Но это **другой фреймворк**, не совместимый с Electron.
+
+**Плюсы**: встроенная поддержка web-деплоя, лёгкий (~2MB vs ~150MB Electron).
+**Минусы**: требует полной переписки приложения.
+
+| Надёжность | Уверенность |
+|------------|-------------|
+| 7/10 | 6/10 |
+
+---
+
+### ✅ 4. DIY Transport Abstraction (Slack Pattern) — РЕКОМЕНДУЕТСЯ
+
+- **Источник**: [Slack Engineering: Interop's Labyrinth](https://slack.engineering/interops-labyrinth-sharing-code-between-web-electron-apps/)
+- **Источник**: [Slack Engineering: Building Hybrid Applications](https://slack.engineering/building-hybrid-applications-with-electron/)
+
+**Как работает**: Абстракция транспортного слоя — фронтенд не знает, работает он через Electron IPC или через WebSocket. Один интерфейс, две реализации:
+
+```typescript
+// Интерфейс транспорта
+interface IpcTransport {
+ invoke(channel: string, ...args: unknown[]): Promise;
+ on(channel: string, cb: (...args: unknown[]) => void): () => void;
+}
+
+// Electron — для десктоп-версии
+class ElectronTransport implements IpcTransport {
+ invoke(ch, ...args) { return window.api[ch](...args); }
+ on(ch, cb) { return window.api.on(ch, cb); }
+}
+
+// WebSocket — для веб-версии
+class WebSocketTransport implements IpcTransport {
+ private ws: WebSocket;
+ invoke(ch, ...args) { /* JSON-RPC через WS */ }
+ on(ch, cb) { /* подписка на WS сообщения */ }
+}
+```
+
+На сервере: Express/Fastify + WebSocket, обработчики зеркалят существующие IPC handlers.
+
+**Плюсы**:
+- Проверено Slack в продакшне (миллионы пользователей)
+- Полный контроль над архитектурой
+- Никаких внешних зависимостей с сомнительным качеством
+- ~100 строк кода для абстракции
+- Сохраняет обе версии (десктоп + веб)
+
+**Минусы**: нужно написать самим (но объём небольшой).
+
+| Надёжность | Уверенность |
+|------------|-------------|
+| **9/10** | **9/10** |
+
+---
+
+### ✅ 5. Vite Web Build + DIY — РЕКОМЕНДУЕТСЯ
+
+Расширение варианта 4, адаптированное под наш стек.
+
+**Как работает**: `src/renderer/` — уже обычное React SPA на Vite. Его можно собрать отдельно стандартным `vite build` для веба, подменив IPC через абстракцию из п.4.
+
+Архитектура:
+```
+Сервер (VPS)
+├── Node.js сервер (Express/Fastify)
+│ ├── HTTP API (зеркало IPC handlers из src/main/)
+│ ├── WebSocket (live updates — замена FileWatcher events)
+│ └── Claude Code процессы (spawn/manage)
+└── Static files (React SPA — vite build из src/renderer/)
+
+Браузер (где угодно)
+└── React SPA → HTTP/WS → сервер
+```
+
+**Что нужно сделать**:
+1. Абстракция транспорта (`IpcTransport` interface) — ~100 LOC
+2. `ElectronTransport` — обёртка над `window.api` — ~50 LOC
+3. `WebSocketTransport` — JSON-RPC через WS — ~150 LOC
+4. Node.js HTTP/WS сервер, зеркалящий IPC handlers — ~300-500 LOC
+5. Vite конфиг для web-сборки — ~30 LOC
+6. Auth для веб-версии (JWT/session) — ~200 LOC
+
+**Плюсы**:
+- Минимальные изменения в существующем коде
+- Renderer остаётся тем же React SPA
+- Работает с текущим стеком (Vite + React + Zustand + Tailwind)
+- Electron-версия продолжает работать как раньше
+- Latency: JSON-RPC добавляет ~1-2ms vs нативный IPC
+
+**Минусы**: нужно поддерживать серверную часть отдельно.
+
+| Надёжность | Уверенность |
+|------------|-------------|
+| **8/10** | **8/10** |
+
+---
+
+## Сводная таблица
+
+| Подход | Объём работы | Надёжность | Риск |
+|--------------------------|---------------------------|------------|-----------------------------|
+| `electron-to-web` | Минимальный (2 импорта) | 3/10 | Пакет v0.2.0, 0 загрузок |
+| `electron-common-ipc` | Средний | 5/10 | Нужна адаптация handlers |
+| Neutralino.js | Огромный (переписать всё) | 7/10 | Другой фреймворк |
+| **DIY (Slack pattern)** | **Средний (~3-4 дня)** | **9/10** | **Минимальный** |
+| **Vite web build + DIY** | **Средний (~3-4 дня)** | **8/10** | **Минимальный** |
+
+## Решение
+
+**Вариант 4 + 5** — DIY абстракция транспорта (по паттерну Slack) + отдельная Vite web-сборка. Это единственный подход, который:
+
+- Проверен в продакшне крупными компаниями
+- Не зависит от сырых/нишевых пакетов
+- Сохраняет обратную совместимость с Electron-версией
+- Требует умеренного объёма работы (~3-4 дня)
+- Даёт полный контроль над архитектурой
diff --git a/docs/research/workflow-editors.md b/docs/research/workflow-editors.md
new file mode 100644
index 00000000..f785f02b
--- /dev/null
+++ b/docs/research/workflow-editors.md
@@ -0,0 +1,202 @@
+# Workflow Editor: визуальные библиотеки и паттерны
+
+> Дата: 2026-03-04
+> Статус: Исследование завершено
+
+## Цель
+
+Выбрать библиотеку для визуального node-based workflow editor в React-приложении для оркестрации AI-агентов.
+
+---
+
+## Часть 1: Библиотеки
+
+### ✅ @xyflow/react (React Flow) — ОДНОЗНАЧНЫЙ ЛИДЕР
+
+| Параметр | Значение |
+|---|---|
+| **GitHub** | [xyflow/xyflow](https://github.com/xyflow/xyflow) |
+| **npm** | [@xyflow/react](https://www.npmjs.com/package/@xyflow/react) |
+| **Stars** | ~35,500 |
+| **npm загрузки/неделю** | ~4,900,000 (старый `reactflow` + новый `@xyflow/react`) |
+| **Версия** | v12.10.1 (2025) |
+| **Лицензия** | MIT |
+| **Bundle size** | ~40-50 kB min+gzip |
+| **TypeScript** | Полная поддержка, написан на TypeScript |
+| **React** | Нативная React-библиотека |
+
+**Ключевые фичи:**
+- Drag & drop нод, zoom/pan, minimap, controls — из коробки
+- Кастомные ноды и edges = обычные React-компоненты
+- Hooks API (`useNodes`, `useEdges`, `useReactFlow`)
+- Новые React Flow Components на базе **shadcn/ui** (2025)
+- Performance: перерисовываются только изменённые ноды
+
+**Кто использует:** Stripe, n8n (Vue Flow), Langflow, Flowise, **Dify** — 4 из 8 крупнейших AI-платформ
+
+**Надёжность: 10/10 | Уверенность: 10/10**
+
+---
+
+### Rete.js
+
+| Параметр | Значение |
+|---|---|
+| **GitHub** | [retejs/rete](https://github.com/retejs/rete) |
+| **Stars** | ~11,900 |
+| **npm загрузки/неделю** | ~42,700 |
+| **Лицензия** | MIT |
+| **React** | Через плагин `rete-react-plugin` |
+
+Фреймворк-агностик (React, Vue, Angular, Svelte). Более "инженерный" подход с типизированными портами. Rete Studio — уникальная фича code ↔ visual. Но значительно меньше комьюнити и слабее документация.
+
+**Надёжность: 7/10 | Уверенность: 8/10**
+
+---
+
+### AntV X6
+
+| Параметр | Значение |
+|---|---|
+| **GitHub** | [antvis/X6](https://github.com/antvis/X6) |
+| **Stars** | ~6,400 |
+| **npm загрузки/неделю** | ~68,200 |
+| **Лицензия** | MIT |
+
+Enterprise-grade, часть экосистемы Ant Design. SVG/HTML рендеринг. Но документация преимущественно на китайском, React-интеграция через обёртки.
+
+**Надёжность: 7/10 | Уверенность: 8/10**
+
+---
+
+### JointJS / JointJS+
+
+| Параметр | Значение |
+|---|---|
+| **GitHub** | [clientIO/joint](https://github.com/clientIO/joint) |
+| **Stars** | ~5,170 |
+| **Лицензия** | MPL 2.0 (open source) / Commercial (JointJS+ от $2,990) |
+
+Самая зрелая библиотека (с 2010). BPMN 2.0, UML, ERD, Visio import/export. Но коммерческая лицензия дорогая, open source часть ограничена.
+
+**Надёжность: 8/10 | Уверенность: 8/10**
+
+---
+
+### Drawflow, Flume, BaklavaJS, LiteGraph.js, jsPlumb
+
+| Библиотека | Stars | React | TypeScript | Статус |
+|---|---|---|---|---|
+| Drawflow | 6,000 | Нет | Нет | Заброшен |
+| LiteGraph.js | 7,800 | Нет (Canvas2D) | Нет | ComfyUI мигрирует на Vue |
+| jsPlumb | 7,800 | Через Toolkit ($990) | Частично | Community заброшен |
+| Flume | 1,500 | Да | Частично | Полу-заброшен |
+| BaklavaJS | 1,500 | Нет (только Vue) | Да | Нишевый |
+
+Все перечисленные **не подходят** для нашего проекта из-за отсутствия React-поддержки, TypeScript, или заброшенности.
+
+---
+
+## Сводная таблица
+
+| Библиотека | Stars | npm/нед. | React | TS | Лицензия | Цена | Рекомендация |
+|---|---|---|---|---|---|---|---|
+| **@xyflow/react** | 35,500 | 4,900,000 | Нативный | Да | MIT | Free | ✅ **Выбор** |
+| Rete.js | 11,900 | 42,700 | Плагин | Да | MIT | Free | Альтернатива |
+| AntV X6 | 6,400 | 68,200 | Обёртка | Да | MIT | Free | Для Ant Design |
+| JointJS | 5,200 | 51,900 | @joint/react | Да | MPL/Comm. | от $2,990 | Enterprise |
+| Остальные | <8K | <15K | Нет/Частично | Нет | MIT | Free | Не подходят |
+
+---
+
+## Часть 2: Как AI-платформы реализуют workflow editors
+
+### Кто какую библиотеку использует
+
+| Платформа | Stars | Библиотека | Фреймворк |
+|---|---|---|---|
+| **n8n** | ~177K | Vue Flow (Vue-порт React Flow) | Vue 3 + Pinia |
+| **Langflow** | ~130K | React Flow | React + FastAPI |
+| **Dify** | ~119K | React Flow | Next.js + React 19 + Flask |
+| **Flowise** | ~43K | React Flow | React + Express |
+| **ComfyUI** | ~103K | LiteGraph.js → Vue (мигрирует) | Vue 3 + Pinia |
+| **Rivet** | ~4.5K | Кастомный canvas | React + Tauri |
+| **Promptflow** | ~11K | Кастомный DAG в VS Code | VS Code webview |
+| **Haystack** | ~24K | Закрытый фронтенд | Python + hosted UI |
+
+**React Flow используют 4 из 8 платформ** — де-факто стандарт.
+
+---
+
+### Лучшие UX-паттерны для заимствования
+
+| Паттерн | Из платформы | Описание |
+|---|---|---|
+| **Relationships Panel** | Dify | Shift+click подсвечивает связи узла, затемняя остальное |
+| **Real-time data flow** | Rivet | При execution видны данные, текущие через каждый wire |
+| **Inline prompt editor** | Dify, Langflow | Редактирование промпта прямо в узле на канвасе |
+| **AI Graph Creator** | Rivet | CMD+I — AI создает/редактирует граф по промпту |
+| **Prompt optimization** | Dify | AI-ассистент автоматически оптимизирует промпт |
+| **Auto-fix code** | Dify | Если Code-узел падает, AI генерирует исправление |
+| **HITL checkpoint** | Flowise | Агент останавливается и запрашивает confirmation у человека |
+| **Flow-as-subflow** | Flowise, Rivet | Flow вызывает другой flow как функцию |
+| **YAML/JSON export** | Rivet, Promptflow | Графы как код = version control и diff |
+| **Typed & colored ports** | ComfyUI, Dify | Цветовое кодирование портов по типу данных |
+| **Playground/Chat** | Langflow, Dify | Встроенный чат для тестирования без деплоя |
+| **Variable live tracking** | Dify | При debug-запуске видны значения переменных в каждом узле |
+
+---
+
+### Главный тренд 2025: Agent Node
+
+Dify, Flowise, Langflow — все добавили **"Agent Node"** — узел, где LLM сам решает какие tools вызывать. Workflow перестаёт быть чисто детерминированным DAG-ом и включает LLM-driven branching.
+
+---
+
+### React Flow vs Custom Canvas — когда что
+
+| Подход | Когда использовать | Пример |
+|---|---|---|
+| **React Flow** | <500 нод, нужна быстрая разработка, Rich UI | Dify, Langflow, Flowise |
+| **Custom Canvas2D** | Тысячи нод, max performance | ComfyUI (но мигрирует на Vue!) |
+| **Кастомный canvas** | Desktop app, полный контроль | Rivet (Tauri) |
+
+ComfyUI мигрирует с Canvas2D на Vue-компоненты — показатель того, что **гибкость UI важнее raw performance**.
+
+---
+
+## Решение
+
+**@xyflow/react (React Flow)** — единственный обоснованный выбор:
+
+1. 4 из 8 крупнейших AI-платформ используют React Flow
+2. ~5M загрузок/неделю, 35.5K stars, MIT лицензия
+3. Нативный React, TypeScript, shadcn-совместимые компоненты
+4. Кастомные ноды = обычные React-компоненты (идеально для нашего стека)
+5. Отличная документация и активная команда
+
+### Конкретный план интеграции
+
+```bash
+pnpm add @xyflow/react
+```
+
+Типы нод для нашего workflow editor:
+- **AgentNode** — Claude Code агент (настройка модели, промпта, tools)
+- **TeamNode** — группа агентов с ролями
+- **TaskNode** — задача для агента
+- **ConditionNode** — IF/ELSE ветвление
+- **MCP ServerNode** — подключение MCP-сервера
+- **SkillNode** — подключение skill/plugin
+- **TriggerNode** — запуск workflow (cron, webhook, manual)
+- **OutputNode** — результат (файл, PR, сообщение)
+
+Sources:
+- [xyflow/xyflow GitHub](https://github.com/xyflow/xyflow)
+- [React Flow Components (shadcn)](https://xyflow.com/blog/react-flow-components)
+- [React Flow Showcase](https://reactflow.dev/showcase)
+- [Dify Workflow Source](https://github.com/langgenius/dify/blob/main/web/app/components/workflow/index.tsx)
+- [n8n Canvas Architecture](https://deepwiki.com/n8n-io/n8n-docs/2.2-editor-ui)
+- [Flowise Agentflow V2](https://docs.flowiseai.com/using-flowise/agentflowv2)
+- [Rivet GitHub](https://github.com/Ironclad/rivet)
+- [ComfyUI Nodes 2.0](https://docs.comfy.org/interface/nodes-2)
diff --git a/src/main/index.ts b/src/main/index.ts
index ff729dae..7adf3dc0 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -438,48 +438,60 @@ function wireFileWatcherEvents(context: ServiceContext): void {
}
httpServer?.broadcast('team-change', event);
- // Process inbox change events — relay to lead + native OS notifications.
+ // Process inbox and task change events.
try {
if (!event || typeof event !== 'object') return;
const row = event as { type?: unknown; teamName?: unknown; detail?: unknown };
- if (row.type !== 'inbox') return;
if (typeof row.teamName !== 'string' || row.teamName.trim().length === 0) return;
const teamName = row.teamName.trim();
const detail = typeof row.detail === 'string' ? row.detail : '';
- // Auto-relay direct messages to live team lead process (no UI dependency).
- if (teamProvisioningService.isTeamAlive(teamName)) {
- void teamProvisioningService
- .relayLeadInboxMessages(teamName)
- .catch((e: unknown) => logger.warn(`[FileWatcher] relay failed for ${teamName}: ${e}`));
+ // --- Inbox change events: relay to lead + native OS notifications ---
+ if (row.type === 'inbox') {
+ // Auto-relay direct messages to live team lead process (no UI dependency).
+ if (teamProvisioningService.isTeamAlive(teamName)) {
+ void teamProvisioningService
+ .relayLeadInboxMessages(teamName)
+ .catch((e: unknown) => logger.warn(`[FileWatcher] relay failed for ${teamName}: ${e}`));
+ }
+
+ // Show native OS notification for new inbox messages (debounced per inbox).
+ if (detail.startsWith('inboxes/')) {
+ const timerKey = `${teamName}:${detail}`;
+ const existing = inboxNotifyTimers.get(timerKey);
+ if (existing) clearTimeout(existing);
+ inboxNotifyTimers.set(
+ timerKey,
+ setTimeout(() => {
+ inboxNotifyTimers.delete(timerKey);
+ void notifyNewInboxMessages(teamName, detail).catch(() => undefined);
+ }, INBOX_NOTIFY_DEBOUNCE_MS)
+ );
+ }
+
+ // Show native OS notification for new lead → user messages (sentMessages.json).
+ if (detail === 'sentMessages.json') {
+ const timerKey = `${teamName}:sentMessages`;
+ const existing = inboxNotifyTimers.get(timerKey);
+ if (existing) clearTimeout(existing);
+ inboxNotifyTimers.set(
+ timerKey,
+ setTimeout(() => {
+ inboxNotifyTimers.delete(timerKey);
+ void notifyNewSentMessages(teamName).catch(() => undefined);
+ }, INBOX_NOTIFY_DEBOUNCE_MS)
+ );
+ }
}
- // Show native OS notification for new inbox messages (debounced per inbox).
- if (detail.startsWith('inboxes/')) {
- const timerKey = `${teamName}:${detail}`;
- const existing = inboxNotifyTimers.get(timerKey);
- if (existing) clearTimeout(existing);
- inboxNotifyTimers.set(
- timerKey,
- setTimeout(() => {
- inboxNotifyTimers.delete(timerKey);
- void notifyNewInboxMessages(teamName, detail).catch(() => undefined);
- }, INBOX_NOTIFY_DEBOUNCE_MS)
- );
- }
-
- // Show native OS notification for new lead → user messages (sentMessages.json).
- if (detail === 'sentMessages.json') {
- const timerKey = `${teamName}:sentMessages`;
- const existing = inboxNotifyTimers.get(timerKey);
- if (existing) clearTimeout(existing);
- inboxNotifyTimers.set(
- timerKey,
- setTimeout(() => {
- inboxNotifyTimers.delete(timerKey);
- void notifyNewSentMessages(teamName).catch(() => undefined);
- }, INBOX_NOTIFY_DEBOUNCE_MS)
- );
+ // --- Task change events: notify lead when teammate starts a task via CLI ---
+ if (row.type === 'task' && detail.endsWith('.json') && teamDataService) {
+ const taskId = detail.replace('.json', '');
+ void teamDataService
+ .notifyLeadOnTeammateTaskStart(teamName, taskId)
+ .catch((e: unknown) =>
+ logger.warn(`[FileWatcher] task start notify failed for ${teamName}#${taskId}: ${e}`)
+ );
}
} catch {
// ignore
diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts
index 13be0714..29206169 100644
--- a/src/main/ipc/teams.ts
+++ b/src/main/ipc/teams.ts
@@ -23,6 +23,7 @@ import {
TEAM_KILL_PROCESS,
TEAM_LAUNCH,
TEAM_LEAD_ACTIVITY,
+ TEAM_LEAD_CONTEXT,
TEAM_LIST,
TEAM_PERMANENTLY_DELETE,
TEAM_PREPARE_PROVISIONING,
@@ -49,6 +50,9 @@ import {
TEAM_UPDATE_TASK_FIELDS,
TEAM_UPDATE_TASK_OWNER,
TEAM_UPDATE_TASK_STATUS,
+ TEAM_SAVE_TASK_ATTACHMENT,
+ TEAM_GET_TASK_ATTACHMENT,
+ TEAM_DELETE_TASK_ATTACHMENT,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
} from '@preload/constants/ipcChannels';
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
@@ -75,6 +79,7 @@ const notifiedRateLimitKeys = new Set();
const RATE_LIMIT_KEYS_MAX = 500;
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
+import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
import type {
MemberStatsComputer,
@@ -84,16 +89,19 @@ import type {
} from '../services';
import type {
AttachmentFileData,
+ AttachmentMediaType,
AttachmentMeta,
AttachmentPayload,
CreateTaskRequest,
GlobalTask,
IpcResult,
KanbanColumnId,
+ LeadContextUsage,
MemberFullStats,
MemberLogSummary,
SendMessageRequest,
SendMessageResult,
+ TaskAttachmentMeta,
TaskComment,
TeamConfig,
TeamCreateConfigRequest,
@@ -165,6 +173,7 @@ let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
let memberStatsComputer: MemberStatsComputer | null = null;
const attachmentStore = new TeamAttachmentStore();
+const taskAttachmentStore = new TeamTaskAttachmentStore();
const ALLOWED_ATTACHMENT_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB per file
@@ -222,6 +231,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments);
ipcMain.handle(TEAM_KILL_PROCESS, handleKillProcess);
ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity);
+ ipcMain.handle(TEAM_LEAD_CONTEXT, handleLeadContext);
ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask);
ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask);
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
@@ -229,6 +239,9 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_SHOW_MESSAGE_NOTIFICATION, handleShowMessageNotification);
ipcMain.handle(TEAM_ADD_TASK_RELATIONSHIP, handleAddTaskRelationship);
ipcMain.handle(TEAM_REMOVE_TASK_RELATIONSHIP, handleRemoveTaskRelationship);
+ ipcMain.handle(TEAM_SAVE_TASK_ATTACHMENT, handleSaveTaskAttachment);
+ ipcMain.handle(TEAM_GET_TASK_ATTACHMENT, handleGetTaskAttachment);
+ ipcMain.handle(TEAM_DELETE_TASK_ATTACHMENT, handleDeleteTaskAttachment);
logger.info('Team handlers registered');
}
@@ -271,6 +284,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_GET_ATTACHMENTS);
ipcMain.removeHandler(TEAM_KILL_PROCESS);
ipcMain.removeHandler(TEAM_LEAD_ACTIVITY);
+ ipcMain.removeHandler(TEAM_LEAD_CONTEXT);
ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK);
ipcMain.removeHandler(TEAM_RESTORE_TASK);
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
@@ -278,6 +292,9 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_SHOW_MESSAGE_NOTIFICATION);
ipcMain.removeHandler(TEAM_ADD_TASK_RELATIONSHIP);
ipcMain.removeHandler(TEAM_REMOVE_TASK_RELATIONSHIP);
+ ipcMain.removeHandler(TEAM_SAVE_TASK_ATTACHMENT);
+ ipcMain.removeHandler(TEAM_GET_TASK_ATTACHMENT);
+ ipcMain.removeHandler(TEAM_DELETE_TASK_ATTACHMENT);
}
function getTeamDataService(): TeamDataService {
@@ -1280,12 +1297,21 @@ async function handleUpdateTaskOwner(
return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' };
}
- if (owner !== null && (typeof owner !== 'string' || owner.length === 0)) {
- return { success: false, error: 'owner must be a non-empty string or null' };
+ let nextOwner: string | null = null;
+ if (owner !== null) {
+ const validatedOwner = validateMemberName(owner);
+ if (!validatedOwner.valid) {
+ return { success: false, error: validatedOwner.error ?? 'Invalid owner' };
+ }
+ nextOwner = validatedOwner.value!;
}
return wrapTeamHandler('updateTaskOwner', () =>
- getTeamDataService().updateTaskOwner(validatedTeamName.value!, validatedTaskId.value!, owner)
+ getTeamDataService().updateTaskOwner(
+ validatedTeamName.value!,
+ validatedTaskId.value!,
+ nextOwner
+ )
);
}
@@ -1510,6 +1536,19 @@ async function handleLeadActivity(
);
}
+async function handleLeadContext(
+ _event: IpcMainInvokeEvent,
+ teamName: unknown
+): Promise> {
+ const validated = validateTeamName(teamName);
+ if (!validated.valid) {
+ return { success: false, error: validated.error ?? 'Invalid teamName' };
+ }
+ return wrapTeamHandler('leadContext', async () =>
+ getTeamProvisioningService().getLeadContextUsage(validated.value!)
+ );
+}
+
async function handleStopTeam(
_event: IpcMainInvokeEvent,
teamName: unknown
@@ -1680,9 +1719,9 @@ async function handleUpdateTaskFields(
): Promise> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
- if (typeof taskId !== 'string' || !taskId.trim()) {
- return { success: false, error: 'taskId must be a non-empty string' };
- }
+ const vTask = validateTaskId(taskId);
+ if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
+ const tid = vTask.value!;
if (!fields || typeof fields !== 'object') {
return { success: false, error: 'fields must be an object' };
}
@@ -1698,7 +1737,7 @@ async function handleUpdateTaskFields(
}
const validFields: { subject?: string; description?: string } = {};
- if (typeof subject === 'string') validFields.subject = subject;
+ if (typeof subject === 'string') validFields.subject = subject.trim();
if (typeof description === 'string') validFields.description = description;
if (Object.keys(validFields).length === 0) {
@@ -1707,7 +1746,7 @@ async function handleUpdateTaskFields(
return wrapTeamHandler('updateTaskFields', async () => {
const tn = vTeam.value!;
- await getTeamDataService().updateTaskFields(tn, taskId, validFields);
+ await getTeamDataService().updateTaskFields(tn, tid, validFields);
// Notify the lead about updated task fields
const provisioning = getTeamProvisioningService();
@@ -1716,12 +1755,12 @@ async function handleUpdateTaskFields(
if (validFields.subject) changedParts.push('title');
if (validFields.description !== undefined) changedParts.push('description');
const message =
- `Task #${taskId} has been updated by the user (changed: ${changedParts.join(', ')}). ` +
+ `Task #${tid} has been updated by the user (changed: ${changedParts.join(', ')}). ` +
`New title: "${validFields.subject ?? '(unchanged)'}".`;
try {
await provisioning.sendMessageToTeam(tn, message);
} catch {
- logger.warn(`Failed to notify lead about task fields update for #${taskId} in ${tn}`);
+ logger.warn(`Failed to notify lead about task fields update for #${tid} in ${tn}`);
}
}
});
@@ -1897,7 +1936,8 @@ async function handleAddTaskComment(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown,
- text: unknown
+ text: unknown,
+ attachments?: unknown
): Promise> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
@@ -1908,9 +1948,54 @@ async function handleAddTaskComment(
if (text.trim().length > 2000)
return { success: false, error: 'Comment exceeds 2000 characters' };
- return wrapTeamHandler('addTaskComment', () =>
- getTeamDataService().addTaskComment(vTeam.value!, vTask.value!, text.trim())
- );
+ const rawAttachments = Array.isArray(attachments) ? attachments : [];
+ if (rawAttachments.length > MAX_ATTACHMENTS) {
+ return { success: false, error: `Maximum ${MAX_ATTACHMENTS} attachments per comment` };
+ }
+
+ return wrapTeamHandler('addTaskComment', async () => {
+ // Save comment attachments (images). Done inside wrapTeamHandler so failures return IpcResult.
+ let savedAttachments: TaskAttachmentMeta[] | undefined;
+ if (rawAttachments.length > 0) {
+ savedAttachments = [];
+ for (const att of rawAttachments) {
+ if (!att || typeof att !== 'object') {
+ throw new Error('Invalid attachment data');
+ }
+ const a = att as Record;
+ if (
+ typeof a.id !== 'string' ||
+ typeof a.filename !== 'string' ||
+ typeof a.mimeType !== 'string' ||
+ typeof a.base64Data !== 'string' ||
+ a.base64Data.length === 0 ||
+ !ALLOWED_ATTACHMENT_TYPES.has(a.mimeType)
+ ) {
+ throw new Error('Invalid attachment data');
+ }
+ const safeId = a.id.trim();
+ if (safeId.includes('/') || safeId.includes('\\') || safeId.includes('..')) {
+ throw new Error('Invalid attachment ID');
+ }
+ const meta = await taskAttachmentStore.saveAttachment(
+ vTeam.value!,
+ vTask.value!,
+ safeId,
+ a.filename,
+ a.mimeType as AttachmentMediaType,
+ a.base64Data
+ );
+ savedAttachments.push(meta);
+ }
+ }
+
+ return getTeamDataService().addTaskComment(
+ vTeam.value!,
+ vTask.value!,
+ text.trim(),
+ savedAttachments
+ );
+ });
}
const VALID_RELATIONSHIP_TYPES = ['blockedBy', 'blocks', 'related'] as const;
@@ -1975,3 +2060,122 @@ async function handleRemoveTaskRelationship(
)
);
}
+
+// ---------------------------------------------------------------------------
+// Task Attachment Handlers
+// ---------------------------------------------------------------------------
+
+async function handleSaveTaskAttachment(
+ _event: IpcMainInvokeEvent,
+ teamName: unknown,
+ taskId: unknown,
+ attachmentId: unknown,
+ filename: unknown,
+ mimeType: unknown,
+ base64Data: unknown
+): Promise> {
+ const vTeam = validateTeamName(teamName);
+ if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
+ const vTask = validateTaskId(taskId);
+ if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
+ if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) {
+ return { success: false, error: 'attachmentId must be a non-empty string' };
+ }
+ if (typeof filename !== 'string' || filename.trim().length === 0) {
+ return { success: false, error: 'filename must be a non-empty string' };
+ }
+ if (typeof mimeType !== 'string' || !ALLOWED_ATTACHMENT_TYPES.has(mimeType)) {
+ return {
+ success: false,
+ error: `mimeType must be one of: ${[...ALLOWED_ATTACHMENT_TYPES].join(', ')}`,
+ };
+ }
+ if (typeof base64Data !== 'string' || base64Data.length === 0) {
+ return { success: false, error: 'base64Data must be a non-empty string' };
+ }
+ // Sanitize IDs against path traversal
+ const safeAttId = attachmentId.trim();
+ if (safeAttId.includes('/') || safeAttId.includes('\\') || safeAttId.includes('..')) {
+ return { success: false, error: 'Invalid attachmentId' };
+ }
+
+ return wrapTeamHandler('saveTaskAttachment', async () => {
+ const meta = await taskAttachmentStore.saveAttachment(
+ vTeam.value!,
+ vTask.value!,
+ safeAttId,
+ filename as string,
+ mimeType as AttachmentMediaType,
+ base64Data as string
+ );
+ // Write metadata into the task JSON
+ await getTeamDataService().addTaskAttachment(vTeam.value!, vTask.value!, meta);
+ return meta;
+ });
+}
+
+async function handleGetTaskAttachment(
+ _event: IpcMainInvokeEvent,
+ teamName: unknown,
+ taskId: unknown,
+ attachmentId: unknown,
+ mimeType: unknown
+): Promise> {
+ const vTeam = validateTeamName(teamName);
+ if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
+ const vTask = validateTaskId(taskId);
+ if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
+ if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) {
+ return { success: false, error: 'attachmentId must be a non-empty string' };
+ }
+ if (typeof mimeType !== 'string' || !ALLOWED_ATTACHMENT_TYPES.has(mimeType)) {
+ return { success: false, error: 'Invalid mimeType' };
+ }
+ const safeAttId = attachmentId.trim();
+ if (safeAttId.includes('/') || safeAttId.includes('\\') || safeAttId.includes('..')) {
+ return { success: false, error: 'Invalid attachmentId' };
+ }
+
+ return wrapTeamHandler('getTaskAttachment', () =>
+ taskAttachmentStore.getAttachment(
+ vTeam.value!,
+ vTask.value!,
+ safeAttId,
+ mimeType as AttachmentMediaType
+ )
+ );
+}
+
+async function handleDeleteTaskAttachment(
+ _event: IpcMainInvokeEvent,
+ teamName: unknown,
+ taskId: unknown,
+ attachmentId: unknown,
+ mimeType: unknown
+): Promise> {
+ const vTeam = validateTeamName(teamName);
+ if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
+ const vTask = validateTaskId(taskId);
+ if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
+ if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) {
+ return { success: false, error: 'attachmentId must be a non-empty string' };
+ }
+ if (typeof mimeType !== 'string' || !ALLOWED_ATTACHMENT_TYPES.has(mimeType)) {
+ return { success: false, error: 'Invalid mimeType' };
+ }
+ const safeAttId = attachmentId.trim();
+ if (safeAttId.includes('/') || safeAttId.includes('\\') || safeAttId.includes('..')) {
+ return { success: false, error: 'Invalid attachmentId' };
+ }
+
+ return wrapTeamHandler('deleteTaskAttachment', async () => {
+ await taskAttachmentStore.deleteAttachment(
+ vTeam.value!,
+ vTask.value!,
+ safeAttId,
+ mimeType as AttachmentMediaType
+ );
+ // Remove metadata from task JSON
+ await getTeamDataService().removeTaskAttachment(vTeam.value!, vTask.value!, safeAttId);
+ });
+}
diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts
index a7c86dad..c9056f04 100644
--- a/src/main/services/discovery/ProjectScanner.ts
+++ b/src/main/services/discovery/ProjectScanner.ts
@@ -703,6 +703,11 @@ export class ProjectScanner {
let startIndex = 0;
if (cursor) {
try {
+ // Defensive limit: cursor originates from a query param / IPC input and should be tiny.
+ // Prevent pathological memory allocation on Buffer.from(cursor, 'base64').
+ if (cursor.length > 4096) {
+ throw new Error('cursor too large');
+ }
const decoded = JSON.parse(
Buffer.from(cursor, 'base64').toString('utf8')
) as SessionCursor;
diff --git a/src/main/services/team/TeamAttachmentStore.ts b/src/main/services/team/TeamAttachmentStore.ts
index c402c895..cd4d36d7 100644
--- a/src/main/services/team/TeamAttachmentStore.ts
+++ b/src/main/services/team/TeamAttachmentStore.ts
@@ -12,11 +12,25 @@ const logger = createLogger('Service:TeamAttachmentStore');
const ATTACHMENTS_DIR = 'attachments';
export class TeamAttachmentStore {
+ private assertSafePathSegment(label: string, value: string): void {
+ if (
+ value.length === 0 ||
+ value.includes('/') ||
+ value.includes('\\') ||
+ value.includes('..') ||
+ value.includes('\0')
+ ) {
+ throw new Error(`Invalid ${label}`);
+ }
+ }
+
private getDir(teamName: string): string {
+ this.assertSafePathSegment('teamName', teamName);
return path.join(getTeamsBasePath(), teamName, ATTACHMENTS_DIR);
}
private getFilePath(teamName: string, messageId: string): string {
+ this.assertSafePathSegment('messageId', messageId);
return path.join(this.getDir(teamName), `${messageId}.json`);
}
diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts
index 64fd6c37..2f9cc319 100644
--- a/src/main/services/team/TeamDataService.ts
+++ b/src/main/services/team/TeamDataService.ts
@@ -65,6 +65,8 @@ const TASK_MAP_YIELD_EVERY = 250;
export class TeamDataService {
private processHealthTimer: ReturnType | null = null;
private processHealthTeams = new Set();
+ /** Tracks notified task-start transitions to avoid duplicate lead notifications. */
+ private notifiedTaskStarts = new Set();
constructor(
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
@@ -873,6 +875,48 @@ export class TeamDataService {
await this.taskWriter.updateStatus(teamName, taskId, status, actor);
}
+ /**
+ * Called when a task file changes on disk (e.g. teammate CLI wrote it).
+ * If the latest statusHistory entry shows a non-user actor started the task,
+ * sends an inbox notification to the team lead.
+ */
+ async notifyLeadOnTeammateTaskStart(teamName: string, taskId: string): Promise {
+ try {
+ const tasks = await this.taskReader.getTasks(teamName);
+ const task = tasks.find((t) => t.id === taskId);
+ if (!task) return;
+
+ const history = task.statusHistory;
+ if (!Array.isArray(history) || history.length === 0) return;
+
+ const last = history[history.length - 1];
+ if (last.to !== 'in_progress') return;
+ if (!last.actor || last.actor === 'user') return;
+
+ // Dedup: only notify once per unique transition (keyed by team+task+timestamp).
+ const dedupKey = `${teamName}:${taskId}:${last.timestamp}`;
+ if (this.notifiedTaskStarts.has(dedupKey)) return;
+ this.notifiedTaskStarts.add(dedupKey);
+ // Prevent unbounded growth in long-running sessions.
+ if (this.notifiedTaskStarts.size > 500) {
+ const first = this.notifiedTaskStarts.values().next().value!;
+ this.notifiedTaskStarts.delete(first);
+ }
+
+ const leadName = await this.resolveLeadName(teamName);
+ if (this.isLeadOwner(last.actor, leadName)) return;
+
+ await this.sendMessage(teamName, {
+ member: leadName,
+ from: last.actor,
+ text: `Task #${task.id} "${task.subject}" has been started by ${last.actor}.`,
+ summary: `Task #${task.id} started`,
+ });
+ } catch (error) {
+ logger.warn(`[TeamDataService] notifyLeadOnTeammateTaskStart failed: ${error}`);
+ }
+ }
+
async softDeleteTask(teamName: string, taskId: string): Promise {
await this.taskWriter.softDelete(teamName, taskId, 'user');
}
@@ -897,6 +941,22 @@ export class TeamDataService {
await this.taskWriter.updateFields(teamName, taskId, fields);
}
+ async addTaskAttachment(
+ teamName: string,
+ taskId: string,
+ meta: import('@shared/types').TaskAttachmentMeta
+ ): Promise {
+ await this.taskWriter.addAttachment(teamName, taskId, meta);
+ }
+
+ async removeTaskAttachment(
+ teamName: string,
+ taskId: string,
+ attachmentId: string
+ ): Promise {
+ await this.taskWriter.removeAttachment(teamName, taskId, attachmentId);
+ }
+
async setTaskNeedsClarification(
teamName: string,
taskId: string,
@@ -923,8 +983,15 @@ export class TeamDataService {
await this.taskWriter.removeRelationship(teamName, taskId, targetId, type);
}
- async addTaskComment(teamName: string, taskId: string, text: string): Promise {
- const comment = await this.taskWriter.addComment(teamName, taskId, text);
+ async addTaskComment(
+ teamName: string,
+ taskId: string,
+ text: string,
+ attachments?: import('@shared/types').TaskAttachmentMeta[]
+ ): Promise {
+ const comment = await this.taskWriter.addComment(teamName, taskId, text, {
+ attachments,
+ });
try {
const [tasks, toolPath, config] = await Promise.all([
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index 74da9ae6..a6a44fe8 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -37,6 +37,7 @@ import { TeamTaskReader } from './TeamTaskReader';
import type {
InboxMessage,
+ LeadContextUsage,
TeamChangeEvent,
TeamCreateRequest,
TeamCreateResponse,
@@ -58,7 +59,7 @@ const LOG_PROGRESS_THROTTLE_MS = 300;
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
const SHELL_ENV_TIMEOUT_MS = 12000;
// const CLI_PREPARE_TIMEOUT_MS = 10000;
-const PROBE_CACHE_TTL_MS = 60_000;
+const PROBE_CACHE_TTL_MS = 10 * 60_000;
const PREFLIGHT_TIMEOUT_MS = 30000;
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
@@ -154,6 +155,13 @@ interface ProvisioningRun {
authFailureRetried: boolean;
/** Set to true while auth-failure respawn is in progress to prevent duplicate handling. */
authRetryInProgress: boolean;
+ /** Tracks lead process context window usage from stream-json usage data. */
+ leadContextUsage: {
+ currentTokens: number;
+ contextWindow: number;
+ lastUsageMessageId: string | null;
+ lastEmittedAt: number;
+ } | null;
/** Saved spawn context for auth-failure respawn. */
spawnContext: {
claudePath: string;
@@ -1014,6 +1022,16 @@ export class TeamProvisioningService {
return run.leadActivityState;
}
+ getLeadContextUsage(teamName: string): LeadContextUsage | null {
+ const runId = this.activeByTeam.get(teamName);
+ if (!runId) return null;
+ const run = this.runs.get(runId);
+ if (!run?.leadContextUsage || run.processKilled || run.cancelRequested) return null;
+ const { currentTokens, contextWindow } = run.leadContextUsage;
+ const percent = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0;
+ return { currentTokens, contextWindow, percent, updatedAt: new Date().toISOString() };
+ }
+
private setLeadActivity(run: ProvisioningRun, state: 'active' | 'idle' | 'offline'): void {
if (run.leadActivityState === state) return;
run.leadActivityState = state;
@@ -1024,6 +1042,33 @@ export class TeamProvisioningService {
});
}
+ private static readonly CONTEXT_EMIT_THROTTLE_MS = 2000;
+
+ private emitLeadContextUsage(run: ProvisioningRun): void {
+ if (!run.leadContextUsage || !run.provisioningComplete) return;
+ const now = Date.now();
+ if (
+ now - run.leadContextUsage.lastEmittedAt <
+ TeamProvisioningService.CONTEXT_EMIT_THROTTLE_MS
+ ) {
+ return;
+ }
+ run.leadContextUsage.lastEmittedAt = now;
+ const { currentTokens, contextWindow } = run.leadContextUsage;
+ const percent = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0;
+ const payload: LeadContextUsage = {
+ currentTokens,
+ contextWindow,
+ percent,
+ updatedAt: new Date().toISOString(),
+ };
+ this.teamChangeEmitter?.({
+ type: 'lead-context',
+ teamName: run.teamName,
+ detail: JSON.stringify(payload),
+ });
+ }
+
async warmup(): Promise {
try {
if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS) {
@@ -1433,6 +1478,7 @@ export class TeamProvisioningService {
provisioningOutputParts: [],
detectedSessionId: null,
leadActivityState: 'active',
+ leadContextUsage: null,
authFailureRetried: false,
authRetryInProgress: false,
spawnContext: null,
@@ -1716,6 +1762,7 @@ export class TeamProvisioningService {
provisioningOutputParts: [],
detectedSessionId: null,
leadActivityState: 'active',
+ leadContextUsage: null,
authFailureRetried: false,
authRetryInProgress: false,
spawnContext: null,
@@ -2464,6 +2511,40 @@ export class TeamProvisioningService {
if (run.provisioningComplete) {
this.captureSendMessageToUser(run, content ?? []);
}
+
+ // Extract context window usage from message.usage for real-time tracking.
+ // SDKAssistantMessage wraps BetaMessage which contains usage stats.
+ const messageObj = (msg.message ?? msg) as Record;
+ if (messageObj && typeof messageObj === 'object') {
+ const msgId = typeof messageObj.id === 'string' ? messageObj.id : null;
+ const usage = messageObj.usage as Record | undefined;
+ if (usage && typeof usage === 'object') {
+ // Dedup: skip if same message.id (SDK bug: multi-block = same usage repeated)
+ if (!msgId || run.leadContextUsage?.lastUsageMessageId !== msgId) {
+ const inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0;
+ const cacheCreation =
+ typeof usage.cache_creation_input_tokens === 'number'
+ ? usage.cache_creation_input_tokens
+ : 0;
+ const cacheRead =
+ typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0;
+ const currentTokens = inputTokens + cacheCreation + cacheRead;
+
+ if (!run.leadContextUsage) {
+ run.leadContextUsage = {
+ currentTokens,
+ contextWindow: 200_000,
+ lastUsageMessageId: msgId,
+ lastEmittedAt: 0,
+ };
+ } else {
+ run.leadContextUsage.currentTokens = currentTokens;
+ run.leadContextUsage.lastUsageMessageId = msgId;
+ }
+ this.emitLeadContextUsage(run);
+ }
+ }
+ }
}
// Capture session_id from any message type (first occurrence wins)
@@ -2489,6 +2570,53 @@ export class TeamProvisioningService {
})();
if (subtype === 'success') {
logger.info(`[${run.teamName}] stream-json result: success — turn complete, process alive`);
+
+ // Extract contextWindow from modelUsage if available (SDKResultSuccess.modelUsage)
+ const modelUsageObj = (msg.modelUsage ??
+ (msg.result as Record | undefined)?.modelUsage) as
+ | Record>
+ | undefined;
+ if (modelUsageObj && typeof modelUsageObj === 'object') {
+ for (const modelData of Object.values(modelUsageObj)) {
+ if (
+ modelData &&
+ typeof modelData === 'object' &&
+ typeof modelData.contextWindow === 'number' &&
+ modelData.contextWindow > 0
+ ) {
+ if (run.leadContextUsage) {
+ run.leadContextUsage.contextWindow = modelData.contextWindow;
+ run.leadContextUsage.lastEmittedAt = 0; // force re-emit
+ this.emitLeadContextUsage(run);
+ }
+ break;
+ }
+ }
+ }
+
+ // Extract usage from result message itself (final turn usage)
+ const resultUsage = (msg.usage ??
+ (msg.result as Record | undefined)?.usage) as
+ | Record
+ | undefined;
+ if (resultUsage && typeof resultUsage === 'object') {
+ const inp = typeof resultUsage.input_tokens === 'number' ? resultUsage.input_tokens : 0;
+ const cc =
+ typeof resultUsage.cache_creation_input_tokens === 'number'
+ ? resultUsage.cache_creation_input_tokens
+ : 0;
+ const cr =
+ typeof resultUsage.cache_read_input_tokens === 'number'
+ ? resultUsage.cache_read_input_tokens
+ : 0;
+ const total = inp + cc + cr;
+ if (total > 0 && run.leadContextUsage) {
+ run.leadContextUsage.currentTokens = total;
+ run.leadContextUsage.lastEmittedAt = 0;
+ this.emitLeadContextUsage(run);
+ }
+ }
+
if (run.provisioningComplete) {
this.setLeadActivity(run, 'idle');
}
@@ -2585,6 +2713,15 @@ export class TeamProvisioningService {
}
}
}
+
+ // Handle compact_boundary — context was compacted, next assistant message will carry fresh usage
+ if (msg.type === 'system') {
+ const sub = typeof msg.subtype === 'string' ? msg.subtype : undefined;
+ if (sub === 'compact_boundary' && run.leadContextUsage) {
+ run.leadContextUsage.lastUsageMessageId = null;
+ logger.info(`[${run.teamName}] compact_boundary — context will refresh on next turn`);
+ }
+ }
}
/**
diff --git a/src/main/services/team/TeamTaskAttachmentStore.ts b/src/main/services/team/TeamTaskAttachmentStore.ts
new file mode 100644
index 00000000..9661df70
--- /dev/null
+++ b/src/main/services/team/TeamTaskAttachmentStore.ts
@@ -0,0 +1,168 @@
+import { getTeamsBasePath } from '@main/utils/pathDecoder';
+import { createLogger } from '@shared/utils/logger';
+import * as fs from 'fs';
+import * as path from 'path';
+
+import type { AttachmentMediaType, TaskAttachmentMeta } from '@shared/types';
+
+const logger = createLogger('Service:TeamTaskAttachmentStore');
+
+const TASK_ATTACHMENTS_DIR = 'task-attachments';
+const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB
+
+const ALLOWED_MIME_TYPES: ReadonlySet = new Set([
+ 'image/png',
+ 'image/jpeg',
+ 'image/gif',
+ 'image/webp',
+]);
+
+export class TeamTaskAttachmentStore {
+ private assertSafePathSegment(label: string, value: string): void {
+ if (
+ value.length === 0 ||
+ value.includes('/') ||
+ value.includes('\\') ||
+ value.includes('..') ||
+ value.includes('\0')
+ ) {
+ throw new Error(`Invalid ${label}`);
+ }
+ }
+
+ /** Returns the directory for a specific task's attachments. */
+ private getTaskDir(teamName: string, taskId: string): string {
+ this.assertSafePathSegment('teamName', teamName);
+ this.assertSafePathSegment('taskId', taskId);
+ return path.join(getTeamsBasePath(), teamName, TASK_ATTACHMENTS_DIR, taskId);
+ }
+
+ /** Returns the file path for a specific attachment. */
+ private getFilePath(teamName: string, taskId: string, attachmentId: string, ext: string): string {
+ this.assertSafePathSegment('attachmentId', attachmentId);
+ return path.join(this.getTaskDir(teamName, taskId), `${attachmentId}${ext}`);
+ }
+
+ /** Map MIME type to file extension. */
+ private mimeToExt(mimeType: AttachmentMediaType): string {
+ switch (mimeType) {
+ case 'image/png':
+ return '.png';
+ case 'image/jpeg':
+ return '.jpg';
+ case 'image/gif':
+ return '.gif';
+ case 'image/webp':
+ return '.webp';
+ }
+ }
+
+ /**
+ * Save an attachment to disk. Data is expected as a base64-encoded string.
+ * Returns metadata for the saved attachment.
+ */
+ async saveAttachment(
+ teamName: string,
+ taskId: string,
+ attachmentId: string,
+ filename: string,
+ mimeType: AttachmentMediaType,
+ base64Data: string
+ ): Promise {
+ if (!ALLOWED_MIME_TYPES.has(mimeType)) {
+ throw new Error(`Unsupported MIME type: ${mimeType}`);
+ }
+
+ const trimmed = base64Data.trim();
+ // Avoid allocating huge Buffers for obviously too-large payloads.
+ // Base64 decoded size is roughly 3/4 of the string length minus padding.
+ const padding = trimmed.endsWith('==') ? 2 : trimmed.endsWith('=') ? 1 : 0;
+ const estimatedBytes = Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding);
+ if (estimatedBytes > MAX_ATTACHMENT_SIZE) {
+ throw new Error(
+ `Attachment too large: ${(estimatedBytes / (1024 * 1024)).toFixed(1)} MB (max ${MAX_ATTACHMENT_SIZE / (1024 * 1024)} MB)`
+ );
+ }
+
+ const buffer = Buffer.from(trimmed, 'base64');
+ if (buffer.length > MAX_ATTACHMENT_SIZE) {
+ throw new Error(
+ `Attachment too large: ${(buffer.length / (1024 * 1024)).toFixed(1)} MB (max ${MAX_ATTACHMENT_SIZE / (1024 * 1024)} MB)`
+ );
+ }
+
+ const dir = this.getTaskDir(teamName, taskId);
+ await fs.promises.mkdir(dir, { recursive: true });
+
+ const ext = this.mimeToExt(mimeType);
+ const filePath = this.getFilePath(teamName, taskId, attachmentId, ext);
+ await fs.promises.writeFile(filePath, buffer);
+
+ const meta: TaskAttachmentMeta = {
+ id: attachmentId,
+ filename,
+ mimeType,
+ size: buffer.length,
+ addedAt: new Date().toISOString(),
+ };
+
+ logger.debug(`[${teamName}] Saved task attachment ${attachmentId} for task #${taskId}`);
+ return meta;
+ }
+
+ /**
+ * Read an attachment file and return its base64 data.
+ */
+ async getAttachment(
+ teamName: string,
+ taskId: string,
+ attachmentId: string,
+ mimeType: AttachmentMediaType
+ ): Promise {
+ const ext = this.mimeToExt(mimeType);
+ const filePath = this.getFilePath(teamName, taskId, attachmentId, ext);
+
+ try {
+ const buffer = await fs.promises.readFile(filePath);
+ return buffer.toString('base64');
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ return null;
+ }
+ throw error;
+ }
+ }
+
+ /**
+ * Delete an attachment file from disk.
+ */
+ async deleteAttachment(
+ teamName: string,
+ taskId: string,
+ attachmentId: string,
+ mimeType: AttachmentMediaType
+ ): Promise {
+ const ext = this.mimeToExt(mimeType);
+ const filePath = this.getFilePath(teamName, taskId, attachmentId, ext);
+
+ try {
+ await fs.promises.unlink(filePath);
+ logger.debug(`[${teamName}] Deleted task attachment ${attachmentId} for task #${taskId}`);
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
+ throw error;
+ }
+ }
+
+ // Clean up empty directory
+ const dir = this.getTaskDir(teamName, taskId);
+ try {
+ const entries = await fs.promises.readdir(dir);
+ if (entries.length === 0) {
+ await fs.promises.rmdir(dir);
+ }
+ } catch {
+ // ignore cleanup errors
+ }
+ }
+}
diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts
index 9cf4eee9..7b7246f6 100644
--- a/src/main/services/team/TeamTaskReader.ts
+++ b/src/main/services/team/TeamTaskReader.ts
@@ -8,7 +8,9 @@ import * as path from 'path';
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
import type {
+ AttachmentMediaType,
StatusTransition,
+ TaskAttachmentMeta,
TaskComment,
TaskWorkInterval,
TeamTask,
@@ -18,6 +20,13 @@ import type {
const logger = createLogger('Service:TeamTaskReader');
const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024;
+const VALID_ATTACHMENT_MIME_TYPES: ReadonlySet = new Set([
+ 'image/png',
+ 'image/jpeg',
+ 'image/gif',
+ 'image/webp',
+]);
+
export class TeamTaskReader {
/**
* Returns the next available numeric task ID by scanning ALL task files
@@ -187,6 +196,21 @@ export class TeamTaskReader {
type: (['regular', 'review_request', 'review_approved'] as const).includes(c.type)
? c.type
: ('regular' as const),
+ attachments: Array.isArray(c.attachments)
+ ? (c.attachments as unknown[]).filter(
+ (a): a is TaskAttachmentMeta =>
+ Boolean(a) &&
+ typeof a === 'object' &&
+ typeof (a as Record).id === 'string' &&
+ typeof (a as Record).filename === 'string' &&
+ typeof (a as Record).mimeType === 'string' &&
+ VALID_ATTACHMENT_MIME_TYPES.has(
+ (a as Record).mimeType as string
+ ) &&
+ typeof (a as Record).size === 'number' &&
+ typeof (a as Record).addedAt === 'string'
+ )
+ : undefined,
}))
: undefined,
needsClarification: (['lead', 'user'] as const).includes(
@@ -195,6 +219,29 @@ export class TeamTaskReader {
? (parsed.needsClarification as 'lead' | 'user')
: undefined,
deletedAt: undefined, // deleted tasks are filtered out below
+ attachments: Array.isArray(parsed.attachments)
+ ? (parsed.attachments as unknown[])
+ .filter(
+ (a): a is TaskAttachmentMeta =>
+ Boolean(a) &&
+ typeof a === 'object' &&
+ typeof (a as Record).id === 'string' &&
+ typeof (a as Record).filename === 'string' &&
+ typeof (a as Record).mimeType === 'string' &&
+ VALID_ATTACHMENT_MIME_TYPES.has(
+ (a as Record).mimeType as string
+ ) &&
+ typeof (a as Record).size === 'number' &&
+ typeof (a as Record).addedAt === 'string'
+ )
+ .map((a) => ({
+ id: a.id,
+ filename: a.filename,
+ mimeType: a.mimeType,
+ size: a.size,
+ addedAt: a.addedAt,
+ }))
+ : undefined,
} satisfies Record;
if (task.status === 'deleted') {
continue;
diff --git a/src/main/services/team/TeamTaskWriter.ts b/src/main/services/team/TeamTaskWriter.ts
index 757471fa..d1d6603e 100644
--- a/src/main/services/team/TeamTaskWriter.ts
+++ b/src/main/services/team/TeamTaskWriter.ts
@@ -7,6 +7,7 @@ import { atomicWriteAsync } from './atomicWrite';
import type {
StatusTransition,
+ TaskAttachmentMeta,
TaskComment,
TaskCommentType,
TeamTask,
@@ -520,7 +521,13 @@ export class TeamTaskWriter {
teamName: string,
taskId: string,
text: string,
- options?: { id?: string; author?: string; createdAt?: string; type?: TaskCommentType }
+ options?: {
+ id?: string;
+ author?: string;
+ createdAt?: string;
+ type?: TaskCommentType;
+ attachments?: TaskAttachmentMeta[];
+ }
): Promise {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
const comment: TaskComment = {
@@ -529,6 +536,9 @@ export class TeamTaskWriter {
text,
createdAt: options?.createdAt ?? new Date().toISOString(),
type: options?.type ?? 'regular',
+ ...(options?.attachments && options.attachments.length > 0
+ ? { attachments: options.attachments }
+ : {}),
};
await withTaskLock(taskPath, async () => {
@@ -554,4 +564,41 @@ export class TeamTaskWriter {
return comment;
}
+
+ async addAttachment(teamName: string, taskId: string, meta: TaskAttachmentMeta): Promise {
+ const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
+
+ await withTaskLock(taskPath, async () => {
+ const raw = await fs.promises.readFile(taskPath, 'utf8');
+ const task = JSON.parse(raw) as Record;
+ const existing = Array.isArray(task.attachments)
+ ? (task.attachments as TaskAttachmentMeta[])
+ : [];
+ // Dedup by ID
+ if (existing.some((a) => a.id === meta.id)) {
+ return;
+ }
+ task.attachments = [...existing, meta];
+ await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
+ });
+ }
+
+ async removeAttachment(teamName: string, taskId: string, attachmentId: string): Promise {
+ const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
+
+ await withTaskLock(taskPath, async () => {
+ const raw = await fs.promises.readFile(taskPath, 'utf8');
+ const task = JSON.parse(raw) as Record;
+ const existing = Array.isArray(task.attachments)
+ ? (task.attachments as TaskAttachmentMeta[])
+ : [];
+ const filtered = existing.filter((a) => a.id !== attachmentId);
+ if (filtered.length > 0) {
+ task.attachments = filtered;
+ } else {
+ delete task.attachments;
+ }
+ await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
+ });
+ }
}
diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts
index 1853b1ab..ee21dc5d 100644
--- a/src/main/workers/team-fs-worker.ts
+++ b/src/main/workers/team-fs-worker.ts
@@ -110,6 +110,7 @@ interface ParsedTask {
metadata?: { _internal?: unknown };
workIntervals?: unknown;
statusHistory?: unknown;
+ attachments?: unknown;
}
interface RawWorkInterval {
@@ -596,6 +597,9 @@ async function readTasksDirForTeam(
comments: normalizeComments(parsed),
needsClarification,
deletedAt: undefined,
+ attachments: Array.isArray(parsed.attachments)
+ ? (parsed.attachments as unknown[])
+ : undefined,
teamName,
});
} catch (error) {
diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts
index 0b823901..a3ca64c3 100644
--- a/src/preload/constants/ipcChannels.ts
+++ b/src/preload/constants/ipcChannels.ts
@@ -325,6 +325,9 @@ export const TEAM_KILL_PROCESS = 'team:killProcess';
/** Get lead process activity state (active/idle/offline) */
export const TEAM_LEAD_ACTIVITY = 'team:leadActivity';
+/** Get lead process context window usage */
+export const TEAM_LEAD_CONTEXT = 'team:leadContext';
+
/** Soft-delete a task (set status to 'deleted' with deletedAt timestamp) */
export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask';
@@ -343,6 +346,15 @@ export const TEAM_ADD_TASK_RELATIONSHIP = 'team:addTaskRelationship';
/** Remove a relationship (blockedBy/blocks/related) between two tasks */
export const TEAM_REMOVE_TASK_RELATIONSHIP = 'team:removeTaskRelationship';
+/** Save an image attachment to a task */
+export const TEAM_SAVE_TASK_ATTACHMENT = 'team:saveTaskAttachment';
+
+/** Get base64 data for a task attachment */
+export const TEAM_GET_TASK_ATTACHMENT = 'team:getTaskAttachment';
+
+/** Delete an attachment from a task */
+export const TEAM_DELETE_TASK_ATTACHMENT = 'team:deleteTaskAttachment';
+
// =============================================================================
// CLI Installer API Channels
// =============================================================================
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 3a513c61..8361d241 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -79,6 +79,7 @@ import {
TEAM_KILL_PROCESS,
TEAM_LAUNCH,
TEAM_LEAD_ACTIVITY,
+ TEAM_LEAD_CONTEXT,
TEAM_LIST,
TEAM_PERMANENTLY_DELETE,
TEAM_PREPARE_PROVISIONING,
@@ -89,6 +90,9 @@ import {
TEAM_REMOVE_MEMBER,
TEAM_REMOVE_TASK_RELATIONSHIP,
TEAM_REPLACE_MEMBERS,
+ TEAM_SAVE_TASK_ATTACHMENT,
+ TEAM_GET_TASK_ATTACHMENT,
+ TEAM_DELETE_TASK_ATTACHMENT,
TEAM_REQUEST_REVIEW,
TEAM_RESTORE,
TEAM_RESTORE_TASK,
@@ -173,6 +177,7 @@ import type {
HunkDecision,
IpcResult,
KanbanColumnId,
+ LeadContextUsage,
MemberFullStats,
MemberLogSummary,
NotificationTrigger,
@@ -187,6 +192,8 @@ import type {
SshConnectionConfig,
SshConnectionStatus,
SshLastConnection,
+ CommentAttachmentPayload,
+ TaskAttachmentMeta,
TaskChangeSetV2,
TaskComment,
TeamChangeEvent,
@@ -797,8 +804,19 @@ const electronAPI: ElectronAPI = {
updateConfig: async (teamName: string, updates: TeamUpdateConfigRequest) => {
return invokeIpcWithResult(TEAM_UPDATE_CONFIG, teamName, updates);
},
- addTaskComment: async (teamName: string, taskId: string, text: string) => {
- return invokeIpcWithResult(TEAM_ADD_TASK_COMMENT, teamName, taskId, text);
+ addTaskComment: async (
+ teamName: string,
+ taskId: string,
+ text: string,
+ attachments?: CommentAttachmentPayload[]
+ ) => {
+ return invokeIpcWithResult(
+ TEAM_ADD_TASK_COMMENT,
+ teamName,
+ taskId,
+ text,
+ attachments
+ );
},
addMember: async (teamName: string, request: AddMemberRequest) => {
return invokeIpcWithResult(TEAM_ADD_MEMBER, teamName, request);
@@ -825,6 +843,9 @@ const electronAPI: ElectronAPI = {
const result = await invokeIpcWithResult(TEAM_LEAD_ACTIVITY, teamName);
return result as 'active' | 'idle' | 'offline';
},
+ getLeadContext: async (teamName: string) => {
+ return invokeIpcWithResult(TEAM_LEAD_CONTEXT, teamName);
+ },
softDeleteTask: async (teamName: string, taskId: string) => {
return invokeIpcWithResult(TEAM_SOFT_DELETE_TASK, teamName, taskId);
},
@@ -872,6 +893,52 @@ const electronAPI: ElectronAPI = {
type
);
},
+ saveTaskAttachment: async (
+ teamName: string,
+ taskId: string,
+ attachmentId: string,
+ filename: string,
+ mimeType: string,
+ base64Data: string
+ ) => {
+ return invokeIpcWithResult(
+ TEAM_SAVE_TASK_ATTACHMENT,
+ teamName,
+ taskId,
+ attachmentId,
+ filename,
+ mimeType,
+ base64Data
+ );
+ },
+ getTaskAttachment: async (
+ teamName: string,
+ taskId: string,
+ attachmentId: string,
+ mimeType: string
+ ) => {
+ return invokeIpcWithResult(
+ TEAM_GET_TASK_ATTACHMENT,
+ teamName,
+ taskId,
+ attachmentId,
+ mimeType
+ );
+ },
+ deleteTaskAttachment: async (
+ teamName: string,
+ taskId: string,
+ attachmentId: string,
+ mimeType: string
+ ) => {
+ return invokeIpcWithResult(
+ TEAM_DELETE_TASK_ATTACHMENT,
+ teamName,
+ taskId,
+ attachmentId,
+ mimeType
+ );
+ },
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
ipcRenderer.on(
TEAM_CHANGE,
diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts
index e9905e9d..b2c69fa8 100644
--- a/src/renderer/api/httpClient.ts
+++ b/src/renderer/api/httpClient.ts
@@ -796,6 +796,9 @@ export class HttpAPIClient implements ElectronAPI {
getLeadActivity: async (_teamName: string): Promise<'active' | 'idle' | 'offline'> => {
return 'offline';
},
+ getLeadContext: async () => {
+ return null;
+ },
softDeleteTask: async (_teamName: string, _taskId: string): Promise => {
// Not available via HTTP client — no-op
},
@@ -831,6 +834,32 @@ export class HttpAPIClient implements ElectronAPI {
): Promise => {
throw new Error('Task relationships are not available in browser mode');
},
+ saveTaskAttachment: async (
+ _teamName: string,
+ _taskId: string,
+ _attachmentId: string,
+ _filename: string,
+ _mimeType: string,
+ _base64Data: string
+ ): Promise => {
+ throw new Error('Task attachments are not available in browser mode');
+ },
+ getTaskAttachment: async (
+ _teamName: string,
+ _taskId: string,
+ _attachmentId: string,
+ _mimeType: string
+ ): Promise => {
+ return null;
+ },
+ deleteTaskAttachment: async (
+ _teamName: string,
+ _taskId: string,
+ _attachmentId: string,
+ _mimeType: string
+ ): Promise => {
+ throw new Error('Task attachments are not available in browser mode');
+ },
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
return this.addEventListener('team-change', (data: unknown) =>
callback(null, data as TeamChangeEvent)
diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx
index 1c730df2..f7670baf 100644
--- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx
+++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx
@@ -3,6 +3,7 @@ import ReactMarkdown, { type Components } from 'react-markdown';
import { api } from '@renderer/api';
import { CopyButton } from '@renderer/components/common/CopyButton';
+import { getTeamColorSet } from '@renderer/constants/teamColors';
import {
CODE_BG,
CODE_BORDER,
@@ -199,21 +200,45 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
),
// Links — inline element, no hl(); parent block element's hl() descends here
- a: ({ href, children }) => (
- {
- e.preventDefault();
- if (href) {
- void api.openExternal(href);
- }
- }}
- >
- {children}
-
- ),
+ // task:// links are handled by ancestor onClickCapture handlers (e.g. ActivityItem)
+ // mention:// links render as colored inline badges
+ a: ({ href, children }) => {
+ if (href?.startsWith('mention://')) {
+ const path = href.slice('mention://'.length);
+ const slashIdx = path.indexOf('/');
+ const color = slashIdx >= 0 ? decodeURIComponent(path.slice(0, slashIdx)) : '';
+ const colorSet = getTeamColorSet(color);
+ const bg = colorSet.badge;
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+ {
+ e.preventDefault();
+ if (href && !href.startsWith('task://')) {
+ void api.openExternal(href);
+ }
+ }}
+ >
+ {children}
+
+ );
+ },
// Strong/Bold — inline element, no hl()
strong: ({ children }) => (
diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx
index 1e411933..d82fc53a 100644
--- a/src/renderer/components/team/CollapsibleTeamSection.tsx
+++ b/src/renderer/components/team/CollapsibleTeamSection.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
+import { cn } from '@renderer/lib/utils';
import { ChevronRight } from 'lucide-react';
function scrollAfterExpand(el: HTMLElement): void {
@@ -27,6 +28,10 @@ interface CollapsibleTeamSectionProps {
sectionId?: string;
/** Extra classes applied to the content wrapper (e.g. padding). */
contentClassName?: string;
+ /** Extra classes for the header bar (e.g. "-mx-6 w-[calc(100%+3rem)]" to match parent padding). */
+ headerClassName?: string;
+ /** Extra classes for the inner header content (e.g. "pl-6" to match parent padding). */
+ headerContentClassName?: string;
children: React.ReactNode;
}
@@ -41,6 +46,8 @@ export const CollapsibleTeamSection = ({
action,
sectionId,
contentClassName,
+ headerClassName,
+ headerContentClassName,
children,
}: CollapsibleTeamSectionProps): React.JSX.Element => {
const [open, setOpen] = useState(defaultOpen);
@@ -61,14 +68,24 @@ export const CollapsibleTeamSection = ({
return (
-