merge(dev): sync dev into main
This commit is contained in:
commit
617299494e
279 changed files with 36699 additions and 4765 deletions
273
docs/ideas/codeboarding-integration.md
Normal file
273
docs/ideas/codeboarding-integration.md
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
# CodeBoarding Integration Idea
|
||||
|
||||
Дата проверки: 2026-05-03.
|
||||
|
||||
## Короткий вывод
|
||||
|
||||
CodeBoarding полезен для Agent Teams как опциональная визуализация архитектурного влияния агентских изменений. Он не выглядит как готовый embeddable real-time daemon для нашего Electron UI, но у него есть достаточная база для near real-time режима:
|
||||
|
||||
- baseline анализ через `codeboarding full --local <project>`;
|
||||
- incremental анализ через `codeboarding incremental --local <project>`;
|
||||
- partial обновление компонента через `codeboarding partial --local <project> --component-id <id>`;
|
||||
- выходные артефакты в `.codeboarding/`, включая `analysis.json`, Markdown и Mermaid;
|
||||
- method/component change tracking в VS Code extension.
|
||||
|
||||
Практичный продуктовый вариант: делаем CodeBoarding optional dependency, даём пользователю install/detect/setup в UI, запускаем full один раз, а дальше показываем live-ish overlay по изменениям агентов. Быструю подсветку делаем сами по git diff/task change ledger, а CodeBoarding incremental используем как более точный фоновый refresh.
|
||||
|
||||
## Что проверено
|
||||
|
||||
- GitHub repo: [CodeBoarding/CodeBoarding](https://github.com/CodeBoarding/CodeBoarding)
|
||||
- Website: [codeboarding.org](https://www.codeboarding.org/)
|
||||
- PyPI JSON: [pypi.org/pypi/codeboarding/json](https://pypi.org/pypi/codeboarding/json)
|
||||
- Release: [v0.11.0](https://github.com/CodeBoarding/CodeBoarding/releases/tag/v0.11.0)
|
||||
- VS Code Marketplace: [CodeBoarding extension](https://marketplace.visualstudio.com/items?itemName=Codeboarding.codeboarding)
|
||||
- MCP repo: [CodeBoarding/CodeBoarding-MCP](https://github.com/CodeBoarding/CodeBoarding-MCP)
|
||||
|
||||
На момент проверки:
|
||||
|
||||
- Latest GitHub release: `v0.11.0`, published 2026-04-29.
|
||||
- Latest PyPI version: `0.11.0`, requires Python `>=3.12,<3.14`.
|
||||
- License: MIT.
|
||||
- Repo активный: последний push был 2026-05-03.
|
||||
- Основной стек CodeBoarding: Python CLI, static analysis, LSP, tree-sitter, LLM providers.
|
||||
- Поддерживаемые языки из README/PyPI: Python, TypeScript, JavaScript, Java, Go, PHP, Rust, C#.
|
||||
- LLM providers из README/PyPI: OpenAI, Anthropic, Google, Vercel AI Gateway, AWS Bedrock, Ollama, OpenRouter и другие.
|
||||
|
||||
## Что CodeBoarding умеет
|
||||
|
||||
Из README и CLI:
|
||||
|
||||
- генерирует high-level architecture diagrams;
|
||||
- генерирует deeper component diagrams;
|
||||
- пишет Markdown документацию в `.codeboarding/`;
|
||||
- пишет Mermaid output, который удобно показывать в нашем Markdown/Mermaid viewer;
|
||||
- умеет incremental updates, когда есть предыдущий analysis;
|
||||
- умеет partial update одного component id;
|
||||
- для private repos использует `GITHUB_TOKEN`;
|
||||
- конфиг LLM ключей хранит в `~/.codeboarding/config.toml`, но env vars имеют приоритет.
|
||||
|
||||
Публичные команды CLI:
|
||||
|
||||
```bash
|
||||
codeboarding full --local /path/to/repo
|
||||
codeboarding incremental --local /path/to/repo
|
||||
codeboarding partial --local /path/to/repo --component-id "1.2"
|
||||
```
|
||||
|
||||
Установка из README/PyPI:
|
||||
|
||||
```bash
|
||||
pipx install codeboarding --python python3.12
|
||||
codeboarding-setup
|
||||
codeboarding full --local /path/to/repo
|
||||
```
|
||||
|
||||
Важно: `codeboarding-setup` скачивает language server binaries в `~/.codeboarding/servers/`. Node.js/npm нужен для Python, TypeScript, JavaScript и PHP language servers; если Node/npm не найден, CodeBoarding может скачать pinned Node runtime в `~/.codeboarding/servers/nodeenv/`.
|
||||
|
||||
## Real-time оценка
|
||||
|
||||
В VS Code Marketplace заявлено:
|
||||
|
||||
- `Realtime Component Change tracking` - можно видеть, в каких компонентах есть file edits.
|
||||
- `0.11.0` - Git Commit Diff View, timeline slider, подсветка components/files/methods по recent commits.
|
||||
- `0.11.0` - Faster Incremental Analysis, refresh переиспользует прошлые результаты и анализирует только затронутое.
|
||||
- `0.10.0` - Method-Level Change Tracking.
|
||||
- `0.10.0` - Real-time Method Updates.
|
||||
- `0.10.2` - Smoother Real-time Updates.
|
||||
|
||||
Но в open-source CLI я не нашёл отдельного публичного `watch`/daemon режима. В коде есть incremental pipeline, worktree diff, `incrementalDelta`, method-level statuses, comments про IDE/wrapper integration и snapshot target refs, но публичный CLI остаётся командным.
|
||||
|
||||
Вывод: CodeBoarding позволяет сделать near real-time визуализацию, но real-time orchestration надо делать нам:
|
||||
|
||||
1. watcher ловит изменения файлов от агента;
|
||||
2. debounce, например 2-10 секунд;
|
||||
3. быстрый overlay строится по git diff/task change ledger и текущему `.codeboarding/analysis.json`;
|
||||
4. CodeBoarding incremental запускается фоном реже или на завершение task;
|
||||
5. UI обновляет Mermaid/architecture map и affected components.
|
||||
|
||||
## Что можно показать пользователю
|
||||
|
||||
Хорошо подходит:
|
||||
|
||||
- 🟢 новый файл попал в конкретный компонент;
|
||||
- 🟡 метод или файл изменён внутри компонента;
|
||||
- 🔴 файл/метод удалён;
|
||||
- какие компоненты трогает конкретный агент;
|
||||
- какие компоненты трогает конкретная task;
|
||||
- архитектурный контекст рядом с code review;
|
||||
- diff timeline по commits или task snapshots;
|
||||
- Markdown/Mermaid docs прямо в нашем Project Editor/Review UI.
|
||||
|
||||
Сложно или рискованно:
|
||||
|
||||
- мгновенная перестройка диаграммы на каждый символ;
|
||||
- точная визуализация rename/copy, потому что incremental pipeline сейчас может требовать full analysis для rename/copy;
|
||||
- стабильная работа на очень больших репах без очереди, debounce и cancellation;
|
||||
- full/incremental анализ без настроенного LLM provider;
|
||||
- автоматическая установка Python 3.12/3.13 на всех OS без отдельного installer UX.
|
||||
|
||||
## Варианты интеграции
|
||||
|
||||
### 1. Optional CLI Runner + просмотр `.codeboarding/`
|
||||
|
||||
🎯 9 🛡️ 8 🧠 4
|
||||
Примерно `250-450` строк.
|
||||
|
||||
Суть: в Settings/Integrations добавляем CodeBoarding detect/install/run. Первый MVP только запускает `full`/`incremental`, показывает статус, открывает `.codeboarding/analysis.json` и Markdown/Mermaid в существующем viewer.
|
||||
|
||||
Плюсы:
|
||||
|
||||
- быстро проверить реальную пользу;
|
||||
- почти не вмешивается в team/review lifecycle;
|
||||
- опирается на уже существующие Markdown/Mermaid возможности;
|
||||
- безопаснее, потому что dependency optional.
|
||||
|
||||
Минусы:
|
||||
|
||||
- это не live UX;
|
||||
- пользователь сам интерпретирует изменения;
|
||||
- нет красивой связки с задачами агентов.
|
||||
|
||||
Когда выбирать: если хотим дешёвый probe перед большой фичей.
|
||||
|
||||
### 2. Live-ish Overlay поверх baseline анализа
|
||||
|
||||
🎯 9 🛡️ 8 🧠 5
|
||||
Примерно `900-1400` строк.
|
||||
|
||||
Суть: CodeBoarding делает baseline `.codeboarding/analysis.json`. Дальше наш watcher/git status/task change ledger быстро мапит изменённые файлы на компоненты из baseline и подсвечивает affected components почти в реальном времени. CodeBoarding `incremental` запускается фоном по debounce, на завершение task или по кнопке refresh.
|
||||
|
||||
Плюсы:
|
||||
|
||||
- даёт пользователю ощущение real-time;
|
||||
- не заставляет LLM работать на каждое маленькое изменение;
|
||||
- хорошо ложится на агентские изменения и task review;
|
||||
- можно показывать impact до завершения задачи.
|
||||
|
||||
Минусы:
|
||||
|
||||
- нужна собственная модель overlay state;
|
||||
- baseline mapping может быть устаревшим до следующего incremental;
|
||||
- для новых файлов компонент может определяться эвристикой до refresh.
|
||||
|
||||
Когда выбирать: лучший первый продуктовый вариант.
|
||||
|
||||
### 3. Architecture Review per Task
|
||||
|
||||
🎯 8 🛡️ 7 🧠 8
|
||||
Примерно `1600-2500` строк.
|
||||
|
||||
Суть: связываем CodeBoarding с review flow. Для каждой task показываем impacted components, changed methods, old/new architecture map, summary риска и ссылки на файлы. Можно добавить отдельную вкладку в task detail или review dialog.
|
||||
|
||||
Плюсы:
|
||||
|
||||
- максимальная ценность для Agent Teams;
|
||||
- помогает ревьюить AI-generated changes не только по diff, но и по архитектурному влиянию;
|
||||
- можно использовать как сильный selling point.
|
||||
|
||||
Минусы:
|
||||
|
||||
- крупная фича;
|
||||
- нужны тесты на task-change mapping, IPC, persistence и UI;
|
||||
- есть риск перегрузить review screen.
|
||||
|
||||
Когда выбирать: после MVP и подтверждения, что карты реально помогают пользователям.
|
||||
|
||||
## Варианты установки optional dependency
|
||||
|
||||
### A. pipx install в user environment
|
||||
|
||||
🎯 8 🛡️ 7 🧠 4
|
||||
Примерно `350-600` строк.
|
||||
|
||||
UI проверяет `python3.12`/`python3.13`, `pipx`, `codeboarding`. Если нет, предлагает install через `pipx install codeboarding --python python3.12`, затем `codeboarding-setup`.
|
||||
|
||||
Плюсы: соответствует README, изолированная среда, меньше конфликтов с системным Python.
|
||||
|
||||
Минусы: надо отдельно вести UX для отсутствующего Python/pipx.
|
||||
|
||||
### B. Скачать packaged binary из GitHub Release
|
||||
|
||||
🎯 7 🛡️ 7 🧠 6
|
||||
Примерно `600-1000` строк.
|
||||
|
||||
У CodeBoarding release `v0.11.0` содержит assets для macOS/Linux/Windows. Можно скачивать бинарь под OS, проверять sha256 asset и хранить в app-managed tools dir.
|
||||
|
||||
Плюсы: меньше зависимости от Python/pipx у пользователя.
|
||||
|
||||
Минусы: нужно аккуратно делать download, checksum, permissions, updates, notarization/security prompts.
|
||||
|
||||
### C. Встроить Python package в наш app bundle
|
||||
|
||||
🎯 4 🛡️ 5 🧠 9
|
||||
Примерно `1200-2200` строк.
|
||||
|
||||
Пакуем CodeBoarding и Python runtime вместе с приложением.
|
||||
|
||||
Плюсы: самый гладкий UX после установки.
|
||||
|
||||
Минусы: тяжёлый bundle, OS-specific packaging, LSP binaries, security/update burden. Для optional feature это слишком дорого.
|
||||
|
||||
Рекомендация: начать с A, потом рассмотреть B для packaged app.
|
||||
|
||||
## Как это ложится на нашу архитектуру
|
||||
|
||||
Так как фича пересекает main/preload/renderer и запускает внешний инструмент, её лучше делать по `docs/FEATURE_ARCHITECTURE_STANDARD.md`:
|
||||
|
||||
```text
|
||||
src/features/codeboarding/
|
||||
contracts/
|
||||
core/
|
||||
main/
|
||||
adapters/
|
||||
infrastructure/
|
||||
preload/
|
||||
renderer/
|
||||
```
|
||||
|
||||
Основные части:
|
||||
|
||||
- contracts: DTO для status, install state, run request, run result, affected components;
|
||||
- core: правила выбора режима `full`/`incremental`, debounce policy, overlay merge policy;
|
||||
- main/infrastructure: binary detection, installer, command runner, output parser, `.codeboarding` reader;
|
||||
- main/adapters/input: IPC handlers;
|
||||
- preload: bridge;
|
||||
- renderer: settings panel, project action, architecture map panel, task/review badges.
|
||||
|
||||
Надо использовать path validation и не давать CodeBoarding работать вне выбранного project root.
|
||||
|
||||
## MVP flow
|
||||
|
||||
1. Пользователь открывает project.
|
||||
2. UI показывает “Enable CodeBoarding architecture map”.
|
||||
3. App проверяет наличие `codeboarding`.
|
||||
4. Если нет, предлагает install.
|
||||
5. После install запускает `codeboarding-setup`.
|
||||
6. Первый запуск: `codeboarding full --local <project>`.
|
||||
7. App читает `.codeboarding/analysis.json`.
|
||||
8. Показывает diagram/docs.
|
||||
9. Когда агент меняет файлы, app быстро подсвечивает affected components по baseline mapping.
|
||||
10. После debounce или завершения task запускает `codeboarding incremental --local <project>`.
|
||||
11. Если incremental возвращает `requiresFullAnalysis`, UI предлагает full refresh.
|
||||
|
||||
## Риски
|
||||
|
||||
- 🟠 LLM keys: без provider key full/incremental может не пройти. Нужен понятный setup и read-only detect.
|
||||
- 🟠 Performance: full analysis может быть долгим. Нужны cancellation, queue, progress, timeout.
|
||||
- 🟠 Dirty worktree: incremental умеет работать с worktree, но target refs и snapshots надо использовать аккуратно.
|
||||
- 🟠 Cost: LLM вызовы могут стоить денег. Нужен явный opt-in и возможно “run on task complete” вместо постоянного refresh.
|
||||
- 🟡 Security: не отправлять код в неизвестный сервис. CodeBoarding заявляет local processing plus direct provider API calls, но UX должен прямо показывать выбранный provider.
|
||||
- 🟡 Generated files: `.codeboarding/` не всегда надо коммитить. Нужно дать настройку ignore/commit.
|
||||
- 🟡 MCP: CodeBoarding-MCP выглядит сырым, поэтому не стоит брать его как основную интеграцию.
|
||||
|
||||
## Рекомендация
|
||||
|
||||
Делать поэтапно:
|
||||
|
||||
1. MVP optional CLI runner и viewer.
|
||||
2. Live-ish overlay на базе нашего task change ledger и CodeBoarding baseline.
|
||||
3. Background incremental refresh.
|
||||
4. Architecture Review per Task.
|
||||
5. Только потом MCP/context tools для агентов.
|
||||
|
||||
Самое ценное для пользователя: видеть не “агент изменил 12 файлов”, а “агент сейчас меняет Auth Runtime Detection и это затрагивает Provider Connection + Team Provisioning”. CodeBoarding может дать основу для такой карты, но realtime UX должен быть нашим.
|
||||
136
landing/assets/styles/brand-tokens.css
Normal file
136
landing/assets/styles/brand-tokens.css
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
|
||||
--at-font-sans: "Inter", "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--at-font-mono: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
|
||||
--at-c-cyan: #00f0ff;
|
||||
--at-c-cyan-strong: #00d4e6;
|
||||
--at-c-cyan-deep: #0891b2;
|
||||
--at-c-magenta: #ff00ff;
|
||||
--at-c-green: #39ff14;
|
||||
--at-c-gold: #ffd700;
|
||||
--at-c-red: #ff4757;
|
||||
|
||||
--at-c-dark-0: #05070b;
|
||||
--at-c-dark-1: #0a0a0f;
|
||||
--at-c-dark-2: #12121a;
|
||||
--at-c-dark-3: #1e293b;
|
||||
--at-c-light-0: #ffffff;
|
||||
--at-c-light-1: #f8fafc;
|
||||
--at-c-light-2: #f0f2f5;
|
||||
|
||||
--at-c-text-dark-1: #e0e6ff;
|
||||
--at-c-text-dark-2: #c8d6e5;
|
||||
--at-c-text-dark-3: #a0a8c0;
|
||||
--at-c-text-dark-muted: #8892b0;
|
||||
--at-c-text-light-1: #1e293b;
|
||||
--at-c-text-light-2: #475569;
|
||||
--at-c-text-light-3: #64748b;
|
||||
|
||||
--at-c-bg: var(--at-c-dark-1);
|
||||
--at-c-bg-soft: rgba(10, 10, 15, 0.8);
|
||||
--at-c-surface: var(--at-c-dark-2);
|
||||
--at-c-surface-soft: rgba(10, 10, 15, 0.6);
|
||||
--at-c-surface-raised: rgba(30, 41, 59, 0.78);
|
||||
--at-c-text: var(--at-c-text-dark-1);
|
||||
--at-c-text-soft: var(--at-c-text-dark-2);
|
||||
--at-c-text-muted: var(--at-c-text-dark-muted);
|
||||
--at-c-border: rgba(0, 240, 255, 0.12);
|
||||
--at-c-border-strong: rgba(0, 240, 255, 0.28);
|
||||
--at-c-focus: rgba(0, 240, 255, 0.55);
|
||||
|
||||
--at-gradient-brand: linear-gradient(135deg, var(--at-c-cyan), var(--at-c-magenta));
|
||||
--at-gradient-brand-text: linear-gradient(135deg, #e0e6ff 0%, var(--at-c-cyan) 50%, var(--at-c-magenta) 100%);
|
||||
--at-gradient-success: linear-gradient(135deg, var(--at-c-cyan), var(--at-c-green));
|
||||
--at-gradient-cyan-text: linear-gradient(135deg, #e0e6ff 0%, var(--at-c-cyan) 100%);
|
||||
--at-gradient-panel: linear-gradient(135deg, rgba(0, 240, 255, 0.06), rgba(255, 0, 255, 0.035));
|
||||
|
||||
--at-radius-xs: 6px;
|
||||
--at-radius-sm: 8px;
|
||||
--at-radius-md: 10px;
|
||||
--at-radius-lg: 12px;
|
||||
--at-radius-xl: 16px;
|
||||
--at-radius-2xl: 20px;
|
||||
--at-radius-preview: 22px;
|
||||
--at-radius-pill: 999px;
|
||||
|
||||
--at-shadow-cyan-sm: 0 4px 20px rgba(0, 240, 255, 0.3);
|
||||
--at-shadow-cyan-md: 0 8px 32px rgba(0, 240, 255, 0.08);
|
||||
--at-shadow-cyan-lg: 0 20px 60px rgba(0, 0, 0, 0.55), 0 0 30px rgba(0, 240, 255, 0.06);
|
||||
--at-shadow-card: 0 16px 32px -10px rgba(0, 0, 0, 0.35);
|
||||
|
||||
--at-blur-sm: 8px;
|
||||
--at-blur-md: 12px;
|
||||
--at-blur-lg: 20px;
|
||||
--at-glass-bg: rgba(10, 10, 15, 0.78);
|
||||
--at-glass-bg-hover: rgba(10, 10, 15, 0.9);
|
||||
--at-glass-border: 1px solid var(--at-c-border);
|
||||
--at-glass-border-strong: 1px solid var(--at-c-border-strong);
|
||||
|
||||
--at-grid-line: rgba(0, 240, 255, 0.03);
|
||||
--at-scanline: rgba(0, 240, 255, 0.008);
|
||||
--at-transition-fast: 0.15s ease;
|
||||
--at-transition-base: 0.25s ease;
|
||||
--at-transition-smooth: 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
--at-z-header: 1000;
|
||||
}
|
||||
|
||||
.v-theme--light,
|
||||
:root:not(.dark) {
|
||||
--at-c-bg: var(--at-c-light-2);
|
||||
--at-c-bg-soft: rgba(255, 255, 255, 0.82);
|
||||
--at-c-surface: var(--at-c-light-0);
|
||||
--at-c-surface-soft: rgba(255, 255, 255, 0.78);
|
||||
--at-c-surface-raised: rgba(255, 255, 255, 0.92);
|
||||
--at-c-text: var(--at-c-text-light-1);
|
||||
--at-c-text-soft: var(--at-c-text-light-2);
|
||||
--at-c-text-muted: var(--at-c-text-light-3);
|
||||
--at-c-border: rgba(0, 0, 0, 0.08);
|
||||
--at-c-border-strong: rgba(0, 139, 178, 0.3);
|
||||
--at-c-focus: rgba(8, 145, 178, 0.5);
|
||||
--at-glass-bg: rgba(255, 255, 255, 0.78);
|
||||
--at-glass-bg-hover: rgba(255, 255, 255, 0.92);
|
||||
--at-glass-border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
--at-glass-border-strong: 1px solid rgba(0, 139, 178, 0.25);
|
||||
--at-shadow-card: 0 16px 32px -10px rgba(0, 0, 0, 0.12);
|
||||
--at-shadow-cyan-lg: 0 20px 60px rgba(0, 180, 200, 0.12);
|
||||
--at-grid-line: rgba(8, 145, 178, 0.045);
|
||||
}
|
||||
|
||||
.v-theme--dark,
|
||||
.dark {
|
||||
--at-c-bg: var(--at-c-dark-1);
|
||||
--at-c-bg-soft: rgba(10, 10, 15, 0.8);
|
||||
--at-c-surface: var(--at-c-dark-2);
|
||||
--at-c-surface-soft: rgba(10, 10, 15, 0.6);
|
||||
--at-c-surface-raised: rgba(30, 41, 59, 0.78);
|
||||
--at-c-text: var(--at-c-text-dark-1);
|
||||
--at-c-text-soft: var(--at-c-text-dark-2);
|
||||
--at-c-text-muted: var(--at-c-text-dark-muted);
|
||||
--at-c-border: rgba(0, 240, 255, 0.12);
|
||||
--at-c-border-strong: rgba(0, 240, 255, 0.28);
|
||||
--at-glass-bg: rgba(10, 10, 15, 0.78);
|
||||
--at-glass-bg-hover: rgba(10, 10, 15, 0.92);
|
||||
}
|
||||
|
||||
.at-gradient-text {
|
||||
background: var(--at-gradient-brand-text);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.at-glass {
|
||||
background: var(--at-glass-bg);
|
||||
border: var(--at-glass-border);
|
||||
backdrop-filter: blur(var(--at-blur-md));
|
||||
-webkit-backdrop-filter: blur(var(--at-blur-md));
|
||||
box-shadow: var(--at-shadow-cyan-md);
|
||||
}
|
||||
|
||||
.at-focus-ring:focus-visible {
|
||||
outline: 2px solid var(--at-c-focus);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
@import "./brand-tokens.css";
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
font-family: var(--at-font-sans);
|
||||
background: rgb(var(--v-theme-background));
|
||||
color: rgb(var(--v-theme-on-background));
|
||||
}
|
||||
|
|
@ -32,7 +34,7 @@ body {
|
|||
|
||||
/* Monospace accent font for technical elements */
|
||||
.mono {
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
font-family: var(--at-font-mono);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
|
|
@ -72,5 +74,5 @@ body {
|
|||
}
|
||||
|
||||
.app-header {
|
||||
backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(var(--at-blur-md));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@
|
|||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 240, 255, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 240, 255, 0.03) 1px, transparent 1px);
|
||||
linear-gradient(var(--at-grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--at-grid-line) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
|
@ -40,8 +40,8 @@
|
|||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 240, 255, 0.008) 2px,
|
||||
rgba(0, 240, 255, 0.008) 4px
|
||||
var(--at-scanline) 2px,
|
||||
var(--at-scanline) 4px
|
||||
);
|
||||
z-index: 2;
|
||||
}
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
.page-bg__orb--1 {
|
||||
width: 900px;
|
||||
height: 900px;
|
||||
background: #00f0ff;
|
||||
background: var(--at-c-cyan);
|
||||
top: -200px;
|
||||
right: -150px;
|
||||
animation: orbDrift1 20s ease-in-out infinite;
|
||||
|
|
@ -65,7 +65,7 @@
|
|||
.page-bg__orb--2 {
|
||||
width: 700px;
|
||||
height: 700px;
|
||||
background: #ff00ff;
|
||||
background: var(--at-c-magenta);
|
||||
top: 300px;
|
||||
left: -200px;
|
||||
animation: orbDrift2 25s ease-in-out infinite;
|
||||
|
|
@ -74,7 +74,7 @@
|
|||
.page-bg__orb--3 {
|
||||
width: 800px;
|
||||
height: 800px;
|
||||
background: #39ff14;
|
||||
background: var(--at-c-green);
|
||||
top: 1200px;
|
||||
right: -100px;
|
||||
opacity: 0.05;
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
.page-bg__orb--4 {
|
||||
width: 700px;
|
||||
height: 700px;
|
||||
background: #00f0ff;
|
||||
background: var(--at-c-cyan);
|
||||
top: 2100px;
|
||||
left: -150px;
|
||||
opacity: 0.06;
|
||||
|
|
@ -94,7 +94,7 @@
|
|||
.page-bg__orb--5 {
|
||||
width: 750px;
|
||||
height: 750px;
|
||||
background: #ff00ff;
|
||||
background: var(--at-c-magenta);
|
||||
top: 2900px;
|
||||
right: -120px;
|
||||
opacity: 0.05;
|
||||
|
|
@ -104,7 +104,7 @@
|
|||
.page-bg__orb--6 {
|
||||
width: 700px;
|
||||
height: 700px;
|
||||
background: #ffd700;
|
||||
background: var(--at-c-gold);
|
||||
top: 3600px;
|
||||
left: -100px;
|
||||
opacity: 0.04;
|
||||
|
|
@ -114,7 +114,7 @@
|
|||
.page-bg__orb--7 {
|
||||
width: 650px;
|
||||
height: 650px;
|
||||
background: #00f0ff;
|
||||
background: var(--at-c-cyan);
|
||||
top: 4300px;
|
||||
right: -80px;
|
||||
opacity: 0.05;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const { repoUrl } = useGithubRepo();
|
||||
const { baseURL } = useRuntimeConfig().app;
|
||||
const year = new Date().getFullYear();
|
||||
const docsHref = computed(() => {
|
||||
const base = baseURL.replace(/\/?$/, '/');
|
||||
return `${base}${locale.value === 'ru' ? 'docs/ru/' : 'docs/'}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -14,6 +19,8 @@ const year = new Date().getFullYear();
|
|||
<a class="app-footer__link" href="https://github.com/777genius" target="_blank">Author</a>
|
||||
<span class="app-footer__divider" />
|
||||
<a class="app-footer__link" :href="repoUrl" target="_blank">GitHub</a>
|
||||
<span class="app-footer__divider" />
|
||||
<a class="app-footer__link" :href="docsHref">{{ t('footer.links.docs') }}</a>
|
||||
</div>
|
||||
</v-container>
|
||||
</footer>
|
||||
|
|
@ -21,7 +28,7 @@ const year = new Date().getFullYear();
|
|||
|
||||
<style scoped>
|
||||
.app-footer {
|
||||
border-top: 1px solid rgba(0, 240, 255, 0.08);
|
||||
border-top: 1px solid var(--at-c-border);
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
|
|
@ -34,7 +41,7 @@ const year = new Date().getFullYear();
|
|||
.app-footer__copy {
|
||||
font-size: 13px;
|
||||
opacity: 0.5;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--at-font-mono);
|
||||
}
|
||||
|
||||
.app-footer__links {
|
||||
|
|
@ -44,12 +51,12 @@ const year = new Date().getFullYear();
|
|||
}
|
||||
|
||||
.app-footer__link {
|
||||
color: #00f0ff;
|
||||
color: var(--at-c-cyan);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--at-font-mono);
|
||||
}
|
||||
|
||||
.app-footer__link:hover {
|
||||
|
|
@ -59,11 +66,11 @@ const year = new Date().getFullYear();
|
|||
.app-footer__divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: rgba(0, 240, 255, 0.2);
|
||||
background: var(--at-c-border-strong);
|
||||
}
|
||||
|
||||
.v-theme--light .app-footer {
|
||||
border-top-color: rgba(0, 0, 0, 0.08);
|
||||
border-top-color: var(--at-c-border);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import { mdiMenu, mdiClose, mdiGithub } from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const { repoUrl } = useGithubRepo();
|
||||
const { baseURL } = useRuntimeConfig().app;
|
||||
const menuOpen = ref(false);
|
||||
|
||||
const withBase = (path: string) => `${baseURL.replace(/\/?$/, '/')}${path.replace(/^\/+/, '')}`;
|
||||
const docsHref = computed(() => withBase(locale.value === 'ru' ? 'docs/ru/' : 'docs/'));
|
||||
|
||||
const navItems = computed(() => [
|
||||
{ id: 'screenshots', label: t('nav.screenshots') },
|
||||
{ id: 'download', label: t('nav.download') },
|
||||
{ id: 'comparison', label: t('nav.comparison') },
|
||||
{ id: 'pricing', label: t('nav.pricing') },
|
||||
{ id: 'faq', label: t('nav.faq') },
|
||||
{ href: '#screenshots', label: t('nav.screenshots') },
|
||||
{ href: docsHref.value, label: t('nav.docs') },
|
||||
{ href: '#download', label: t('nav.download') },
|
||||
{ href: '#comparison', label: t('nav.comparison') },
|
||||
{ href: '#pricing', label: t('nav.pricing') },
|
||||
{ href: '#faq', label: t('nav.faq') },
|
||||
]);
|
||||
</script>
|
||||
|
||||
|
|
@ -19,7 +24,7 @@ const navItems = computed(() => [
|
|||
<v-container class="app-header__inner">
|
||||
<AppLogo />
|
||||
<nav class="app-header__nav">
|
||||
<v-btn v-for="item in navItems" :key="item.id" variant="text" :href="`#${item.id}`">
|
||||
<v-btn v-for="item in navItems" :key="item.href" variant="text" :href="item.href">
|
||||
{{ item.label }}
|
||||
</v-btn>
|
||||
</nav>
|
||||
|
|
@ -49,12 +54,12 @@ const navItems = computed(() => [
|
|||
<div style="flex: 1" />
|
||||
<v-btn :icon="mdiClose" variant="text" @click="menuOpen = false" />
|
||||
</div>
|
||||
<hr class="mobile-menu__divider" />
|
||||
<hr class="mobile-menu__divider">
|
||||
<nav class="mobile-menu__list">
|
||||
<a
|
||||
v-for="item in navItems"
|
||||
:key="item.id"
|
||||
:href="`#${item.id}`"
|
||||
:key="item.href"
|
||||
:href="item.href"
|
||||
class="mobile-menu__link"
|
||||
@click="menuOpen = false"
|
||||
>
|
||||
|
|
@ -69,7 +74,7 @@ const navItems = computed(() => [
|
|||
GitHub
|
||||
</a>
|
||||
</nav>
|
||||
<hr class="mobile-menu__divider" />
|
||||
<hr class="mobile-menu__divider">
|
||||
<div class="mobile-menu__actions">
|
||||
<LanguageSwitcher compact />
|
||||
<ThemeToggle />
|
||||
|
|
@ -89,18 +94,18 @@ const navItems = computed(() => [
|
|||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
z-index: var(--at-z-header);
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(0, 240, 255, 0.08);
|
||||
backdrop-filter: blur(var(--at-blur-md));
|
||||
-webkit-backdrop-filter: blur(var(--at-blur-md));
|
||||
border-bottom: 1px solid var(--at-c-border);
|
||||
}
|
||||
|
||||
.v-theme--light .app-header {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-bottom-color: rgba(0, 0, 0, 0.06);
|
||||
border-bottom-color: var(--at-c-border);
|
||||
}
|
||||
|
||||
.v-theme--dark .app-header {
|
||||
|
|
@ -136,15 +141,15 @@ const navItems = computed(() => [
|
|||
}
|
||||
|
||||
.app-header__github-btn {
|
||||
border-color: rgba(0, 240, 255, 0.25) !important;
|
||||
color: #00f0ff !important;
|
||||
border-color: var(--at-c-border-strong) !important;
|
||||
color: var(--at-c-cyan) !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 12px !important;
|
||||
letter-spacing: 0.02em !important;
|
||||
}
|
||||
|
||||
.app-header__github-btn:hover {
|
||||
border-color: rgba(0, 240, 255, 0.5) !important;
|
||||
border-color: var(--at-c-focus) !important;
|
||||
background: rgba(0, 240, 255, 0.06) !important;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const { t, locale } = useI18n();
|
|||
const downloadStore = useDownloadStore();
|
||||
const { data: releaseData, resolve } = useReleaseDownloads();
|
||||
const { trackDownloadClick } = useAnalytics();
|
||||
const { releaseDownloadUrl } = useGithubRepo();
|
||||
const { repoUrl, releaseDownloadUrl } = useGithubRepo();
|
||||
|
||||
onMounted(() => downloadStore.init());
|
||||
|
||||
|
|
@ -62,6 +62,13 @@ const releaseDate = computed(() => {
|
|||
day: 'numeric',
|
||||
});
|
||||
});
|
||||
|
||||
const devBranchUrl = computed(() => `${repoUrl.value}/tree/dev`);
|
||||
const devBranchNote = computed(() =>
|
||||
locale.value === 'ru'
|
||||
? 'Самая свежая версия доступна в ветке dev - можно развернуть локально.'
|
||||
: 'Freshest version is available on the dev branch - clone and run it locally.',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -133,6 +140,15 @@ const releaseDate = computed(() => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
class="download-section__dev-note"
|
||||
:href="devBranchUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{{ devBranchNote }}
|
||||
</a>
|
||||
|
||||
<p v-if="releaseVersion" class="download-section__release-info">
|
||||
v{{ releaseVersion }} · {{ releaseDate }}
|
||||
</p>
|
||||
|
|
@ -374,6 +390,38 @@ const releaseDate = computed(() => {
|
|||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.download-section__dev-note {
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
max-width: min(620px, calc(100vw - 32px));
|
||||
margin: 18px auto 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(0, 240, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 240, 255, 0.035);
|
||||
color: #00f0ff;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.55;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
opacity: 0.82;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background 0.2s ease,
|
||||
color 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.download-section__dev-note:hover {
|
||||
border-color: rgba(57, 255, 20, 0.24);
|
||||
background: rgba(57, 255, 20, 0.045);
|
||||
color: #39ff14;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes downloadFadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
|
@ -423,6 +471,12 @@ const releaseDate = computed(() => {
|
|||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.v-theme--light .download-section__dev-note {
|
||||
background: rgba(8, 145, 178, 0.06);
|
||||
border-color: rgba(8, 145, 178, 0.16);
|
||||
color: #0891b2;
|
||||
}
|
||||
|
||||
.v-theme--light .download-section__card-indicator {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import { mdiRobotOutline, mdiViewDashboardOutline, mdiOpenSourceInitiative } from '@mdi/js';
|
||||
import {
|
||||
mdiBookOpenPageVariantOutline,
|
||||
mdiRobotOutline,
|
||||
mdiViewDashboardOutline,
|
||||
mdiOpenSourceInitiative,
|
||||
} from '@mdi/js';
|
||||
|
||||
const { content } = useLandingContent();
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const { baseURL } = useRuntimeConfig().app;
|
||||
const workflowVideoSrc = 'https://github.com/user-attachments/assets/35e27989-726d-4059-8662-bae610e46b42';
|
||||
|
||||
const downloadStore = useDownloadStore();
|
||||
const { resolve, data: releaseData } = useReleaseDownloads();
|
||||
const { latestReleaseUrl, releaseDownloadUrl } = useGithubRepo();
|
||||
const { repoUrl, latestReleaseUrl, releaseDownloadUrl } = useGithubRepo();
|
||||
const withBase = (path: string) => `${baseURL.replace(/\/?$/, '/')}${path.replace(/^\/+/, '')}`;
|
||||
|
||||
const releaseVersion = computed(() => releaseData.value?.version || null);
|
||||
const releaseDate = computed(() => {
|
||||
|
|
@ -28,14 +35,38 @@ const heroDownloadUrl = computed(() => {
|
|||
const arch = asset.os === 'macos' ? downloadStore.macArch : asset.arch;
|
||||
return resolve(asset.os, arch)?.url || releaseDownloadUrl(asset.fileName);
|
||||
});
|
||||
|
||||
const devBranchUrl = computed(() => `${repoUrl.value}/tree/dev`);
|
||||
const docsHref = computed(() => withBase(locale.value === 'ru' ? 'docs/ru/' : 'docs/'));
|
||||
const devBranchNote = computed(() =>
|
||||
locale.value === 'ru'
|
||||
? 'Самая свежая версия в ветке dev - можно развернуть локально.'
|
||||
: 'Freshest version is on the dev branch - clone and run it locally.',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="hero" class="hero-section section anchor-offset">
|
||||
<div class="hero-section__video-bg" aria-hidden="true">
|
||||
<video
|
||||
class="hero-section__video-bg-player"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
preload="metadata"
|
||||
:poster="`${baseURL}screenshots/1.jpg`"
|
||||
>
|
||||
<source :src="workflowVideoSrc" type="video/mp4">
|
||||
</video>
|
||||
<div class="hero-section__video-bg-wash" />
|
||||
<div class="hero-section__video-bg-edge" />
|
||||
</div>
|
||||
|
||||
<v-container class="hero-section__container">
|
||||
<v-row align="center" justify="space-between">
|
||||
<!-- Left: Text content -->
|
||||
<v-col cols="12" md="6" class="hero-section__content">
|
||||
<v-col cols="12" md="7" class="hero-section__content">
|
||||
<h1 class="hero-section__title">
|
||||
<img
|
||||
:src="`${baseURL}logo-192.png`"
|
||||
|
|
@ -61,6 +92,15 @@ const heroDownloadUrl = computed(() => {
|
|||
>
|
||||
{{ t('hero.downloadNow') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="large"
|
||||
:href="docsHref"
|
||||
class="hero-section__btn-docs"
|
||||
:prepend-icon="mdiBookOpenPageVariantOutline"
|
||||
>
|
||||
{{ t('hero.ctaDocs') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="large"
|
||||
|
|
@ -71,6 +111,15 @@ const heroDownloadUrl = computed(() => {
|
|||
</v-btn>
|
||||
</div>
|
||||
|
||||
<a
|
||||
class="hero-section__dev-note"
|
||||
:href="devBranchUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{{ devBranchNote }}
|
||||
</a>
|
||||
|
||||
<!-- Release version badge -->
|
||||
<div v-if="releaseVersion" class="hero-section__release-badge">
|
||||
v{{ releaseVersion }}<template v-if="releaseDate"> · {{ releaseDate }}</template>
|
||||
|
|
@ -123,15 +172,63 @@ const heroDownloadUrl = computed(() => {
|
|||
min-height: 85vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.hero-section__video-bg {
|
||||
position: absolute;
|
||||
inset: -120px 0 -110px;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-section__video-bg-player {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: blur(1px) saturate(1.22) contrast(1.08);
|
||||
opacity: 0.95;
|
||||
mix-blend-mode: normal;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.hero-section__video-bg-wash {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(90deg, rgb(var(--v-theme-background)) 0%, rgba(var(--v-theme-background), 0.82) 34%, rgba(var(--v-theme-background), 0.08) 64%, rgba(var(--v-theme-background), 0.34) 100%),
|
||||
linear-gradient(180deg, rgba(var(--v-theme-background), 0.28) 0%, rgba(var(--v-theme-background), 0.52) 58%, rgb(var(--v-theme-background)) 96%);
|
||||
}
|
||||
|
||||
.hero-section__video-bg-edge {
|
||||
position: absolute;
|
||||
inset: auto 0 0;
|
||||
height: 42%;
|
||||
background: linear-gradient(180deg, transparent, rgb(var(--v-theme-background)));
|
||||
}
|
||||
|
||||
.v-theme--light .hero-section__video-bg-player {
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
.v-theme--light .hero-section__video-bg-wash {
|
||||
background:
|
||||
linear-gradient(90deg, rgb(var(--v-theme-background)) 0%, rgba(var(--v-theme-background), 0.86) 34%, rgba(var(--v-theme-background), 0.16) 64%, rgba(var(--v-theme-background), 0.36) 100%),
|
||||
linear-gradient(180deg, rgba(var(--v-theme-background), 0.36) 0%, rgba(var(--v-theme-background), 0.54) 58%, rgb(var(--v-theme-background)) 96%);
|
||||
}
|
||||
|
||||
.hero-section__container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.hero-section__content {
|
||||
animation: heroFadeIn 0.8s ease both;
|
||||
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
/* ─── Title ─── */
|
||||
|
|
@ -169,8 +266,8 @@ const heroDownloadUrl = computed(() => {
|
|||
.hero-section__subtitle {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.7;
|
||||
color: #8892b0;
|
||||
opacity: 0.9;
|
||||
color: #aeb8d4;
|
||||
opacity: 0.96;
|
||||
max-width: 480px;
|
||||
margin-bottom: 36px;
|
||||
animation: heroFadeIn 0.8s ease both;
|
||||
|
|
@ -180,13 +277,42 @@ const heroDownloadUrl = computed(() => {
|
|||
/* ─── Actions ─── */
|
||||
.hero-section__actions {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 12px;
|
||||
animation: heroFadeIn 0.8s ease both;
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.hero-section__actions :deep(.v-btn) {
|
||||
min-width: 0 !important;
|
||||
height: 44px !important;
|
||||
padding-inline: 18px !important;
|
||||
font-size: 0.92rem !important;
|
||||
}
|
||||
|
||||
.hero-section__dev-note {
|
||||
display: inline-flex;
|
||||
max-width: 460px;
|
||||
margin-bottom: 14px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.55;
|
||||
color: #00f0ff;
|
||||
opacity: 0.78;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
animation: heroFadeIn 0.8s ease both;
|
||||
animation-delay: 0.43s;
|
||||
}
|
||||
|
||||
.hero-section__dev-note:hover {
|
||||
color: #39ff14;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ─── Release badge ─── */
|
||||
.hero-section__release-badge {
|
||||
font-size: 0.78rem;
|
||||
|
|
@ -225,6 +351,29 @@ const heroDownloadUrl = computed(() => {
|
|||
background: rgba(0, 240, 255, 0.06) !important;
|
||||
}
|
||||
|
||||
.hero-section__btn-docs {
|
||||
border-color: rgba(57, 255, 20, 0.38) !important;
|
||||
color: #d6ffe1 !important;
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: 0.02em !important;
|
||||
background: rgba(57, 255, 20, 0.05) !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(57, 255, 20, 0.06) !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
.hero-section__btn-docs:hover {
|
||||
border-color: rgba(57, 255, 20, 0.62) !important;
|
||||
color: #39ff14 !important;
|
||||
background: rgba(57, 255, 20, 0.09) !important;
|
||||
transform: translateY(-1px) !important;
|
||||
}
|
||||
|
||||
.v-theme--light .hero-section__btn-docs {
|
||||
color: #0d5f2c !important;
|
||||
border-color: rgba(13, 95, 44, 0.32) !important;
|
||||
background: rgba(255, 255, 255, 0.6) !important;
|
||||
}
|
||||
|
||||
/* ─── Trust indicators ─── */
|
||||
.hero-section__trust {
|
||||
display: flex;
|
||||
|
|
@ -346,6 +495,20 @@ const heroDownloadUrl = computed(() => {
|
|||
padding-top: 40px;
|
||||
}
|
||||
|
||||
.hero-section__video-bg {
|
||||
inset: -90px 0 -90px;
|
||||
}
|
||||
|
||||
.hero-section__video-bg-player {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.hero-section__video-bg-wash {
|
||||
background:
|
||||
linear-gradient(90deg, rgb(var(--v-theme-background)) 0%, rgba(var(--v-theme-background), 0.9) 50%, rgba(var(--v-theme-background), 0.54) 100%),
|
||||
linear-gradient(180deg, rgba(var(--v-theme-background), 0.42) 0%, rgba(var(--v-theme-background), 0.72) 58%, rgb(var(--v-theme-background)) 96%);
|
||||
}
|
||||
|
||||
.hero-section__title {
|
||||
font-size: 2rem;
|
||||
white-space: nowrap;
|
||||
|
|
@ -372,6 +535,11 @@ const heroDownloadUrl = computed(() => {
|
|||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.hero-section__content {
|
||||
flex: 0 0 calc(100vw - 48px);
|
||||
max-width: calc(100vw - 48px);
|
||||
}
|
||||
|
||||
.hero-section__title {
|
||||
font-size: 1.6rem;
|
||||
white-space: nowrap;
|
||||
|
|
@ -387,10 +555,28 @@ const heroDownloadUrl = computed(() => {
|
|||
.hero-section__subtitle {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 28px;
|
||||
width: 100%;
|
||||
max-width: 330px;
|
||||
word-break: normal;
|
||||
overflow-wrap: normal;
|
||||
hyphens: none;
|
||||
}
|
||||
|
||||
.hero-section__actions {
|
||||
margin-bottom: 28px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
max-width: 320px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.hero-section__actions :deep(.v-btn) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero-section__dev-note {
|
||||
max-width: 320px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.hero-section__trust {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ function onGetStarted() {
|
|||
<h3 class="pricing-card__name">{{ plan.name }}</h3>
|
||||
<div class="pricing-card__price-wrap">
|
||||
<span class="pricing-card__price">{{ plan.price }}</span>
|
||||
<span class="pricing-card__period">/ {{ plan.period }}</span>
|
||||
<span v-if="plan.period" class="pricing-card__period">/ {{ plan.period }}</span>
|
||||
</div>
|
||||
<p class="pricing-card__description">{{ plan.description }}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@
|
|||
{
|
||||
"id": "isFree",
|
||||
"question": "هل هو مجاني فعلاً؟",
|
||||
"answer": "نعم. التطبيق مجاني 100% ومفتوح المصدر. التطبيق نفسه لا يملك خطة مدفوعة. لتشغيل الوكلاء تحتاج فقط إلى وصول إلى مزود أو runtime مدعوم مثل Anthropic أو Codex."
|
||||
"answer": "نعم. التطبيق مجاني ومفتوح المصدر. التطبيق نفسه لا يملك خطة مدفوعة. لتشغيل الوكلاء تحتاج فقط إلى وصول إلى مزود أو runtime مدعوم مثل Anthropic أو Codex."
|
||||
},
|
||||
{
|
||||
"id": "platforms",
|
||||
|
|
@ -82,9 +82,9 @@
|
|||
"pricing": [
|
||||
{
|
||||
"id": "free",
|
||||
"name": "مجاني للأبد",
|
||||
"name": "مجاني",
|
||||
"price": "$0",
|
||||
"period": "للأبد",
|
||||
"period": "",
|
||||
"description": "كل شيء متضمن. بدون حدود، بدون مفاتيح API، بدون بطاقة ائتمان.",
|
||||
"features": [
|
||||
"فرق وكلاء غير محدودة",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@
|
|||
{
|
||||
"id": "isFree",
|
||||
"question": "Ist es wirklich kostenlos?",
|
||||
"answer": "Ja. Die App ist 100% kostenlos und Open Source. Die App selbst hat kein Bezahlmodell. Um Agenten auszuführen, brauchen Sie nur Zugriff auf einen unterstützten Provider bzw. Runtime wie Anthropic oder Codex."
|
||||
"answer": "Ja. Die App ist kostenlos und Open Source. Die App selbst hat kein Bezahlmodell. Um Agenten auszuführen, brauchen Sie nur Zugriff auf einen unterstützten Provider bzw. Runtime wie Anthropic oder Codex."
|
||||
},
|
||||
{
|
||||
"id": "platforms",
|
||||
|
|
@ -82,9 +82,9 @@
|
|||
"pricing": [
|
||||
{
|
||||
"id": "free",
|
||||
"name": "Für immer kostenlos",
|
||||
"name": "Kostenlos",
|
||||
"price": "0€",
|
||||
"period": "für immer",
|
||||
"period": "",
|
||||
"description": "Alles inklusive. Keine Limits, keine API-Schlüssel, keine Kreditkarte.",
|
||||
"features": [
|
||||
"Unbegrenzte Agenten-Teams",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@
|
|||
{
|
||||
"id": "isFree",
|
||||
"question": "Is it really free?",
|
||||
"answer": "Yes. The app itself is 100% free and open source. The app has no paid tier of its own. To run agents, you only need access to a supported provider/runtime, such as Anthropic or Codex."
|
||||
"answer": "Yes. The app itself is free and open source. The app has no paid tier of its own. To run agents, you only need access to a supported provider/runtime, such as Anthropic or Codex."
|
||||
},
|
||||
{
|
||||
"id": "platforms",
|
||||
|
|
@ -82,9 +82,9 @@
|
|||
"pricing": [
|
||||
{
|
||||
"id": "free",
|
||||
"name": "Free Forever",
|
||||
"name": "Free",
|
||||
"price": "$0",
|
||||
"period": "forever",
|
||||
"period": "",
|
||||
"description": "Everything included. No limits, no API keys, no credit card.",
|
||||
"features": [
|
||||
"Unlimited agent teams",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@
|
|||
{
|
||||
"id": "isFree",
|
||||
"question": "¿Es realmente gratis?",
|
||||
"answer": "Sí. La app es 100% gratuita y de código abierto. La app no tiene un plan de pago propio. Para ejecutar agentes solo necesitas acceso a un proveedor/runtime compatible, como Anthropic o Codex."
|
||||
"answer": "Sí. La app es gratuita y de código abierto. La app no tiene un plan de pago propio. Para ejecutar agentes solo necesitas acceso a un proveedor/runtime compatible, como Anthropic o Codex."
|
||||
},
|
||||
{
|
||||
"id": "platforms",
|
||||
|
|
@ -82,9 +82,9 @@
|
|||
"pricing": [
|
||||
{
|
||||
"id": "free",
|
||||
"name": "Gratis para siempre",
|
||||
"name": "Gratis",
|
||||
"price": "$0",
|
||||
"period": "para siempre",
|
||||
"period": "",
|
||||
"description": "Todo incluido. Sin límites, sin claves API, sin tarjeta de crédito.",
|
||||
"features": [
|
||||
"Equipos de agentes ilimitados",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@
|
|||
{
|
||||
"id": "isFree",
|
||||
"question": "C'est vraiment gratuit ?",
|
||||
"answer": "Oui. L'application est 100% gratuite et open source. L'application n'a pas d'offre payante. Pour exécuter des agents, vous avez seulement besoin d'un accès à un provider/runtime pris en charge, comme Anthropic ou Codex."
|
||||
"answer": "Oui. L'application est gratuite et open source. L'application n'a pas d'offre payante. Pour exécuter des agents, vous avez seulement besoin d'un accès à un provider/runtime pris en charge, comme Anthropic ou Codex."
|
||||
},
|
||||
{
|
||||
"id": "platforms",
|
||||
|
|
@ -82,9 +82,9 @@
|
|||
"pricing": [
|
||||
{
|
||||
"id": "free",
|
||||
"name": "Gratuit pour toujours",
|
||||
"name": "Gratuit",
|
||||
"price": "0€",
|
||||
"period": "pour toujours",
|
||||
"period": "",
|
||||
"description": "Tout inclus. Sans limites, sans clés API, sans carte bancaire.",
|
||||
"features": [
|
||||
"Équipes d'agents illimitées",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@
|
|||
{
|
||||
"id": "isFree",
|
||||
"question": "क्या यह सच में मुफ़्त है?",
|
||||
"answer": "हाँ। ऐप 100% मुफ़्त और ओपन सोर्स है। ऐप का अपना कोई paid plan नहीं है। agents चलाने के लिए आपको सिर्फ़ किसी supported provider/runtime, जैसे Anthropic या Codex, का access चाहिए।"
|
||||
"answer": "हाँ। ऐप मुफ़्त और ओपन सोर्स है। ऐप का अपना कोई paid plan नहीं है। agents चलाने के लिए आपको सिर्फ़ किसी supported provider/runtime, जैसे Anthropic या Codex, का access चाहिए।"
|
||||
},
|
||||
{
|
||||
"id": "platforms",
|
||||
|
|
@ -82,9 +82,9 @@
|
|||
"pricing": [
|
||||
{
|
||||
"id": "free",
|
||||
"name": "हमेशा मुफ़्त",
|
||||
"name": "मुफ़्त",
|
||||
"price": "$0",
|
||||
"period": "हमेशा",
|
||||
"period": "",
|
||||
"description": "सब कुछ शामिल। कोई लिमिट नहीं, कोई API कुंजी नहीं, कोई क्रेडिट कार्ड नहीं।",
|
||||
"features": [
|
||||
"असीमित एजेंट टीमें",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@
|
|||
{
|
||||
"id": "isFree",
|
||||
"question": "本当に無料ですか?",
|
||||
"answer": "はい。アプリは100%無料のオープンソースです。アプリ自体に有料プランはありません。エージェントを実行するには、Anthropic や Codex など対応する provider/runtime へのアクセスだけが必要です。"
|
||||
"answer": "はい。アプリは無料のオープンソースです。アプリ自体に有料プランはありません。エージェントを実行するには、Anthropic や Codex など対応する provider/runtime へのアクセスだけが必要です。"
|
||||
},
|
||||
{
|
||||
"id": "platforms",
|
||||
|
|
@ -82,9 +82,9 @@
|
|||
"pricing": [
|
||||
{
|
||||
"id": "free",
|
||||
"name": "永久無料",
|
||||
"name": "無料",
|
||||
"price": "¥0",
|
||||
"period": "永久",
|
||||
"period": "",
|
||||
"description": "すべて含まれています。制限なし、APIキー不要、クレジットカード不要。",
|
||||
"features": [
|
||||
"無制限のエージェントチーム",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@
|
|||
{
|
||||
"id": "isFree",
|
||||
"question": "É realmente grátis?",
|
||||
"answer": "Sim. O app é 100% gratuito e open source. O app não tem plano pago próprio. Para rodar agentes, você só precisa de acesso a um provider/runtime compatível, como Anthropic ou Codex."
|
||||
"answer": "Sim. O app é gratuito e open source. O app não tem plano pago próprio. Para rodar agentes, você só precisa de acesso a um provider/runtime compatível, como Anthropic ou Codex."
|
||||
},
|
||||
{
|
||||
"id": "platforms",
|
||||
|
|
@ -82,9 +82,9 @@
|
|||
"pricing": [
|
||||
{
|
||||
"id": "free",
|
||||
"name": "Grátis para sempre",
|
||||
"name": "Grátis",
|
||||
"price": "$0",
|
||||
"period": "para sempre",
|
||||
"period": "",
|
||||
"description": "Tudo incluído. Sem limites, sem chaves de API, sem cartão de crédito.",
|
||||
"features": [
|
||||
"Equipes de agentes ilimitadas",
|
||||
|
|
|
|||
|
|
@ -82,9 +82,9 @@
|
|||
"pricing": [
|
||||
{
|
||||
"id": "free",
|
||||
"name": "Бесплатно навсегда",
|
||||
"name": "Бесплатно",
|
||||
"price": "$0",
|
||||
"period": "навсегда",
|
||||
"period": "",
|
||||
"description": "Всё включено. Без лимитов, без API-ключей, без кредитной карты.",
|
||||
"features": [
|
||||
"Безлимитные команды агентов",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@
|
|||
{
|
||||
"id": "isFree",
|
||||
"question": "真的免费吗?",
|
||||
"answer": "是的。应用本身 100% 免费且开源。应用没有自己的付费方案。要运行智能体,你只需要接入受支持的 provider/runtime,例如 Anthropic 或 Codex。"
|
||||
"answer": "是的。应用本身免费且开源。应用没有自己的付费方案。要运行智能体,你只需要接入受支持的 provider/runtime,例如 Anthropic 或 Codex。"
|
||||
},
|
||||
{
|
||||
"id": "platforms",
|
||||
|
|
@ -82,9 +82,9 @@
|
|||
"pricing": [
|
||||
{
|
||||
"id": "free",
|
||||
"name": "永久免费",
|
||||
"name": "免费",
|
||||
"price": "$0",
|
||||
"period": "永久",
|
||||
"period": "",
|
||||
"description": "全部功能。无限制,无需 API 密钥,无需信用卡。",
|
||||
"features": [
|
||||
"无限智能体团队",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"nav": {
|
||||
"features": "المميزات",
|
||||
"screenshots": "لقطات الشاشة",
|
||||
"docs": "التوثيق",
|
||||
"comparison": "المقارنة",
|
||||
"download": "تحميل",
|
||||
"pricing": "مجاني",
|
||||
|
|
@ -13,6 +14,7 @@
|
|||
"downloadNow": "حمّل الآن",
|
||||
"ctaPrimary": "تحميل لـ {platform}",
|
||||
"ctaSecondary": "مقارنة",
|
||||
"ctaDocs": "التوثيق",
|
||||
"preview": "معاينة المنتج",
|
||||
"trust": {
|
||||
"agentTeams": "فرق الوكلاء",
|
||||
|
|
@ -44,8 +46,8 @@
|
|||
"sectionTitle": "مجاني 100%. بدون شروط.",
|
||||
"sectionSubtitle": "مفتوح المصدر، بدون مفاتيح API، بدون إعدادات. فقط ثبّت وابدأ.",
|
||||
"getStarted": "حمّل الآن",
|
||||
"popular": "مجاني للأبد",
|
||||
"note": "مفتوح المصدر 100%. بدون مفاتيح API. بدون إعدادات. يعمل محلياً بالكامل."
|
||||
"popular": "مجاني",
|
||||
"note": "مفتوح المصدر. بدون مفاتيح API. بدون إعدادات. يعمل محلياً بالكامل."
|
||||
},
|
||||
"testimonials": {
|
||||
"sectionTitle": "ماذا يقول المطورون",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"nav": {
|
||||
"features": "Funktionen",
|
||||
"screenshots": "Screenshots",
|
||||
"docs": "Dokumentation",
|
||||
"comparison": "Vergleich",
|
||||
"download": "Download",
|
||||
"pricing": "Kostenlos",
|
||||
|
|
@ -13,6 +14,7 @@
|
|||
"downloadNow": "Herunterladen",
|
||||
"ctaPrimary": "Für {platform} herunterladen",
|
||||
"ctaSecondary": "Vergleichen",
|
||||
"ctaDocs": "Dokumentation",
|
||||
"preview": "Produktvorschau",
|
||||
"trust": {
|
||||
"agentTeams": "Agenten-Teams",
|
||||
|
|
@ -44,8 +46,8 @@
|
|||
"sectionTitle": "100% Kostenlos. Ohne Haken.",
|
||||
"sectionSubtitle": "Open Source, keine API-Schlüssel, keine Konfiguration. Einfach installieren und loslegen.",
|
||||
"getStarted": "Herunterladen",
|
||||
"popular": "Für immer kostenlos",
|
||||
"note": "100% Open Source. Keine API-Schlüssel. Keine Konfiguration. Läuft vollständig lokal."
|
||||
"popular": "Kostenlos",
|
||||
"note": "Open Source. Keine API-Schlüssel. Keine Konfiguration. Läuft vollständig lokal."
|
||||
},
|
||||
"testimonials": {
|
||||
"sectionTitle": "Was Entwickler sagen",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"nav": {
|
||||
"features": "Features",
|
||||
"screenshots": "Screenshots",
|
||||
"docs": "Documentation",
|
||||
"comparison": "Compare",
|
||||
"download": "Download",
|
||||
"pricing": "Free",
|
||||
|
|
@ -13,6 +14,7 @@
|
|||
"downloadNow": "Download Now",
|
||||
"ctaPrimary": "Download for {platform}",
|
||||
"ctaSecondary": "Compare",
|
||||
"ctaDocs": "Documentation",
|
||||
"preview": "Product preview",
|
||||
"trust": {
|
||||
"agentTeams": "Agent Teams",
|
||||
|
|
@ -44,8 +46,8 @@
|
|||
"sectionTitle": "100% Free. No strings attached.",
|
||||
"sectionSubtitle": "Open source, no API keys, no configuration. Just install and go.",
|
||||
"getStarted": "Download Now",
|
||||
"popular": "Free Forever",
|
||||
"note": "100% open source. No API keys. No configuration. Runs entirely locally."
|
||||
"popular": "Free",
|
||||
"note": "Open source. No API keys. No configuration. Runs entirely locally."
|
||||
},
|
||||
"testimonials": {
|
||||
"sectionTitle": "What developers say",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"nav": {
|
||||
"features": "Funciones",
|
||||
"screenshots": "Capturas",
|
||||
"docs": "Documentación",
|
||||
"comparison": "Comparar",
|
||||
"download": "Descargar",
|
||||
"pricing": "Gratis",
|
||||
|
|
@ -13,6 +14,7 @@
|
|||
"downloadNow": "Descargar ahora",
|
||||
"ctaPrimary": "Descargar para {platform}",
|
||||
"ctaSecondary": "Comparar",
|
||||
"ctaDocs": "Documentación",
|
||||
"preview": "Vista previa del producto",
|
||||
"trust": {
|
||||
"agentTeams": "Equipos de agentes",
|
||||
|
|
@ -44,8 +46,8 @@
|
|||
"sectionTitle": "100% Gratis. Sin letra pequeña.",
|
||||
"sectionSubtitle": "Código abierto, sin claves API, sin configuración. Instala y empieza.",
|
||||
"getStarted": "Descargar ahora",
|
||||
"popular": "Gratis para siempre",
|
||||
"note": "100% código abierto. Sin claves API. Sin configuración. Funciona completamente en local."
|
||||
"popular": "Gratis",
|
||||
"note": "Código abierto. Sin claves API. Sin configuración. Funciona completamente en local."
|
||||
},
|
||||
"testimonials": {
|
||||
"sectionTitle": "Lo que dicen los desarrolladores",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"nav": {
|
||||
"features": "Fonctionnalités",
|
||||
"screenshots": "Captures d'écran",
|
||||
"docs": "Documentation",
|
||||
"comparison": "Comparer",
|
||||
"download": "Télécharger",
|
||||
"pricing": "Gratuit",
|
||||
|
|
@ -13,6 +14,7 @@
|
|||
"downloadNow": "Télécharger",
|
||||
"ctaPrimary": "Télécharger pour {platform}",
|
||||
"ctaSecondary": "Comparer",
|
||||
"ctaDocs": "Documentation",
|
||||
"preview": "Aperçu du produit",
|
||||
"trust": {
|
||||
"agentTeams": "Équipes d'agents",
|
||||
|
|
@ -44,8 +46,8 @@
|
|||
"sectionTitle": "100% Gratuit. Sans conditions.",
|
||||
"sectionSubtitle": "Open source, sans clé API, sans configuration. Installez et c'est parti.",
|
||||
"getStarted": "Télécharger",
|
||||
"popular": "Gratuit pour toujours",
|
||||
"note": "100% open source. Sans clé API. Sans configuration. Fonctionne entièrement en local."
|
||||
"popular": "Gratuit",
|
||||
"note": "Open source. Sans clé API. Sans configuration. Fonctionne entièrement en local."
|
||||
},
|
||||
"testimonials": {
|
||||
"sectionTitle": "Ce que disent les développeurs",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"nav": {
|
||||
"features": "सुविधाएँ",
|
||||
"screenshots": "स्क्रीनशॉट",
|
||||
"docs": "दस्तावेज़",
|
||||
"comparison": "तुलना",
|
||||
"download": "डाउनलोड",
|
||||
"pricing": "मुफ़्त",
|
||||
|
|
@ -13,6 +14,7 @@
|
|||
"downloadNow": "अभी डाउनलोड करें",
|
||||
"ctaPrimary": "{platform} के लिए डाउनलोड करें",
|
||||
"ctaSecondary": "तुलना करें",
|
||||
"ctaDocs": "दस्तावेज़",
|
||||
"preview": "प्रोडक्ट प्रीव्यू",
|
||||
"trust": {
|
||||
"agentTeams": "एजेंट टीमें",
|
||||
|
|
@ -44,8 +46,8 @@
|
|||
"sectionTitle": "100% मुफ़्त। कोई शर्त नहीं।",
|
||||
"sectionSubtitle": "ओपन सोर्स, कोई API कुंजी नहीं, कोई कॉन्फ़िगरेशन नहीं। बस इंस्टॉल करें और शुरू करें।",
|
||||
"getStarted": "अभी डाउनलोड करें",
|
||||
"popular": "हमेशा मुफ़्त",
|
||||
"note": "100% ओपन सोर्स। कोई API कुंजी नहीं। कोई कॉन्फ़िगरेशन नहीं। पूरी तरह लोकल चलता है।"
|
||||
"popular": "मुफ़्त",
|
||||
"note": "ओपन सोर्स। कोई API कुंजी नहीं। कोई कॉन्फ़िगरेशन नहीं। पूरी तरह लोकल चलता है।"
|
||||
},
|
||||
"testimonials": {
|
||||
"sectionTitle": "डेवलपर्स क्या कहते हैं",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"nav": {
|
||||
"features": "機能",
|
||||
"screenshots": "スクリーンショット",
|
||||
"docs": "ドキュメント",
|
||||
"comparison": "比較",
|
||||
"download": "ダウンロード",
|
||||
"pricing": "無料",
|
||||
|
|
@ -13,6 +14,7 @@
|
|||
"downloadNow": "ダウンロード",
|
||||
"ctaPrimary": "{platform}版をダウンロード",
|
||||
"ctaSecondary": "比較する",
|
||||
"ctaDocs": "ドキュメント",
|
||||
"preview": "製品プレビュー",
|
||||
"trust": {
|
||||
"agentTeams": "エージェントチーム",
|
||||
|
|
@ -44,8 +46,8 @@
|
|||
"sectionTitle": "100%無料。制約なし。",
|
||||
"sectionSubtitle": "オープンソース、APIキー不要、設定不要。インストールするだけ。",
|
||||
"getStarted": "ダウンロード",
|
||||
"popular": "永久無料",
|
||||
"note": "100%オープンソース。APIキー不要。設定不要。完全にローカルで動作。"
|
||||
"popular": "無料",
|
||||
"note": "オープンソース。APIキー不要。設定不要。完全にローカルで動作。"
|
||||
},
|
||||
"testimonials": {
|
||||
"sectionTitle": "開発者の声",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"nav": {
|
||||
"features": "Recursos",
|
||||
"screenshots": "Capturas",
|
||||
"docs": "Documentação",
|
||||
"comparison": "Comparar",
|
||||
"download": "Baixar",
|
||||
"pricing": "Grátis",
|
||||
|
|
@ -13,6 +14,7 @@
|
|||
"downloadNow": "Baixar agora",
|
||||
"ctaPrimary": "Baixar para {platform}",
|
||||
"ctaSecondary": "Comparar",
|
||||
"ctaDocs": "Documentação",
|
||||
"preview": "Prévia do produto",
|
||||
"trust": {
|
||||
"agentTeams": "Equipes de agentes",
|
||||
|
|
@ -44,8 +46,8 @@
|
|||
"sectionTitle": "100% Grátis. Sem pegadinhas.",
|
||||
"sectionSubtitle": "Código aberto, sem chaves de API, sem configuração. Instale e comece.",
|
||||
"getStarted": "Baixar agora",
|
||||
"popular": "Grátis para sempre",
|
||||
"note": "100% código aberto. Sem chaves de API. Sem configuração. Roda totalmente local."
|
||||
"popular": "Grátis",
|
||||
"note": "Código aberto. Sem chaves de API. Sem configuração. Roda totalmente local."
|
||||
},
|
||||
"testimonials": {
|
||||
"sectionTitle": "O que os desenvolvedores dizem",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"nav": {
|
||||
"features": "Возможности",
|
||||
"screenshots": "Скриншоты",
|
||||
"docs": "Документация",
|
||||
"comparison": "Сравнение",
|
||||
"download": "Скачать",
|
||||
"pricing": "Бесплатно",
|
||||
|
|
@ -13,6 +14,7 @@
|
|||
"downloadNow": "Скачать",
|
||||
"ctaPrimary": "Скачать для {platform}",
|
||||
"ctaSecondary": "Сравнить",
|
||||
"ctaDocs": "Документация",
|
||||
"preview": "Превью продукта",
|
||||
"trust": {
|
||||
"agentTeams": "Команды агентов",
|
||||
|
|
@ -44,8 +46,8 @@
|
|||
"sectionTitle": "100% Бесплатно. Без подвоха.",
|
||||
"sectionSubtitle": "Открытый код, без API-ключей, без конфигурации. Просто установите и работайте.",
|
||||
"getStarted": "Скачать",
|
||||
"popular": "Бесплатно навсегда",
|
||||
"note": "100% открытый код. Без API-ключей. Без конфигурации. Работает полностью локально."
|
||||
"popular": "Бесплатно",
|
||||
"note": "Открытый код. Без API-ключей. Без конфигурации. Работает полностью локально."
|
||||
},
|
||||
"testimonials": {
|
||||
"sectionTitle": "Что говорят разработчики",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"nav": {
|
||||
"features": "功能",
|
||||
"screenshots": "截图",
|
||||
"docs": "文档",
|
||||
"comparison": "对比",
|
||||
"download": "下载",
|
||||
"pricing": "免费",
|
||||
|
|
@ -13,6 +14,7 @@
|
|||
"downloadNow": "立即下载",
|
||||
"ctaPrimary": "下载 {platform} 版",
|
||||
"ctaSecondary": "对比",
|
||||
"ctaDocs": "文档",
|
||||
"preview": "产品预览",
|
||||
"trust": {
|
||||
"agentTeams": "智能体团队",
|
||||
|
|
@ -44,8 +46,8 @@
|
|||
"sectionTitle": "100% 免费,没有附加条件。",
|
||||
"sectionSubtitle": "开源,无需 API 密钥,无需配置。安装即用。",
|
||||
"getStarted": "立即下载",
|
||||
"popular": "永久免费",
|
||||
"note": "100% 开源。无需 API 密钥。无需配置。完全本地运行。"
|
||||
"popular": "免费",
|
||||
"note": "开源。无需 API 密钥。无需配置。完全本地运行。"
|
||||
},
|
||||
"testimonials": {
|
||||
"sectionTitle": "开发者怎么说",
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ export default defineNuxtConfig({
|
|||
nitro: {
|
||||
compressPublicAssets: true,
|
||||
prerender: {
|
||||
ignore: [
|
||||
"/docs",
|
||||
"/docs/**"
|
||||
],
|
||||
routes: [
|
||||
...generateI18nRoutes(),
|
||||
"/sitemap.xml",
|
||||
|
|
|
|||
2011
landing/package-lock.json
generated
2011
landing/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,7 +6,12 @@
|
|||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
"generate": "nuxt generate",
|
||||
"generate:docs": "vitepress build product-docs --outDir .output/public/docs",
|
||||
"generate:all": "nuxt generate && vitepress build product-docs --outDir .output/public/docs",
|
||||
"preview": "nuxt preview",
|
||||
"docs:dev": "vitepress dev product-docs",
|
||||
"docs:build": "vitepress build product-docs",
|
||||
"docs:preview": "vitepress preview product-docs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier . --check",
|
||||
|
|
@ -29,9 +34,14 @@
|
|||
"devDependencies": {
|
||||
"@mdi/font": "^7.4.47",
|
||||
"@nuxt/eslint": "^1.12.1",
|
||||
"@shikijs/transformers": "3.22.0",
|
||||
"eslint": "^9.39.2",
|
||||
"medium-zoom": "^1.1.0",
|
||||
"prettier": "^3.8.0",
|
||||
"sass": "^1.97.2",
|
||||
"vite-plugin-vuetify": "^2.1.3"
|
||||
"vite-plugin-vuetify": "^2.1.3",
|
||||
"vitepress": "2.0.0-alpha.17",
|
||||
"vitepress-codeblock-collapse": "^1.0.0",
|
||||
"vitepress-plugin-llms": "^1.12.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,15 @@ import "vuetify/styles";
|
|||
import { createVuetify } from "vuetify";
|
||||
import { aliases, mdi } from "vuetify/iconsets/mdi-svg";
|
||||
|
||||
const brand = {
|
||||
cyan: "#00f0ff",
|
||||
magenta: "#ff00ff",
|
||||
lightBackground: "#f0f2f5",
|
||||
lightSurface: "#ffffff",
|
||||
darkBackground: "#0a0a0f",
|
||||
darkSurface: "#12121a"
|
||||
};
|
||||
|
||||
export default defineNuxtPlugin({
|
||||
name: "vuetify",
|
||||
setup(nuxtApp) {
|
||||
|
|
@ -16,18 +25,18 @@ export default defineNuxtPlugin({
|
|||
themes: {
|
||||
light: {
|
||||
colors: {
|
||||
primary: "#00f0ff",
|
||||
secondary: "#ff00ff",
|
||||
background: "#f0f2f5",
|
||||
surface: "#ffffff"
|
||||
primary: brand.cyan,
|
||||
secondary: brand.magenta,
|
||||
background: brand.lightBackground,
|
||||
surface: brand.lightSurface
|
||||
}
|
||||
},
|
||||
dark: {
|
||||
colors: {
|
||||
primary: "#00f0ff",
|
||||
secondary: "#ff00ff",
|
||||
background: "#0a0a0f",
|
||||
surface: "#12121a"
|
||||
primary: brand.cyan,
|
||||
secondary: brand.magenta,
|
||||
background: brand.darkBackground,
|
||||
surface: brand.darkSurface
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
212
landing/product-docs/.vitepress/config.ts
Normal file
212
landing/product-docs/.vitepress/config.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import {
|
||||
transformerNotationDiff,
|
||||
transformerNotationErrorLevel,
|
||||
transformerNotationFocus,
|
||||
transformerNotationHighlight
|
||||
} from "@shikijs/transformers";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig, type DefaultTheme } from "vitepress";
|
||||
import llmstxt, { copyOrDownloadAsMarkdownButtons } from "vitepress-plugin-llms";
|
||||
|
||||
const REPO = "777genius/claude_agent_teams_ui";
|
||||
const SITE_TITLE = "Agent Teams Docs";
|
||||
const SITE_DESCRIPTION = "Documentation for Agent Teams, a local desktop app for AI agent orchestration.";
|
||||
|
||||
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "");
|
||||
const normalizeBase = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed === "/") return "/";
|
||||
return `/${trimmed.replace(/^\/+|\/+$/g, "")}/`;
|
||||
};
|
||||
const withTrailingSlash = (value: string) => `${trimTrailingSlash(value)}/`;
|
||||
|
||||
const appBase = normalizeBase(process.env.NUXT_APP_BASE_URL || "/");
|
||||
const base = appBase === "/" ? "/docs/" : `${appBase}docs/`;
|
||||
const siteUrl = trimTrailingSlash(
|
||||
process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/claude_agent_teams_ui"
|
||||
);
|
||||
const publicBaseUrl =
|
||||
appBase === "/" || siteUrl.endsWith(trimTrailingSlash(appBase))
|
||||
? withTrailingSlash(siteUrl)
|
||||
: `${withTrailingSlash(siteUrl)}${appBase.replace(/^\/+/, "")}`;
|
||||
const docsUrl = `${publicBaseUrl}docs/`;
|
||||
const downloadUrl = `${publicBaseUrl}download/`;
|
||||
const ruDownloadUrl = `${publicBaseUrl}ru/download/`;
|
||||
const landingPublicDir = fileURLToPath(new URL("../../public", import.meta.url));
|
||||
|
||||
const rootGuide: DefaultTheme.SidebarItem[] = [
|
||||
{
|
||||
text: "Start",
|
||||
items: [
|
||||
{ text: "Quickstart", link: "/guide/quickstart" },
|
||||
{ text: "Installation", link: "/guide/installation" },
|
||||
{ text: "Create a team", link: "/guide/create-team" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Workflows",
|
||||
items: [
|
||||
{ text: "Runtime setup", link: "/guide/runtime-setup" },
|
||||
{ text: "Agent workflow", link: "/guide/agent-workflow" },
|
||||
{ text: "Code review", link: "/guide/code-review" },
|
||||
{ text: "Troubleshooting", link: "/guide/troubleshooting" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Reference",
|
||||
items: [
|
||||
{ text: "Concepts", link: "/reference/concepts" },
|
||||
{ text: "Providers and runtimes", link: "/reference/providers-runtimes" },
|
||||
{ text: "Privacy and local data", link: "/reference/privacy-local-data" },
|
||||
{ text: "FAQ", link: "/reference/faq" }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const ruGuide: DefaultTheme.SidebarItem[] = [
|
||||
{
|
||||
text: "Старт",
|
||||
items: [
|
||||
{ text: "Быстрый старт", link: "/ru/guide/quickstart" },
|
||||
{ text: "Установка", link: "/ru/guide/installation" },
|
||||
{ text: "Создание команды", link: "/ru/guide/create-team" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Рабочие процессы",
|
||||
items: [
|
||||
{ text: "Настройка рантайма", link: "/ru/guide/runtime-setup" },
|
||||
{ text: "Работа агентов", link: "/ru/guide/agent-workflow" },
|
||||
{ text: "Код-ревью", link: "/ru/guide/code-review" },
|
||||
{ text: "Диагностика", link: "/ru/guide/troubleshooting" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Справочник",
|
||||
items: [
|
||||
{ text: "Концепции", link: "/ru/reference/concepts" },
|
||||
{ text: "Провайдеры и рантаймы", link: "/ru/reference/providers-runtimes" },
|
||||
{ text: "Приватность и локальные данные", link: "/ru/reference/privacy-local-data" },
|
||||
{ text: "FAQ", link: "/ru/reference/faq" }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const rootNav: DefaultTheme.NavItem[] = [
|
||||
{ text: "Guide", link: "/guide/quickstart" },
|
||||
{ text: "Reference", link: "/reference/concepts" },
|
||||
{ text: "Troubleshooting", link: "/guide/troubleshooting" },
|
||||
{ text: "Download", link: downloadUrl, target: "_self" }
|
||||
];
|
||||
|
||||
const ruNav: DefaultTheme.NavItem[] = [
|
||||
{ text: "Руководство", link: "/ru/guide/quickstart" },
|
||||
{ text: "Справочник", link: "/ru/reference/concepts" },
|
||||
{ text: "Диагностика", link: "/ru/guide/troubleshooting" },
|
||||
{ text: "Скачать", link: ruDownloadUrl, target: "_self" }
|
||||
];
|
||||
|
||||
export default defineConfig({
|
||||
lang: "en-US",
|
||||
title: SITE_TITLE,
|
||||
description: SITE_DESCRIPTION,
|
||||
base,
|
||||
cleanUrls: true,
|
||||
lastUpdated: true,
|
||||
sitemap: {
|
||||
hostname: docsUrl
|
||||
},
|
||||
head: [
|
||||
["link", { rel: "icon", type: "image/png", href: `${base}logo-192.png` }],
|
||||
["meta", { name: "theme-color", content: "#00f0ff" }],
|
||||
["meta", { property: "og:type", content: "website" }],
|
||||
["meta", { property: "og:title", content: SITE_TITLE }],
|
||||
["meta", { property: "og:description", content: SITE_DESCRIPTION }],
|
||||
["meta", { property: "og:url", content: docsUrl }],
|
||||
["meta", { property: "og:image", content: `${publicBaseUrl}og-image.png` }],
|
||||
["meta", { name: "twitter:card", content: "summary_large_image" }]
|
||||
],
|
||||
vite: {
|
||||
publicDir: landingPublicDir,
|
||||
plugins: [llmstxt()],
|
||||
optimizeDeps: {
|
||||
include: ["medium-zoom", "vitepress-codeblock-collapse"]
|
||||
}
|
||||
},
|
||||
markdown: {
|
||||
codeTransformers: [
|
||||
transformerNotationDiff(),
|
||||
transformerNotationFocus(),
|
||||
transformerNotationHighlight(),
|
||||
transformerNotationErrorLevel()
|
||||
],
|
||||
config(md) {
|
||||
md.use(copyOrDownloadAsMarkdownButtons);
|
||||
}
|
||||
},
|
||||
themeConfig: {
|
||||
logo: "/logo-192.png",
|
||||
siteTitle: "Agent Teams",
|
||||
outline: {
|
||||
level: [2, 3],
|
||||
label: "On this page"
|
||||
},
|
||||
search: {
|
||||
provider: "local"
|
||||
},
|
||||
nav: rootNav,
|
||||
sidebar: {
|
||||
"/ru/": ruGuide,
|
||||
"/": rootGuide
|
||||
},
|
||||
socialLinks: [{ icon: "github", link: `https://github.com/${REPO}` }],
|
||||
editLink: {
|
||||
pattern: `https://github.com/${REPO}/edit/main/landing/product-docs/:path`,
|
||||
text: "Edit this page on GitHub"
|
||||
},
|
||||
footer: {
|
||||
message: "Free and open source.",
|
||||
copyright: "Copyright © 777genius"
|
||||
}
|
||||
},
|
||||
locales: {
|
||||
root: {
|
||||
label: "English",
|
||||
lang: "en-US",
|
||||
themeConfig: {
|
||||
nav: rootNav,
|
||||
sidebar: rootGuide,
|
||||
docFooter: {
|
||||
prev: "Previous",
|
||||
next: "Next"
|
||||
}
|
||||
}
|
||||
},
|
||||
ru: {
|
||||
label: "Русский",
|
||||
lang: "ru-RU",
|
||||
title: "Документация Agent Teams",
|
||||
description: "Документация Agent Teams, локального desktop-приложения для оркестрации AI-агентов.",
|
||||
themeConfig: {
|
||||
nav: ruNav,
|
||||
sidebar: ruGuide,
|
||||
outline: {
|
||||
level: [2, 3],
|
||||
label: "На этой странице"
|
||||
},
|
||||
editLink: {
|
||||
pattern: `https://github.com/${REPO}/edit/main/landing/product-docs/:path`,
|
||||
text: "Редактировать на GitHub"
|
||||
},
|
||||
docFooter: {
|
||||
prev: "Назад",
|
||||
next: "Дальше"
|
||||
},
|
||||
footer: {
|
||||
message: "Бесплатно и с открытым кодом.",
|
||||
copyright: "Copyright © 777genius"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
117
landing/product-docs/.vitepress/theme/DocsCardGrid.vue
Normal file
117
landing/product-docs/.vitepress/theme/DocsCardGrid.vue
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<script setup lang="ts">
|
||||
import { useData, withBase } from "vitepress";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = withDefaults(defineProps<{ type?: "start" | "reference" }>(), {
|
||||
type: "start"
|
||||
});
|
||||
|
||||
const { page } = useData();
|
||||
const isRu = computed(() => page.value.relativePath.startsWith("ru/"));
|
||||
|
||||
const cards = computed(() => {
|
||||
if (isRu.value) {
|
||||
return props.type === "reference"
|
||||
? [
|
||||
{ icon: "◈", title: "Концепции", desc: "Команды, задачи, роли и уровни автономности.", link: "/ru/reference/concepts" },
|
||||
{ icon: "⌁", title: "Рантаймы", desc: "Claude, Codex, OpenCode и multimodel-режим.", link: "/ru/reference/providers-runtimes" },
|
||||
{ icon: "⌘", title: "Локальные данные", desc: "Что хранится на машине и что уходит провайдерам.", link: "/ru/reference/privacy-local-data" },
|
||||
{ icon: "?", title: "FAQ", desc: "Короткие ответы на частые вопросы.", link: "/ru/reference/faq" }
|
||||
]
|
||||
: [
|
||||
{ icon: "01", title: "Быстрый старт", desc: "Поставить приложение и создать первую команду.", link: "/ru/guide/quickstart" },
|
||||
{ icon: "02", title: "Установка", desc: "Платформы, релизы и запуск из исходников.", link: "/ru/guide/installation" },
|
||||
{ icon: "03", title: "Создание команды", desc: "Роли, lead prompt и границы работы.", link: "/ru/guide/create-team" },
|
||||
{ icon: "04", title: "Код-ревью", desc: "Проверка изменений по задачам и hunk-level decisions.", link: "/ru/guide/code-review" }
|
||||
];
|
||||
}
|
||||
|
||||
return props.type === "reference"
|
||||
? [
|
||||
{ icon: "◈", title: "Concepts", desc: "Teams, tasks, roles, and autonomy levels.", link: "/reference/concepts" },
|
||||
{ icon: "⌁", title: "Runtimes", desc: "Claude, Codex, OpenCode, and multimodel mode.", link: "/reference/providers-runtimes" },
|
||||
{ icon: "⌘", title: "Local data", desc: "What stays on disk and what providers receive.", link: "/reference/privacy-local-data" },
|
||||
{ icon: "?", title: "FAQ", desc: "Short answers to common questions.", link: "/reference/faq" }
|
||||
]
|
||||
: [
|
||||
{ icon: "01", title: "Quickstart", desc: "Install the app and create your first team.", link: "/guide/quickstart" },
|
||||
{ icon: "02", title: "Installation", desc: "Platforms, releases, and running from source.", link: "/guide/installation" },
|
||||
{ icon: "03", title: "Create a team", desc: "Roles, lead prompt, and task boundaries.", link: "/guide/create-team" },
|
||||
{ icon: "04", title: "Code review", desc: "Review task changes with hunk-level decisions.", link: "/guide/code-review" }
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="docs-card-grid">
|
||||
<a v-for="card in cards" :key="card.link" class="docs-card" :href="withBase(card.link)">
|
||||
<span class="docs-card__icon">{{ card.icon }}</span>
|
||||
<strong>{{ card.title }}</strong>
|
||||
<span>{{ card.desc }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.docs-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.docs-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
column-gap: 12px;
|
||||
row-gap: 4px;
|
||||
padding: 18px;
|
||||
border: var(--at-glass-border);
|
||||
border-radius: var(--at-radius-xl);
|
||||
background: var(--at-c-surface-soft);
|
||||
color: var(--at-c-text);
|
||||
text-decoration: none !important;
|
||||
transition:
|
||||
border-color var(--at-transition-base),
|
||||
background-color var(--at-transition-base),
|
||||
transform var(--at-transition-base);
|
||||
}
|
||||
|
||||
.docs-card:hover {
|
||||
border-color: var(--at-c-border-strong);
|
||||
background: var(--at-glass-bg-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.docs-card__icon {
|
||||
grid-row: 1 / -1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--at-radius-md);
|
||||
background: var(--at-gradient-panel);
|
||||
color: var(--at-c-cyan);
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 13px;
|
||||
border: 1px solid rgba(0, 240, 255, 0.14);
|
||||
}
|
||||
|
||||
.docs-card strong {
|
||||
color: var(--at-c-text);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.docs-card span:last-child {
|
||||
color: var(--at-c-text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.docs-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
83
landing/product-docs/.vitepress/theme/DocsHeroVisual.vue
Normal file
83
landing/product-docs/.vitepress/theme/DocsHeroVisual.vue
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<script setup lang="ts">
|
||||
import { withBase } from "vitepress";
|
||||
|
||||
const workflowVideoSrc = "https://github.com/user-attachments/assets/35e27989-726d-4059-8662-bae610e46b42";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="docs-hero-visual">
|
||||
<video
|
||||
class="docs-hero-visual__video no-zoom"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
preload="metadata"
|
||||
:poster="withBase('/screenshots/1.jpg')"
|
||||
>
|
||||
<source :src="workflowVideoSrc" type="video/mp4">
|
||||
</video>
|
||||
<div class="docs-hero-visual__wash" />
|
||||
<div class="docs-hero-visual__edge" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.docs-hero-visual {
|
||||
position: absolute;
|
||||
inset: -130px -120px -110px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.docs-hero-visual__video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: blur(1px) saturate(1.22) contrast(1.08);
|
||||
opacity: 0.95;
|
||||
mix-blend-mode: multiply;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.docs-hero-visual__wash {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(90deg, var(--vp-c-bg) 0%, color-mix(in srgb, var(--vp-c-bg) 82%, transparent) 34%, color-mix(in srgb, var(--vp-c-bg) 8%, transparent) 64%, color-mix(in srgb, var(--vp-c-bg) 26%, transparent) 100%),
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--vp-c-bg) 32%, transparent) 0%, color-mix(in srgb, var(--vp-c-bg) 44%, transparent) 58%, var(--vp-c-bg) 96%);
|
||||
}
|
||||
|
||||
.docs-hero-visual__edge {
|
||||
position: absolute;
|
||||
inset: auto 0 0;
|
||||
height: 42%;
|
||||
background: linear-gradient(180deg, transparent, var(--vp-c-bg));
|
||||
}
|
||||
|
||||
.dark .docs-hero-visual__video {
|
||||
opacity: 0.95;
|
||||
filter: blur(1px) saturate(1.24) contrast(1.08);
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
.dark .docs-hero-visual__wash {
|
||||
background:
|
||||
linear-gradient(90deg, var(--vp-c-bg) 0%, color-mix(in srgb, var(--vp-c-bg) 76%, transparent) 34%, color-mix(in srgb, var(--vp-c-bg) 8%, transparent) 64%, color-mix(in srgb, var(--vp-c-bg) 34%, transparent) 100%),
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--vp-c-bg) 28%, transparent) 0%, color-mix(in srgb, var(--vp-c-bg) 52%, transparent) 58%, var(--vp-c-bg) 96%);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.docs-hero-visual {
|
||||
inset: -90px -72px -80px;
|
||||
}
|
||||
|
||||
.docs-hero-visual__video {
|
||||
opacity: 0.95;
|
||||
filter: blur(1px) saturate(1.2) contrast(1.06);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
89
landing/product-docs/.vitepress/theme/DocsLayout.vue
Normal file
89
landing/product-docs/.vitepress/theme/DocsLayout.vue
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<script setup lang="ts">
|
||||
import mediumZoom, { type Zoom } from "medium-zoom";
|
||||
import { useData } from "vitepress";
|
||||
import DefaultTheme from "vitepress/theme";
|
||||
import { useCodeblockCollapse } from "vitepress-codeblock-collapse";
|
||||
import "vitepress-codeblock-collapse/style.css";
|
||||
import { computed, nextTick, onMounted, onUnmounted, provide, watch } from "vue";
|
||||
import DocsHeroVisual from "./DocsHeroVisual.vue";
|
||||
|
||||
const { Layout } = DefaultTheme;
|
||||
const { isDark, page } = useData();
|
||||
|
||||
const pagePath = computed(() => page.value.relativePath);
|
||||
useCodeblockCollapse(pagePath);
|
||||
|
||||
let zoom: Zoom | null = null;
|
||||
|
||||
type ViewTransitionHandle = {
|
||||
ready: Promise<void>;
|
||||
};
|
||||
|
||||
type ViewTransitionDocument = Document & {
|
||||
startViewTransition?: (callback: () => Promise<void>) => ViewTransitionHandle;
|
||||
};
|
||||
|
||||
const refreshImageZoom = async () => {
|
||||
await nextTick();
|
||||
zoom?.detach();
|
||||
zoom = mediumZoom(".vp-doc img:not(.no-zoom), .docs-zoom-image", {
|
||||
background: isDark.value ? "rgba(10, 10, 15, 0.94)" : "rgba(248, 250, 252, 0.94)",
|
||||
margin: 24,
|
||||
scrollOffset: 0
|
||||
});
|
||||
};
|
||||
|
||||
const enableTransitions = () =>
|
||||
typeof document !== "undefined" &&
|
||||
"startViewTransition" in document &&
|
||||
window.matchMedia("(prefers-reduced-motion: no-preference)").matches;
|
||||
|
||||
provide("toggle-appearance", async ({ clientX: x, clientY: y }: MouseEvent) => {
|
||||
if (!enableTransitions()) {
|
||||
isDark.value = !isDark.value;
|
||||
return;
|
||||
}
|
||||
|
||||
const radius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
|
||||
const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`];
|
||||
|
||||
const transitionDocument = document as ViewTransitionDocument;
|
||||
const transition = transitionDocument.startViewTransition?.(async () => {
|
||||
isDark.value = !isDark.value;
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
if (!transition) return;
|
||||
|
||||
await transition.ready;
|
||||
|
||||
document.documentElement.animate(
|
||||
{ clipPath: isDark.value ? clipPath.reverse() : clipPath },
|
||||
{
|
||||
duration: 300,
|
||||
easing: "ease-in",
|
||||
pseudoElement: `::view-transition-${isDark.value ? "old" : "new"}(root)`
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void refreshImageZoom();
|
||||
});
|
||||
|
||||
watch([pagePath, isDark], () => {
|
||||
void refreshImageZoom();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
zoom?.detach();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<template #home-hero-image>
|
||||
<DocsHeroVisual />
|
||||
</template>
|
||||
</Layout>
|
||||
</template>
|
||||
76
landing/product-docs/.vitepress/theme/InstallBlock.vue
Normal file
76
landing/product-docs/.vitepress/theme/InstallBlock.vue
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
command?: string;
|
||||
label?: string;
|
||||
copiedLabel?: string;
|
||||
}>(),
|
||||
{
|
||||
command: "git clone https://github.com/777genius/claude_agent_teams_ui.git",
|
||||
label: "Click to copy",
|
||||
copiedLabel: "Copied"
|
||||
}
|
||||
);
|
||||
|
||||
const copied = ref(false);
|
||||
const copyLabel = computed(() => (copied.value ? props.copiedLabel : props.label));
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(props.command);
|
||||
copied.value = true;
|
||||
window.setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 1800);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="install-block" type="button" @click="copy">
|
||||
<code>$ {{ command }}</code>
|
||||
<span>{{ copyLabel }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.install-block {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
gap: 12px;
|
||||
margin: 12px 0 4px;
|
||||
padding: 12px 16px;
|
||||
border: var(--at-glass-border);
|
||||
border-radius: var(--at-radius-lg);
|
||||
background: var(--at-c-surface-soft);
|
||||
color: var(--at-c-text);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--at-transition-base),
|
||||
background-color var(--at-transition-base),
|
||||
transform var(--at-transition-base);
|
||||
}
|
||||
|
||||
.install-block:hover {
|
||||
border-color: var(--at-c-border-strong);
|
||||
background: var(--at-glass-bg-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.install-block code {
|
||||
overflow: hidden;
|
||||
color: var(--at-c-text);
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.install-block span {
|
||||
flex-shrink: 0;
|
||||
color: var(--at-c-cyan);
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
39
landing/product-docs/.vitepress/theme/ZoomImage.vue
Normal file
39
landing/product-docs/.vitepress/theme/ZoomImage.vue
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script setup lang="ts">
|
||||
import { withBase } from "vitepress";
|
||||
|
||||
defineProps<{
|
||||
src: string;
|
||||
alt: string;
|
||||
caption?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure class="zoom-image">
|
||||
<img class="docs-zoom-image" :src="withBase(src)" :alt="alt" loading="lazy" decoding="async">
|
||||
<figcaption v-if="caption">{{ caption }}</figcaption>
|
||||
</figure>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.zoom-image {
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.zoom-image img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: var(--at-glass-border);
|
||||
border-radius: var(--at-radius-xl);
|
||||
background: var(--at-c-dark-1);
|
||||
box-shadow: var(--at-shadow-card);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.zoom-image figcaption {
|
||||
margin-top: 8px;
|
||||
color: var(--at-c-text-muted);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
400
landing/product-docs/.vitepress/theme/custom.css
Normal file
400
landing/product-docs/.vitepress/theme/custom.css
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
:root {
|
||||
--vp-font-family-base: var(--at-font-sans);
|
||||
--vp-font-family-mono: var(--at-font-mono);
|
||||
--vp-c-brand-1: var(--at-c-cyan);
|
||||
--vp-c-brand-2: var(--at-c-cyan-strong);
|
||||
--vp-c-brand-3: var(--at-c-cyan-deep);
|
||||
--vp-c-brand-soft: rgba(0, 240, 255, 0.12);
|
||||
--vp-c-bg: var(--at-c-light-2);
|
||||
--vp-c-bg-alt: var(--at-c-light-1);
|
||||
--vp-c-bg-elv: var(--at-c-light-0);
|
||||
--vp-c-bg-soft: rgba(255, 255, 255, 0.74);
|
||||
--vp-c-text-1: var(--at-c-text-light-1);
|
||||
--vp-c-text-2: var(--at-c-text-light-2);
|
||||
--vp-c-text-3: var(--at-c-text-light-3);
|
||||
--vp-c-divider: rgba(0, 0, 0, 0.08);
|
||||
--vp-c-border: rgba(0, 0, 0, 0.08);
|
||||
--vp-nav-bg-color: rgba(255, 255, 255, 0.82);
|
||||
--vp-home-hero-name-color: transparent;
|
||||
--vp-home-hero-name-background: linear-gradient(135deg, #111827 0%, #047f94 54%, #7c2d8f 100%);
|
||||
--vp-button-brand-bg: var(--at-c-cyan);
|
||||
--vp-button-brand-text: var(--at-c-dark-1);
|
||||
--vp-button-brand-hover-bg: var(--at-c-green);
|
||||
--vp-button-alt-bg: rgba(255, 255, 255, 0.72);
|
||||
--vp-button-alt-hover-bg: rgba(255, 255, 255, 0.92);
|
||||
--vp-code-bg: rgba(8, 145, 178, 0.08);
|
||||
--vp-code-color: var(--at-c-cyan-deep);
|
||||
--vp-code-block-bg: #0a0a0f;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--vp-c-bg: var(--at-c-dark-1);
|
||||
--vp-c-bg-alt: var(--at-c-dark-2);
|
||||
--vp-c-bg-elv: rgba(18, 18, 26, 0.96);
|
||||
--vp-c-bg-soft: rgba(10, 10, 15, 0.72);
|
||||
--vp-c-text-1: var(--at-c-text-dark-1);
|
||||
--vp-c-text-2: var(--at-c-text-dark-2);
|
||||
--vp-c-text-3: var(--at-c-text-dark-muted);
|
||||
--vp-c-divider: rgba(0, 240, 255, 0.1);
|
||||
--vp-c-border: rgba(0, 240, 255, 0.12);
|
||||
--vp-nav-bg-color: rgba(10, 10, 15, 0.82);
|
||||
--vp-home-hero-name-background: var(--at-gradient-brand-text);
|
||||
--vp-button-alt-bg: rgba(0, 240, 255, 0.08);
|
||||
--vp-button-alt-hover-bg: rgba(0, 240, 255, 0.14);
|
||||
--vp-code-bg: rgba(0, 240, 255, 0.1);
|
||||
--vp-code-color: var(--at-c-cyan);
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
body {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.Layout {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.Layout::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(0, 240, 255, 0.08), transparent 320px),
|
||||
linear-gradient(135deg, rgba(255, 0, 255, 0.055), transparent 42%),
|
||||
var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.Layout::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(180deg, transparent 0%, color-mix(in srgb, var(--vp-c-bg) 72%, transparent) 58%, var(--vp-c-bg) 100%);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.VPNavBar {
|
||||
border-bottom: 1px solid var(--vp-c-divider) !important;
|
||||
background: var(--vp-nav-bg-color) !important;
|
||||
backdrop-filter: blur(var(--at-blur-md)) !important;
|
||||
-webkit-backdrop-filter: blur(var(--at-blur-md)) !important;
|
||||
}
|
||||
|
||||
.VPNavBarTitle .logo {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--at-radius-sm);
|
||||
}
|
||||
|
||||
.VPNavBarTitle .title {
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 14px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.VPHero .name.clip {
|
||||
background: var(--vp-home-hero-name-background) !important;
|
||||
-webkit-background-clip: text !important;
|
||||
background-clip: text !important;
|
||||
-webkit-text-fill-color: transparent !important;
|
||||
}
|
||||
|
||||
.VPHero .text {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.VPHero .tagline {
|
||||
max-width: 680px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.VPHero .actions {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.VPHero.has-image {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 560px;
|
||||
padding: 96px 24px 76px;
|
||||
}
|
||||
|
||||
.VPHero.has-image .container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block !important;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.VPHero.has-image .main {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 820px !important;
|
||||
padding: 38px 0;
|
||||
}
|
||||
|
||||
.VPHero.has-image .image {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
z-index: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
margin: 0 !important;
|
||||
transform: none !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.VPHero.has-image .image-container {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.VPHero.has-image .image-bg {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.VPButton {
|
||||
border-radius: var(--at-radius-lg) !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
min-width: 118px;
|
||||
min-height: 42px;
|
||||
padding: 0 18px !important;
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 13px !important;
|
||||
font-weight: 700 !important;
|
||||
line-height: 1 !important;
|
||||
border: 1px solid transparent !important;
|
||||
transition:
|
||||
transform var(--at-transition-base),
|
||||
border-color var(--at-transition-base),
|
||||
box-shadow var(--at-transition-base),
|
||||
background-color var(--at-transition-base) !important;
|
||||
}
|
||||
|
||||
.VPButton:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.VPButton.brand {
|
||||
background: var(--at-gradient-brand) !important;
|
||||
color: #061018 !important;
|
||||
border-color: rgba(0, 240, 255, 0.38) !important;
|
||||
box-shadow: var(--at-shadow-cyan-sm);
|
||||
}
|
||||
|
||||
.VPButton.alt {
|
||||
color: var(--vp-c-text-1) !important;
|
||||
border-color: var(--vp-c-border) !important;
|
||||
box-shadow: 0 12px 28px -22px rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
|
||||
.VPButton.brand:hover,
|
||||
.VPButton.alt:hover {
|
||||
border-color: var(--at-c-border-strong) !important;
|
||||
}
|
||||
|
||||
.VPFeature {
|
||||
border: var(--at-glass-border) !important;
|
||||
border-radius: var(--at-radius-xl) !important;
|
||||
background: var(--vp-c-bg-soft) !important;
|
||||
backdrop-filter: blur(var(--at-blur-sm));
|
||||
transition:
|
||||
transform var(--at-transition-base),
|
||||
border-color var(--at-transition-base),
|
||||
box-shadow var(--at-transition-base) !important;
|
||||
}
|
||||
|
||||
.VPFeature:hover {
|
||||
border-color: var(--at-c-border-strong) !important;
|
||||
box-shadow: var(--at-shadow-cyan-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.VPFeature .box {
|
||||
display: grid !important;
|
||||
grid-template-columns: 42px 1fr !important;
|
||||
grid-template-rows: auto auto !important;
|
||||
column-gap: 12px !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.VPFeature .box > .icon {
|
||||
grid-row: 1 !important;
|
||||
grid-column: 1 !important;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-bottom: 0 !important;
|
||||
border-radius: var(--at-radius-md);
|
||||
background: var(--at-gradient-panel);
|
||||
border: 1px solid rgba(0, 240, 255, 0.14);
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.VPFeature .box > .title {
|
||||
grid-row: 1 !important;
|
||||
grid-column: 2 !important;
|
||||
}
|
||||
|
||||
.VPFeature .box > .details {
|
||||
grid-row: 2 !important;
|
||||
grid-column: 1 / -1 !important;
|
||||
}
|
||||
|
||||
.VPFeature .box > .link-text {
|
||||
grid-row: 3 !important;
|
||||
grid-column: 1 / -1 !important;
|
||||
margin-top: 8px !important;
|
||||
}
|
||||
|
||||
.VPFeature .box > .link-text .link-text-value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.VPSidebar {
|
||||
background: color-mix(in srgb, var(--vp-c-bg) 86%, transparent) !important;
|
||||
backdrop-filter: blur(var(--at-blur-md));
|
||||
}
|
||||
|
||||
.VPSidebarItem .item .text,
|
||||
.VPDocAsideOutline .outline-link {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.vp-doc h1,
|
||||
.vp-doc h2,
|
||||
.vp-doc h3 {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.vp-doc h1 {
|
||||
color: var(--vp-c-text-1);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dark .vp-doc h1 {
|
||||
background: var(--at-gradient-brand-text);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.vp-doc p,
|
||||
.vp-doc li {
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.vp-doc a {
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.VPNavBarMenuLink[target="_self"].vp-external-link-icon::after,
|
||||
.VPNavScreenMenuLink[target="_self"].vp-external-link-icon::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vp-doc table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
border: var(--at-glass-border);
|
||||
border-radius: var(--at-radius-xl);
|
||||
overflow: hidden;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.vp-doc th {
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--at-c-cyan);
|
||||
background: rgba(0, 240, 255, 0.05);
|
||||
}
|
||||
|
||||
.vp-doc :not(pre) > code {
|
||||
border: 1px solid rgba(0, 240, 255, 0.1);
|
||||
border-radius: var(--at-radius-xs);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.vp-doc div[class*="language-"] {
|
||||
border: 1px solid rgba(0, 240, 255, 0.12);
|
||||
border-radius: var(--at-radius-xl);
|
||||
box-shadow: var(--at-shadow-card);
|
||||
}
|
||||
|
||||
.vp-doc .custom-block {
|
||||
border-radius: var(--at-radius-xl);
|
||||
}
|
||||
|
||||
.vp-doc .custom-block.tip {
|
||||
border-color: rgba(57, 255, 20, 0.22);
|
||||
background: rgba(57, 255, 20, 0.07);
|
||||
}
|
||||
|
||||
.vp-doc .custom-block.warning {
|
||||
border-color: rgba(255, 215, 0, 0.26);
|
||||
background: rgba(255, 215, 0, 0.07);
|
||||
}
|
||||
|
||||
.medium-zoom-overlay {
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.medium-zoom-image--opened {
|
||||
z-index: 10001;
|
||||
border-radius: var(--at-radius-lg);
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
.dark::view-transition-new(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
::view-transition-new(root),
|
||||
.dark::view-transition-old(root) {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.VPHero.has-image {
|
||||
min-height: 520px;
|
||||
padding: 72px 20px 56px;
|
||||
}
|
||||
|
||||
.VPHero.has-image .main {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.VPFeature .box {
|
||||
grid-template-columns: 36px 1fr !important;
|
||||
}
|
||||
|
||||
.VPHero .text {
|
||||
font-size: 34px;
|
||||
}
|
||||
}
|
||||
28
landing/product-docs/.vitepress/theme/index.ts
Normal file
28
landing/product-docs/.vitepress/theme/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { Theme } from "vitepress";
|
||||
import DefaultTheme from "vitepress/theme";
|
||||
import CopyOrDownloadAsMarkdownButtons from "vitepress-plugin-llms/vitepress-components/CopyOrDownloadAsMarkdownButtons.vue";
|
||||
import DocsCardGrid from "./DocsCardGrid.vue";
|
||||
import DocsHeroVisual from "./DocsHeroVisual.vue";
|
||||
import InstallBlock from "./InstallBlock.vue";
|
||||
import Layout from "./DocsLayout.vue";
|
||||
import ZoomImage from "./ZoomImage.vue";
|
||||
import "../../../assets/styles/brand-tokens.css";
|
||||
import "./custom.css";
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
Layout,
|
||||
enhanceApp({ app }) {
|
||||
app.component("CopyOrDownloadAsMarkdownButtons", CopyOrDownloadAsMarkdownButtons);
|
||||
app.component("DocsCardGrid", DocsCardGrid);
|
||||
app.component("DocsHeroVisual", DocsHeroVisual);
|
||||
app.component("InstallBlock", InstallBlock);
|
||||
app.component("ZoomImage", ZoomImage);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("vite:preloadError", () => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
}
|
||||
} satisfies Theme;
|
||||
35
landing/product-docs/guide/agent-workflow.md
Normal file
35
landing/product-docs/guide/agent-workflow.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Agent Workflow
|
||||
|
||||
Agent Teams makes agent work visible as task state, messages, logs, and reviewable code changes.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
| Stage | What happens |
|
||||
| --- | --- |
|
||||
| Provisioning | The app starts the team and confirms runtime readiness |
|
||||
| Planning | The lead creates tasks and may assign teammates |
|
||||
| In progress | Agents work in parallel and update task state |
|
||||
| Review | Changes are reviewed by agents or by you |
|
||||
| Done | Accepted work stays linked to its task history |
|
||||
|
||||
## Kanban board
|
||||
|
||||
The board is the primary operating surface. It lets you scan work, spot blocked tasks, open task detail, inspect logs, and review changes without reading raw session files.
|
||||
|
||||
## Messages and comments
|
||||
|
||||
Use direct messages when you need to redirect an agent. Use task comments when the note belongs to a specific piece of work. Comments preserve context for later review.
|
||||
|
||||
## Task logs
|
||||
|
||||
Task-specific logs isolate runtime output, actions, and messages for one assignment. Use them when you need to answer:
|
||||
|
||||
- what did this agent run?
|
||||
- why did it change this file?
|
||||
- did it ask another teammate for help?
|
||||
- which task produced this diff?
|
||||
|
||||
## Live processes
|
||||
|
||||
The live process section shows URLs and running processes when agents start local servers or tools. Open URLs directly from the app to inspect results.
|
||||
|
||||
35
landing/product-docs/guide/code-review.md
Normal file
35
landing/product-docs/guide/code-review.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Code Review
|
||||
|
||||
Code review in Agent Teams is task-centered. You inspect what changed for a specific task instead of hunting through a large unstructured diff.
|
||||
|
||||
## Review surface
|
||||
|
||||
Use the review UI to:
|
||||
|
||||
- inspect changed files
|
||||
- accept or reject individual hunks
|
||||
- leave comments
|
||||
- connect the diff back to the task and agent logs
|
||||
|
||||
## Hunk-level decisions
|
||||
|
||||
Accept small correct changes and reject isolated mistakes without throwing away the whole task. This is useful when an agent mostly solved the task but overreached in one file.
|
||||
|
||||
## Agent review workflow
|
||||
|
||||
Teams can review each other's work before you make the final call. This catches obvious regressions and keeps the board honest, but you should still review risky areas yourself.
|
||||
|
||||
## What to check manually
|
||||
|
||||
Prioritize:
|
||||
|
||||
- provider auth and runtime detection
|
||||
- IPC, preload, and filesystem boundaries
|
||||
- Git and worktree behavior
|
||||
- parsing and task lifecycle logic
|
||||
- persistence and code review flows
|
||||
|
||||
## Verification
|
||||
|
||||
Prefer focused verification commands. Broad formatting or lint-fix commands should not be used unless the task explicitly intends broad formatting churn.
|
||||
|
||||
51
landing/product-docs/guide/create-team.md
Normal file
51
landing/product-docs/guide/create-team.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Create a Team
|
||||
|
||||
A team is a named group of agents with roles, a lead, a target project, and a coordination prompt.
|
||||
|
||||
## Recommended first team
|
||||
|
||||
Start with a small team:
|
||||
|
||||
| Role | Purpose |
|
||||
| --- | --- |
|
||||
| Lead | Splits work, creates tasks, coordinates teammates |
|
||||
| Builder | Implements scoped tasks |
|
||||
| Reviewer | Reviews output, catches regressions, asks for fixes |
|
||||
|
||||
This shape gives you enough coordination to see the product value without making the first launch noisy.
|
||||
|
||||
## Write a good team brief
|
||||
|
||||
The team brief should include:
|
||||
|
||||
- the outcome you want
|
||||
- the files or feature areas that matter
|
||||
- risk boundaries, such as "do not refactor unrelated modules"
|
||||
- review expectations
|
||||
- verification commands when you know them
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Build a focused improvement to the download flow. Keep changes inside the landing app unless a shared helper is clearly needed. Create tasks before implementation, review each task diff, and run landing lint/build checks.
|
||||
```
|
||||
|
||||
## Choose autonomy
|
||||
|
||||
Agent Teams supports different levels of control. Use more autonomy for routine changes and tighter review for risky areas like provider auth, IPC, persistence, Git workflows, and release tooling.
|
||||
|
||||
## Add context
|
||||
|
||||
Attach files, screenshots, or specific notes when they materially change the task. Agents can use task descriptions, comments, and attachments as durable context.
|
||||
|
||||
## Watch for task quality
|
||||
|
||||
Good teams create tasks that are:
|
||||
|
||||
- specific enough to review
|
||||
- small enough to finish
|
||||
- linked to visible output
|
||||
- backed by a verification path
|
||||
|
||||
If the lead creates vague tasks, send a direct message asking for smaller, testable tasks.
|
||||
|
||||
45
landing/product-docs/guide/installation.md
Normal file
45
landing/product-docs/guide/installation.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Installation
|
||||
|
||||
Agent Teams is distributed as a desktop app for macOS, Windows, and Linux.
|
||||
|
||||
## Download builds
|
||||
|
||||
Use the latest GitHub release when you want the packaged app:
|
||||
|
||||
- macOS Apple Silicon: `.dmg`
|
||||
- macOS Intel: `.dmg`
|
||||
- Windows: `.exe`
|
||||
- Linux: `.AppImage`, `.deb`, `.rpm`, or `.pacman`
|
||||
|
||||
::: warning Windows SmartScreen
|
||||
Unsigned or newly published open-source apps can trigger SmartScreen. If you trust the release source, choose **More info** and then **Run anyway**.
|
||||
:::
|
||||
|
||||
## Requirements
|
||||
|
||||
The packaged app is designed for zero-setup onboarding. It can guide runtime detection and provider authentication from the UI.
|
||||
|
||||
For source development, use:
|
||||
|
||||
| Tool | Version |
|
||||
| --- | --- |
|
||||
| Node.js | 20+ |
|
||||
| pnpm | 10+ |
|
||||
|
||||
## Run from source
|
||||
|
||||
<InstallBlock command="git clone https://github.com/777genius/claude_agent_teams_ui.git && cd claude_agent_teams_ui && pnpm install && pnpm dev" />
|
||||
|
||||
```bash
|
||||
git clone https://github.com/777genius/claude_agent_teams_ui.git
|
||||
cd claude_agent_teams_ui
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
If you want the freshest local version, use the repository branch that currently carries active development.
|
||||
|
||||
## Updating
|
||||
|
||||
Use the latest release for packaged builds. If you run from source, pull the branch you use and rerun install when dependencies change.
|
||||
|
||||
50
landing/product-docs/guide/quickstart.md
Normal file
50
landing/product-docs/guide/quickstart.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Quickstart
|
||||
|
||||
This guide gets you from a fresh install to a running team.
|
||||
|
||||
## 1. Install Agent Teams
|
||||
|
||||
Download the latest release for your platform from the landing page or GitHub releases.
|
||||
|
||||
::: tip
|
||||
The app is free and open source. The agent runtime you choose may still require provider access, such as Claude, Codex, OpenCode, or API-key based providers.
|
||||
:::
|
||||
|
||||
## 2. Open or create a project
|
||||
|
||||
Launch the app and select the project directory you want agents to work in. Agent Teams reads local project files and runtime/session state so the UI can show tasks, logs, diffs, and teammate activity.
|
||||
|
||||
## 3. Choose a runtime path
|
||||
|
||||
Use the setup flow to detect available runtimes. A common first setup is:
|
||||
|
||||
| Runtime | Good for |
|
||||
| --- | --- |
|
||||
| Claude | Claude Code users and existing Anthropic access |
|
||||
| Codex | Codex-native workflows and OpenAI access |
|
||||
| OpenCode | Multimodel teams and many provider backends |
|
||||
|
||||
## 4. Create your first team
|
||||
|
||||
Create a team with a lead and one or more specialists. Keep the first team small: one lead, one implementation agent, and one review-oriented agent is enough to validate the workflow.
|
||||
|
||||
## 5. Give the lead a concrete goal
|
||||
|
||||
Write the goal like you would brief an engineering lead:
|
||||
|
||||
```text
|
||||
Improve the onboarding flow. Split the work into tasks, keep changes small, and ask for review before broad refactors.
|
||||
```
|
||||
|
||||
The lead should create tasks, assign work, and coordinate teammates. You can watch progress on the kanban board and intervene with comments or direct messages.
|
||||
|
||||
## 6. Review results
|
||||
|
||||
Open completed or review-ready tasks, inspect the diff, and accept, reject, or comment on individual changes. Use task logs when you need to understand why an agent made a choice.
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Create a team](/guide/create-team)
|
||||
- [Runtime setup](/guide/runtime-setup)
|
||||
- [Code review](/guide/code-review)
|
||||
|
||||
33
landing/product-docs/guide/runtime-setup.md
Normal file
33
landing/product-docs/guide/runtime-setup.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Runtime Setup
|
||||
|
||||
Agent Teams is a coordination layer. The actual model work runs through supported local runtimes and providers.
|
||||
|
||||
## Supported paths
|
||||
|
||||
| Path | Use when |
|
||||
| --- | --- |
|
||||
| Claude | You already use Claude Code or Anthropic-backed workflows |
|
||||
| Codex | You want Codex-native runtime integration |
|
||||
| OpenCode | You want multimodel routing and broad provider coverage |
|
||||
|
||||
The app detects supported runtimes and guides setup from the UI when possible.
|
||||
|
||||
## Provider access
|
||||
|
||||
Agent Teams has no paid tier of its own. You bring the provider access you already have: subscriptions, local runtime auth, or API keys depending on the path you choose.
|
||||
|
||||
## Multimodel mode
|
||||
|
||||
Multimodel mode can route work through many provider backends via OpenCode-compatible configuration. Use it when you need provider flexibility or want teammates to use different model lanes.
|
||||
|
||||
## Operational advice
|
||||
|
||||
- Keep the first runtime setup simple.
|
||||
- Confirm one team can launch before adding many providers.
|
||||
- Treat auth, provider model names, and runtime PATH issues as setup problems, not team-prompt problems.
|
||||
- If launch hangs, check the troubleshooting page before changing code.
|
||||
|
||||
## When to switch runtime paths
|
||||
|
||||
Switch when the current path is blocked by model availability, rate limits, provider capabilities, or team role needs. Keep the same project and team workflow, but validate one small task after switching.
|
||||
|
||||
40
landing/product-docs/guide/troubleshooting.md
Normal file
40
landing/product-docs/guide/troubleshooting.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Troubleshooting
|
||||
|
||||
Most team issues fall into one of four buckets: runtime setup, launch confirmation, task parsing, or provider limits.
|
||||
|
||||
## Team does not launch
|
||||
|
||||
Check:
|
||||
|
||||
- the selected runtime is installed or authenticated
|
||||
- the runtime is available in the environment PATH
|
||||
- the provider has access to the requested model
|
||||
- the project path exists and is readable
|
||||
|
||||
If OpenCode shows `registered` but bootstrap is unconfirmed, inspect launch logs before changing team prompts.
|
||||
|
||||
## Agent replies are missing
|
||||
|
||||
Open task logs and teammate messages. Missing replies often come from runtime delivery, parsing, or task filtering issues. Do not assume the model ignored the message until logs confirm it.
|
||||
|
||||
## Tasks are not linked to changes
|
||||
|
||||
Use task-specific logs and code review links. If a diff appears detached, check whether the task id or task reference was included in the agent output.
|
||||
|
||||
## Rate limits
|
||||
|
||||
If a provider reports a known reset time, Agent Teams can nudge the lead to continue after cooldown. If reset time is unknown, wait or switch provider/runtime path.
|
||||
|
||||
## When to collect evidence
|
||||
|
||||
Collect:
|
||||
|
||||
- task id
|
||||
- team name
|
||||
- runtime path
|
||||
- launch log excerpt
|
||||
- provider/model
|
||||
- exact time window
|
||||
|
||||
This is enough to debug most launch and task lifecycle issues.
|
||||
|
||||
67
landing/product-docs/index.md
Normal file
67
landing/product-docs/index.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
---
|
||||
layout: home
|
||||
hero:
|
||||
name: Agent Teams Docs
|
||||
text: Run AI agent teams from a local desktop app
|
||||
tagline: Create teams, watch work move across a kanban board, review code changes, and coordinate Claude, Codex, OpenCode, and multimodel workflows without giving up local control.
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Quickstart
|
||||
link: /guide/quickstart
|
||||
- theme: alt
|
||||
text: Install
|
||||
link: /guide/installation
|
||||
- theme: alt
|
||||
text: Concepts
|
||||
link: /reference/concepts
|
||||
features:
|
||||
- icon: "01"
|
||||
title: Team-first workflow
|
||||
details: Define roles, launch a lead, and let agents split, claim, and coordinate tasks.
|
||||
link: /guide/create-team
|
||||
linkText: Create a team
|
||||
- icon: "02"
|
||||
title: Live kanban board
|
||||
details: Watch tasks move through todo, progress, review, blocked, and done as agents work.
|
||||
link: /guide/agent-workflow
|
||||
linkText: Understand workflow
|
||||
- icon: "03"
|
||||
title: Built-in code review
|
||||
details: Inspect task-scoped diffs, accept or reject hunks, and comment where agents need direction.
|
||||
link: /guide/code-review
|
||||
linkText: Review changes
|
||||
- icon: "04"
|
||||
title: Runtime-aware setup
|
||||
details: Use Claude, Codex, OpenCode, or multimodel providers through the access you already have.
|
||||
link: /guide/runtime-setup
|
||||
linkText: Configure runtimes
|
||||
- icon: "05"
|
||||
title: Local-first control
|
||||
details: The desktop app reads local project and runtime state. Your code stays on your machine unless a selected provider receives prompt context.
|
||||
link: /reference/privacy-local-data
|
||||
linkText: Privacy model
|
||||
- icon: "06"
|
||||
title: Debuggable teams
|
||||
details: Trace task logs, runtime output, teammate messages, and live processes when a launch or task gets stuck.
|
||||
link: /guide/troubleshooting
|
||||
linkText: Troubleshoot
|
||||
---
|
||||
|
||||
<InstallBlock />
|
||||
|
||||
## Start here
|
||||
|
||||
Agent Teams is a free desktop app for orchestrating AI agent teams. You are not just sending isolated prompts to one agent: you create a team, assign roles, and watch agents coordinate work through a task board.
|
||||
|
||||
<DocsCardGrid />
|
||||
|
||||
## Reference
|
||||
|
||||
Use the reference pages when you need exact terminology, provider behavior, or privacy boundaries.
|
||||
|
||||
<DocsCardGrid type="reference" />
|
||||
|
||||
## Product preview
|
||||
|
||||
<ZoomImage src="/screenshots/1.jpg" alt="Agent Teams kanban board" caption="Task status, teammate activity, and review workflow stay visible in one workspace." />
|
||||
|
||||
32
landing/product-docs/reference/concepts.md
Normal file
32
landing/product-docs/reference/concepts.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Concepts
|
||||
|
||||
This page defines the core terms used across Agent Teams.
|
||||
|
||||
## Team
|
||||
|
||||
A team is a group of agents configured for a project. A team usually has a lead and one or more teammates with specialized roles.
|
||||
|
||||
## Lead
|
||||
|
||||
The lead coordinates work. It should break goals into tasks, assign teammates, track blockers, and ask for review when needed.
|
||||
|
||||
## Task
|
||||
|
||||
A task is the durable unit of work. It has status, description, comments, logs, attachments, and reviewable changes.
|
||||
|
||||
## Solo mode
|
||||
|
||||
Solo mode runs a one-member team. It is useful for quick work, lower token usage, and validating a prompt before expanding to a full team.
|
||||
|
||||
## Cross-team communication
|
||||
|
||||
Agents can message within and across teams. Use this when separate teams own related work and need to coordinate.
|
||||
|
||||
## Autonomy level
|
||||
|
||||
Autonomy controls how much agents can do before asking. Higher autonomy is faster; lower autonomy is safer for sensitive code paths.
|
||||
|
||||
## Runtime
|
||||
|
||||
A runtime is the local execution path that connects Agent Teams to a model/provider workflow, such as Claude, Codex, or OpenCode.
|
||||
|
||||
29
landing/product-docs/reference/faq.md
Normal file
29
landing/product-docs/reference/faq.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# FAQ
|
||||
|
||||
## Is Agent Teams free?
|
||||
|
||||
Yes. The app is free and open source. Provider or runtime access may still cost money depending on what you use.
|
||||
|
||||
## Do I need to install Claude or Codex first?
|
||||
|
||||
Not always. The app guides runtime detection and setup from the UI. Some paths still require external runtime auth.
|
||||
|
||||
## Does it upload my code to Agent Teams servers?
|
||||
|
||||
No. Agent Teams is not a cloud code-sync service. Provider-backed model calls may receive prompt context depending on your selected runtime.
|
||||
|
||||
## Can agents talk to each other?
|
||||
|
||||
Yes. Agents can message teammates, comment on tasks, and coordinate across teams.
|
||||
|
||||
## Can I review code before accepting it?
|
||||
|
||||
Yes. The review flow is built around task-scoped diffs and hunk-level decisions.
|
||||
|
||||
## What is solo mode?
|
||||
|
||||
Solo mode is a one-agent team. It is useful for smaller tasks and lower coordination overhead.
|
||||
|
||||
## What should I do when a launch hangs?
|
||||
|
||||
Open troubleshooting, collect runtime logs, and verify provider auth before changing prompts.
|
||||
30
landing/product-docs/reference/privacy-local-data.md
Normal file
30
landing/product-docs/reference/privacy-local-data.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Privacy and Local Data
|
||||
|
||||
Agent Teams is local-first, but the selected provider path still matters.
|
||||
|
||||
## What stays local
|
||||
|
||||
The desktop app runs on your machine and reads local project/runtime data to power the UI:
|
||||
|
||||
- project files
|
||||
- task metadata
|
||||
- runtime/session logs
|
||||
- review state
|
||||
- local app settings
|
||||
|
||||
## What can leave your machine
|
||||
|
||||
When an agent asks a provider-backed model to work, prompt context and tool results may be sent through that provider/runtime path. This depends on the runtime and provider you choose.
|
||||
|
||||
## Practical guidance
|
||||
|
||||
- Do not attach secrets to tasks.
|
||||
- Review provider policies for sensitive projects.
|
||||
- Use lower autonomy for risky repositories.
|
||||
- Keep task scope narrow when working with private code.
|
||||
- Prefer local evidence and logs when debugging.
|
||||
|
||||
## Open source model
|
||||
|
||||
The app itself is open source and free. You can inspect how local orchestration, task tracking, and review flows work in the repository.
|
||||
|
||||
40
landing/product-docs/reference/providers-runtimes.md
Normal file
40
landing/product-docs/reference/providers-runtimes.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Providers and Runtimes
|
||||
|
||||
Agent Teams separates orchestration from model access.
|
||||
|
||||
## What the app provides
|
||||
|
||||
Agent Teams provides:
|
||||
|
||||
- team and task orchestration
|
||||
- kanban board UI
|
||||
- teammate messaging
|
||||
- task logs
|
||||
- review UI
|
||||
- local project integration
|
||||
|
||||
## What the runtime provides
|
||||
|
||||
The runtime provides:
|
||||
|
||||
- model execution
|
||||
- provider authentication
|
||||
- tool execution behavior
|
||||
- model-specific rate limits and capabilities
|
||||
|
||||
## Common choices
|
||||
|
||||
| Runtime | Notes |
|
||||
| --- | --- |
|
||||
| Claude | Good for Claude Code users and Anthropic access |
|
||||
| Codex | Good for Codex-native workflows and OpenAI access |
|
||||
| OpenCode | Good for multimodel routing and broad provider coverage |
|
||||
|
||||
## Provider costs
|
||||
|
||||
Agent Teams is free. Provider usage is governed by the runtime/provider you select.
|
||||
|
||||
## Capability checks
|
||||
|
||||
During setup, the app may perform access and capability checks. This helps detect missing runtime auth before a team launch fails halfway through provisioning.
|
||||
|
||||
35
landing/product-docs/ru/guide/agent-workflow.md
Normal file
35
landing/product-docs/ru/guide/agent-workflow.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Работа агентов
|
||||
|
||||
Agent Teams делает работу агентов видимой через task state, messages, logs и reviewable code changes.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
| Этап | Что происходит |
|
||||
| --- | --- |
|
||||
| Provisioning | Приложение запускает команду и проверяет готовность runtime |
|
||||
| Planning | Lead создаёт задачи и назначает teammates |
|
||||
| In progress | Агенты работают параллельно и обновляют статус задач |
|
||||
| Review | Изменения проверяют агенты или вы |
|
||||
| Done | Принятая работа остаётся связанной с историей задачи |
|
||||
|
||||
## Канбан-доска
|
||||
|
||||
Доска - основной рабочий экран. Через неё удобно смотреть работу, находить blocked tasks, открывать task detail, читать logs и ревьюить changes без ручного чтения session files.
|
||||
|
||||
## Messages и comments
|
||||
|
||||
Direct messages подходят для перенаправления агента. Task comments лучше использовать, когда заметка относится к конкретной работе. Комментарии сохраняют контекст для review.
|
||||
|
||||
## Task logs
|
||||
|
||||
Task-specific logs изолируют runtime output, actions и messages по одному assignment. Они помогают понять:
|
||||
|
||||
- что агент запускал?
|
||||
- почему он изменил этот файл?
|
||||
- просил ли он помощи у teammate?
|
||||
- какая задача породила diff?
|
||||
|
||||
## Live processes
|
||||
|
||||
Live process section показывает URLs и running processes, когда агенты поднимают локальные servers или tools. Открывайте URL прямо из приложения.
|
||||
|
||||
35
landing/product-docs/ru/guide/code-review.md
Normal file
35
landing/product-docs/ru/guide/code-review.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Код-ревью
|
||||
|
||||
Code review в Agent Teams строится вокруг задачи. Вы смотрите изменения конкретной задачи, а не огромный неструктурированный diff.
|
||||
|
||||
## Review surface
|
||||
|
||||
Через review UI можно:
|
||||
|
||||
- смотреть changed files
|
||||
- принимать или отклонять отдельные hunks
|
||||
- оставлять comments
|
||||
- связывать diff с task logs и агентом
|
||||
|
||||
## Hunk-level decisions
|
||||
|
||||
Принимайте маленькие правильные изменения и отклоняйте отдельные ошибки без удаления всей работы. Это полезно, когда агент в целом решил задачу, но переборщил в одном файле.
|
||||
|
||||
## Agent review workflow
|
||||
|
||||
Команды могут ревьюить работу друг друга до вашего финального решения. Это ловит очевидные регрессии, но risky areas всё равно стоит проверять вручную.
|
||||
|
||||
## Что проверять вручную
|
||||
|
||||
Приоритет:
|
||||
|
||||
- provider auth и runtime detection
|
||||
- IPC, preload и filesystem boundaries
|
||||
- Git и worktree behavior
|
||||
- parsing и task lifecycle logic
|
||||
- persistence и code review flows
|
||||
|
||||
## Verification
|
||||
|
||||
Лучше запускать focused verification commands. Broad formatting или lint-fix команды не стоит использовать, если задача явно не про форматирование.
|
||||
|
||||
51
landing/product-docs/ru/guide/create-team.md
Normal file
51
landing/product-docs/ru/guide/create-team.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Создание команды
|
||||
|
||||
Команда - это группа агентов с ролями, lead-агентом, целевым проектом и coordination prompt.
|
||||
|
||||
## Первая команда
|
||||
|
||||
Начните с малого:
|
||||
|
||||
| Роль | Задача |
|
||||
| --- | --- |
|
||||
| Lead | Делит работу, создаёт задачи, координирует teammates |
|
||||
| Builder | Реализует scoped tasks |
|
||||
| Reviewer | Проверяет результат, ловит регрессии, просит fixes |
|
||||
|
||||
Такая форма даёт достаточно координации, но не создаёт лишний шум на первом запуске.
|
||||
|
||||
## Хороший team brief
|
||||
|
||||
В brief стоит указать:
|
||||
|
||||
- нужный outcome
|
||||
- важные files или feature areas
|
||||
- границы риска, например "не refactor unrelated modules"
|
||||
- ожидания по review
|
||||
- verification commands, если они известны
|
||||
|
||||
Пример:
|
||||
|
||||
```text
|
||||
Улучши download flow. Держи изменения внутри landing app, если shared helper явно не нужен. Создай задачи до реализации, проверь diff каждой задачи и запусти landing lint/build checks.
|
||||
```
|
||||
|
||||
## Уровень автономности
|
||||
|
||||
Agent Teams поддерживает разные уровни контроля. Больше автономности подходит для рутинных изменений, меньше - для provider auth, IPC, persistence, Git workflows и release tooling.
|
||||
|
||||
## Контекст
|
||||
|
||||
Прикладывайте файлы, screenshots или заметки, если они реально меняют задачу. Task descriptions, comments и attachments становятся устойчивым контекстом.
|
||||
|
||||
## Качество задач
|
||||
|
||||
Хорошие задачи:
|
||||
|
||||
- конкретны для review
|
||||
- достаточно малы для завершения
|
||||
- связаны с видимым результатом
|
||||
- имеют verification path
|
||||
|
||||
Если lead создаёт размытые задачи, напишите ему direct message и попросите сделать задачи меньше и проверяемее.
|
||||
|
||||
45
landing/product-docs/ru/guide/installation.md
Normal file
45
landing/product-docs/ru/guide/installation.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Установка
|
||||
|
||||
Agent Teams распространяется как desktop-приложение для macOS, Windows и Linux.
|
||||
|
||||
## Готовые сборки
|
||||
|
||||
Берите последний GitHub release:
|
||||
|
||||
- macOS Apple Silicon: `.dmg`
|
||||
- macOS Intel: `.dmg`
|
||||
- Windows: `.exe`
|
||||
- Linux: `.AppImage`, `.deb`, `.rpm` или `.pacman`
|
||||
|
||||
::: warning Windows SmartScreen
|
||||
Новые open-source приложения могут вызывать SmartScreen. Если вы доверяете источнику релиза, выберите **More info**, затем **Run anyway**.
|
||||
:::
|
||||
|
||||
## Требования
|
||||
|
||||
Пакетная сборка рассчитана на zero-setup onboarding. Приложение само помогает с runtime detection и provider authentication.
|
||||
|
||||
Для запуска из исходников:
|
||||
|
||||
| Инструмент | Версия |
|
||||
| --- | --- |
|
||||
| Node.js | 20+ |
|
||||
| pnpm | 10+ |
|
||||
|
||||
## Запуск из исходников
|
||||
|
||||
<InstallBlock command="git clone https://github.com/777genius/claude_agent_teams_ui.git && cd claude_agent_teams_ui && pnpm install && pnpm dev" label="Скопировать" copied-label="Скопировано" />
|
||||
|
||||
```bash
|
||||
git clone https://github.com/777genius/claude_agent_teams_ui.git
|
||||
cd claude_agent_teams_ui
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Если нужна самая свежая локальная версия, используйте ветку репозитория, где сейчас идёт активная разработка.
|
||||
|
||||
## Обновления
|
||||
|
||||
Для packaged builds берите последний release. Для запуска из исходников подтяните нужную ветку и повторите install, если поменялись зависимости.
|
||||
|
||||
50
landing/product-docs/ru/guide/quickstart.md
Normal file
50
landing/product-docs/ru/guide/quickstart.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Быстрый старт
|
||||
|
||||
Этот гайд проводит от свежей установки до первой запущенной команды.
|
||||
|
||||
## 1. Установите Agent Teams
|
||||
|
||||
Скачайте последний релиз под вашу платформу на лендинге или в GitHub releases.
|
||||
|
||||
::: tip
|
||||
Приложение бесплатное и с открытым кодом. Выбранный runtime может требовать доступ к провайдеру, например Claude, Codex, OpenCode или API-key based providers.
|
||||
:::
|
||||
|
||||
## 2. Откройте проект
|
||||
|
||||
Запустите приложение и выберите директорию проекта, где агенты будут работать. Agent Teams читает локальные project files и runtime/session state, чтобы показывать задачи, логи, diffs и активность команды.
|
||||
|
||||
## 3. Выберите runtime path
|
||||
|
||||
Стандартные варианты:
|
||||
|
||||
| Runtime | Когда подходит |
|
||||
| --- | --- |
|
||||
| Claude | Если вы уже используете Claude Code или Anthropic access |
|
||||
| Codex | Для Codex-native workflows и OpenAI access |
|
||||
| OpenCode | Для multimodel teams и большого числа provider backends |
|
||||
|
||||
## 4. Создайте первую команду
|
||||
|
||||
Начните с маленькой команды: lead, implementation agent и review-oriented agent. Этого достаточно, чтобы проверить workflow без лишнего шума.
|
||||
|
||||
## 5. Дайте lead-агенту конкретную цель
|
||||
|
||||
Пишите задачу как инженерному лиду:
|
||||
|
||||
```text
|
||||
Улучши onboarding flow. Разбей работу на задачи, держи изменения маленькими и проси review перед широкими refactor.
|
||||
```
|
||||
|
||||
Lead должен создать задачи, назначить работу и координировать teammates. Вы следите за прогрессом на канбан-доске и вмешиваетесь через комментарии или direct messages.
|
||||
|
||||
## 6. Проверьте результат
|
||||
|
||||
Откройте задачи в review/done, посмотрите diff, примите или отклоните изменения. Если нужно понять мотивацию агента, откройте task logs.
|
||||
|
||||
## Дальше
|
||||
|
||||
- [Создание команды](/ru/guide/create-team)
|
||||
- [Настройка рантайма](/ru/guide/runtime-setup)
|
||||
- [Код-ревью](/ru/guide/code-review)
|
||||
|
||||
33
landing/product-docs/ru/guide/runtime-setup.md
Normal file
33
landing/product-docs/ru/guide/runtime-setup.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Настройка рантайма
|
||||
|
||||
Agent Teams - coordination layer. Model work выполняется через локальные runtimes и providers.
|
||||
|
||||
## Поддерживаемые пути
|
||||
|
||||
| Путь | Когда использовать |
|
||||
| --- | --- |
|
||||
| Claude | Если вы уже используете Claude Code или Anthropic access |
|
||||
| Codex | Для Codex-native workflows и OpenAI access |
|
||||
| OpenCode | Для multimodel routing и широкой provider coverage |
|
||||
|
||||
Приложение по возможности определяет доступные runtimes и ведёт настройку через UI.
|
||||
|
||||
## Provider access
|
||||
|
||||
У Agent Teams нет своего платного тарифа. Вы используете доступ к провайдеру, который у вас уже есть: subscription, local runtime auth или API keys в зависимости от выбранного пути.
|
||||
|
||||
## Multimodel mode
|
||||
|
||||
Multimodel mode может направлять работу через разные provider backends в OpenCode-compatible конфигурации. Используйте его, когда нужна гибкость провайдеров или разные model lanes для teammates.
|
||||
|
||||
## Практические советы
|
||||
|
||||
- Первый runtime setup держите простым.
|
||||
- Подтвердите запуск одной команды до добавления многих providers.
|
||||
- Auth, model names и PATH issues считайте setup-проблемами, а не проблемами team prompt.
|
||||
- Если запуск завис, сначала откройте диагностику.
|
||||
|
||||
## Когда менять runtime path
|
||||
|
||||
Меняйте путь, когда текущий упирается в availability модели, rate limits, provider capabilities или роли команды. После смены проверьте одну маленькую задачу.
|
||||
|
||||
40
landing/product-docs/ru/guide/troubleshooting.md
Normal file
40
landing/product-docs/ru/guide/troubleshooting.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Диагностика
|
||||
|
||||
Большинство проблем команды попадает в четыре группы: runtime setup, launch confirmation, task parsing или provider limits.
|
||||
|
||||
## Команда не запускается
|
||||
|
||||
Проверьте:
|
||||
|
||||
- выбранный runtime установлен или авторизован
|
||||
- runtime доступен в environment PATH
|
||||
- у провайдера есть доступ к нужной модели
|
||||
- project path существует и читается
|
||||
|
||||
Если OpenCode показывает `registered`, но bootstrap не подтверждён, сначала смотрите launch logs.
|
||||
|
||||
## Не видны ответы агента
|
||||
|
||||
Откройте task logs и teammate messages. Пропавшие replies часто связаны с runtime delivery, parsing или task filtering. Не считайте, что модель проигнорировала сообщение, пока это не подтверждено логами.
|
||||
|
||||
## Changes не связаны с tasks
|
||||
|
||||
Используйте task-specific logs и code review links. Если diff выглядит detached, проверьте, был ли task id или task reference в output агента.
|
||||
|
||||
## Rate limits
|
||||
|
||||
Если провайдер сообщает reset time, Agent Teams может подтолкнуть lead продолжить после cooldown. Если reset time неизвестен, подождите или смените provider/runtime path.
|
||||
|
||||
## Какие данные собрать
|
||||
|
||||
Соберите:
|
||||
|
||||
- task id
|
||||
- team name
|
||||
- runtime path
|
||||
- launch log excerpt
|
||||
- provider/model
|
||||
- точный time window
|
||||
|
||||
Этого обычно хватает для диагностики launch и task lifecycle issues.
|
||||
|
||||
67
landing/product-docs/ru/index.md
Normal file
67
landing/product-docs/ru/index.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
---
|
||||
layout: home
|
||||
hero:
|
||||
name: Документация Agent Teams
|
||||
text: Запускайте команды AI-агентов из локального desktop-приложения
|
||||
tagline: Создавайте команды, наблюдайте за канбан-доской, ревьюйте изменения и координируйте Claude, Codex, OpenCode и multimodel workflows без потери локального контроля.
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Быстрый старт
|
||||
link: /ru/guide/quickstart
|
||||
- theme: alt
|
||||
text: Установка
|
||||
link: /ru/guide/installation
|
||||
- theme: alt
|
||||
text: Концепции
|
||||
link: /ru/reference/concepts
|
||||
features:
|
||||
- icon: "01"
|
||||
title: Командный workflow
|
||||
details: Опишите роли, запустите lead-агента и дайте команде разбивать, брать и координировать задачи.
|
||||
link: /ru/guide/create-team
|
||||
linkText: Создать команду
|
||||
- icon: "02"
|
||||
title: Живая канбан-доска
|
||||
details: Видно, как задачи проходят todo, progress, review, blocked и done во время работы агентов.
|
||||
link: /ru/guide/agent-workflow
|
||||
linkText: Разобрать workflow
|
||||
- icon: "03"
|
||||
title: Встроенное код-ревью
|
||||
details: Проверяйте diff по задаче, принимайте или отклоняйте hunks и оставляйте комментарии.
|
||||
link: /ru/guide/code-review
|
||||
linkText: Ревью изменений
|
||||
- icon: "04"
|
||||
title: Runtime-aware setup
|
||||
details: Используйте Claude, Codex, OpenCode или multimodel-провайдеры через доступ, который у вас уже есть.
|
||||
link: /ru/guide/runtime-setup
|
||||
linkText: Настроить рантаймы
|
||||
- icon: "05"
|
||||
title: Local-first контроль
|
||||
details: Приложение читает локальный проект и runtime-состояние. Код остаётся у вас, если выбранный провайдер не получает контекст для model call.
|
||||
link: /ru/reference/privacy-local-data
|
||||
linkText: Модель приватности
|
||||
- icon: "06"
|
||||
title: Диагностируемые команды
|
||||
details: Отслеживайте task logs, runtime output, сообщения агентов и live processes, когда запуск или задача застряли.
|
||||
link: /ru/guide/troubleshooting
|
||||
linkText: Диагностика
|
||||
---
|
||||
|
||||
<InstallBlock label="Скопировать" copied-label="Скопировано" />
|
||||
|
||||
## С чего начать
|
||||
|
||||
Agent Teams - бесплатное desktop-приложение для оркестрации команд AI-агентов. Это не просто одиночные промпты одному агенту: вы создаёте команду, задаёте роли и смотрите, как агенты координируют работу через task board.
|
||||
|
||||
<DocsCardGrid />
|
||||
|
||||
## Справочник
|
||||
|
||||
Используйте справочник, когда нужны точные термины, поведение провайдеров или границы приватности.
|
||||
|
||||
<DocsCardGrid type="reference" />
|
||||
|
||||
## Превью продукта
|
||||
|
||||
<ZoomImage src="/screenshots/1.jpg" alt="Канбан-доска Agent Teams" caption="Статусы задач, активность агентов и review workflow видны в одном рабочем пространстве." />
|
||||
|
||||
32
landing/product-docs/ru/reference/concepts.md
Normal file
32
landing/product-docs/ru/reference/concepts.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Концепции
|
||||
|
||||
Основные термины Agent Teams.
|
||||
|
||||
## Team
|
||||
|
||||
Team - группа агентов, настроенная для проекта. Обычно есть lead и один или несколько teammates со специализированными ролями.
|
||||
|
||||
## Lead
|
||||
|
||||
Lead координирует работу: разбивает цель на tasks, назначает teammates, отслеживает blockers и просит review.
|
||||
|
||||
## Task
|
||||
|
||||
Task - устойчивая единица работы. У неё есть status, description, comments, logs, attachments и reviewable changes.
|
||||
|
||||
## Solo mode
|
||||
|
||||
Solo mode запускает команду из одного агента. Полезно для маленьких задач, меньшего token usage и проверки prompt перед расширением до команды.
|
||||
|
||||
## Cross-team communication
|
||||
|
||||
Агенты могут писать внутри и между командами. Это нужно, когда разные teams владеют связанными частями работы.
|
||||
|
||||
## Autonomy level
|
||||
|
||||
Autonomy определяет, сколько агент может делать до запроса подтверждения. Больше автономности быстрее, меньше - безопаснее для sensitive code paths.
|
||||
|
||||
## Runtime
|
||||
|
||||
Runtime - локальный execution path, который соединяет Agent Teams с model/provider workflow, например Claude, Codex или OpenCode.
|
||||
|
||||
29
landing/product-docs/ru/reference/faq.md
Normal file
29
landing/product-docs/ru/reference/faq.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# FAQ
|
||||
|
||||
## Agent Teams бесплатный?
|
||||
|
||||
Да. Приложение бесплатное и open source. Provider или runtime access может стоить денег в зависимости от выбранного пути.
|
||||
|
||||
## Нужно ли заранее ставить Claude или Codex?
|
||||
|
||||
Не всегда. Приложение ведёт runtime detection и setup через UI. Некоторые пути всё равно требуют внешнюю авторизацию runtime.
|
||||
|
||||
## Приложение загружает мой код на серверы Agent Teams?
|
||||
|
||||
Нет. Agent Teams не является cloud code-sync сервисом. Но provider-backed model calls могут получать prompt context в зависимости от выбранного runtime.
|
||||
|
||||
## Агенты могут общаться друг с другом?
|
||||
|
||||
Да. Агенты могут писать teammates, комментировать tasks и координироваться между teams.
|
||||
|
||||
## Можно ревьюить код перед принятием?
|
||||
|
||||
Да. Review flow построен вокруг task-scoped diffs и hunk-level decisions.
|
||||
|
||||
## Что такое solo mode?
|
||||
|
||||
Solo mode - команда из одного агента. Подходит для небольших задач и меньшего coordination overhead.
|
||||
|
||||
## Что делать, если launch завис?
|
||||
|
||||
Откройте диагностику, соберите runtime logs и проверьте provider auth до изменения prompts.
|
||||
30
landing/product-docs/ru/reference/privacy-local-data.md
Normal file
30
landing/product-docs/ru/reference/privacy-local-data.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Приватность и локальные данные
|
||||
|
||||
Agent Teams local-first, но выбранный provider path всё равно важен.
|
||||
|
||||
## Что остаётся локально
|
||||
|
||||
Desktop app работает на вашей машине и читает локальные project/runtime data для UI:
|
||||
|
||||
- project files
|
||||
- task metadata
|
||||
- runtime/session logs
|
||||
- review state
|
||||
- local app settings
|
||||
|
||||
## Что может выйти с машины
|
||||
|
||||
Когда агент обращается к provider-backed model, prompt context и tool results могут отправляться через выбранный provider/runtime path. Это зависит от runtime и provider.
|
||||
|
||||
## Практические правила
|
||||
|
||||
- Не прикладывайте secrets к tasks.
|
||||
- Проверяйте provider policies для sensitive projects.
|
||||
- Используйте меньшую autonomy для risky repositories.
|
||||
- Держите task scope узким при работе с private code.
|
||||
- Для диагностики опирайтесь на local evidence и logs.
|
||||
|
||||
## Open source
|
||||
|
||||
Само приложение open source и бесплатное. В репозитории можно посмотреть, как устроены local orchestration, task tracking и review flows.
|
||||
|
||||
40
landing/product-docs/ru/reference/providers-runtimes.md
Normal file
40
landing/product-docs/ru/reference/providers-runtimes.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Провайдеры и рантаймы
|
||||
|
||||
Agent Teams отделяет orchestration от model access.
|
||||
|
||||
## Что даёт приложение
|
||||
|
||||
Agent Teams даёт:
|
||||
|
||||
- orchestration команд и задач
|
||||
- kanban board UI
|
||||
- teammate messaging
|
||||
- task logs
|
||||
- review UI
|
||||
- local project integration
|
||||
|
||||
## Что даёт runtime
|
||||
|
||||
Runtime отвечает за:
|
||||
|
||||
- model execution
|
||||
- provider authentication
|
||||
- tool execution behavior
|
||||
- rate limits и capabilities конкретной модели
|
||||
|
||||
## Частые варианты
|
||||
|
||||
| Runtime | Заметки |
|
||||
| --- | --- |
|
||||
| Claude | Хорошо для Claude Code users и Anthropic access |
|
||||
| Codex | Хорошо для Codex-native workflows и OpenAI access |
|
||||
| OpenCode | Хорошо для multimodel routing и широкой provider coverage |
|
||||
|
||||
## Стоимость providers
|
||||
|
||||
Agent Teams бесплатен. Стоимость provider usage зависит от выбранного runtime/provider.
|
||||
|
||||
## Capability checks
|
||||
|
||||
Во время setup приложение может выполнять access и capability checks. Это помогает найти отсутствующую авторизацию до того, как team launch застрянет в provisioning.
|
||||
|
||||
|
|
@ -7,5 +7,6 @@ export default defineEventHandler((event) => {
|
|||
return `User-agent: *
|
||||
Allow: /
|
||||
Sitemap: ${siteUrl}/sitemap.xml
|
||||
Sitemap: ${siteUrl}/docs/sitemap.xml
|
||||
`;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
server.addTool({
|
||||
name: 'message_send',
|
||||
description:
|
||||
'Send a visible team/user message into team inbox. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. from is required and must be your configured teammate name; user is reserved for app-owned writes. When replying to an app-delivered OpenCode runtime message, include source="runtime_delivery" and relayOfMessageId with the inbound app messageId. Do not invent placeholder task refs. If the message is not about a real board task, omit # task labels; never use #00000000.',
|
||||
'Send a visible team/user message into team inbox. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. from is required and must be your configured teammate name; user is reserved for app-owned writes. When replying to an app-delivered OpenCode runtime message, include source="runtime_delivery" and relayOfMessageId with the inbound app messageId. After a successful app-delivered runtime reply, stop and do not send the same answer again. Do not invent placeholder task refs. If the message is not about a real board task, omit # task labels; never use #00000000.',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
to: z.string().min(1),
|
||||
|
|
@ -58,21 +58,26 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskRefs,
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).messages.sendMessage({
|
||||
to,
|
||||
text,
|
||||
...(from ? { from } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
...(source ? { source } : {}),
|
||||
...(relayOfMessageId ? { relayOfMessageId } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
...(attachments?.length ? { attachments } : {}),
|
||||
...(taskRefs?.length ? { taskRefs } : {}),
|
||||
})
|
||||
)
|
||||
);
|
||||
const result = getController(teamName, claudeDir).messages.sendMessage({
|
||||
to,
|
||||
text,
|
||||
...(from ? { from } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
...(source ? { source } : {}),
|
||||
...(relayOfMessageId ? { relayOfMessageId } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
...(attachments?.length ? { attachments } : {}),
|
||||
...(taskRefs?.length ? { taskRefs } : {}),
|
||||
});
|
||||
const protocolInstruction =
|
||||
source === 'runtime_delivery' || relayOfMessageId
|
||||
? 'Delivered as an app-delivered runtime reply. Stop this turn now; do not call message_send again for the same inbound message.'
|
||||
: 'Delivered. If this answered one app/user instruction, do not call message_send again for the same answer.';
|
||||
const payload =
|
||||
result && typeof result === 'object' && !Array.isArray(result)
|
||||
? { ...(result as Record<string, unknown>), protocolInstruction }
|
||||
: { result, protocolInstruction };
|
||||
return await Promise.resolve(jsonTextContent(payload));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1427,6 +1427,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
);
|
||||
|
||||
expect(sent.deliveredToInbox).toBe(true);
|
||||
expect(sent.protocolInstruction).toContain('do not call message_send again');
|
||||
const inboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
|
||||
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(rows[0].source).toBe('system_notification');
|
||||
|
|
|
|||
|
|
@ -247,6 +247,10 @@
|
|||
"from": "resources/runtime",
|
||||
"to": "runtime"
|
||||
},
|
||||
{
|
||||
"from": "src/renderer/assets/participant-avatars",
|
||||
"to": "participant-avatars"
|
||||
},
|
||||
{
|
||||
"from": "mcp-server/dist/index.js",
|
||||
"to": "mcp-server/index.js"
|
||||
|
|
|
|||
|
|
@ -264,6 +264,7 @@ function drawLaunchStage(
|
|||
|
||||
ctx.save();
|
||||
switch (visualState) {
|
||||
case 'queued':
|
||||
case 'waiting': {
|
||||
const ringR = r + 8 + Math.sin(time * 3.2) * 1.4;
|
||||
const pulseAlpha = 0.28 + 0.18 * (0.5 + 0.5 * Math.sin(time * 3.2));
|
||||
|
|
@ -778,6 +779,7 @@ function truncateSubLabel(ctx: CanvasRenderingContext2D, label: string, r: numbe
|
|||
|
||||
function getLaunchStatusColor(visualState: GraphNode['launchVisualState']): string {
|
||||
switch (visualState) {
|
||||
case 'queued':
|
||||
case 'waiting':
|
||||
return hexWithAlpha('#d4d4d8', 0.8);
|
||||
case 'spawning':
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export type GraphLaunchVisualState =
|
|||
| 'registered_only'
|
||||
| 'stale_runtime'
|
||||
| 'settling'
|
||||
| 'queued'
|
||||
| 'error'
|
||||
| 'skipped';
|
||||
|
||||
|
|
|
|||
1292
pnpm-lock.yaml
1292
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -1,27 +1,27 @@
|
|||
{
|
||||
"version": "0.0.15",
|
||||
"sourceRef": "v0.0.15",
|
||||
"version": "0.0.17",
|
||||
"sourceRef": "v0.0.17",
|
||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||
"releaseRepository": "777genius/claude_agent_teams_ui",
|
||||
"releaseTag": "v1.2.0",
|
||||
"assets": {
|
||||
"darwin-arm64": {
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.15.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.17.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"darwin-x64": {
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.15.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.17.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"linux-x64": {
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.15.tar.gz",
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.17.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"win32-x64": {
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.15.zip",
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.17.zip",
|
||||
"archiveKind": "zip",
|
||||
"binaryName": "claude-multimodel.exe"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,12 @@ export interface TeamGraphData extends TeamViewSnapshot {
|
|||
messageFeed: InboxMessage[];
|
||||
}
|
||||
|
||||
function toGraphLaunchVisualState(
|
||||
visualState: ReturnType<typeof buildMemberLaunchPresentation>['launchVisualState'] | undefined
|
||||
): GraphNode['launchVisualState'] {
|
||||
return visualState === 'bootstrap_stalled' ? 'runtime_pending' : (visualState ?? undefined);
|
||||
}
|
||||
|
||||
export class TeamGraphAdapter {
|
||||
// ─── ES #private fields ──────────────────────────────────────────────────
|
||||
#lastTeamName = '';
|
||||
|
|
@ -430,6 +436,7 @@ export class TeamGraphAdapter {
|
|||
spawnLaunchState: undefined,
|
||||
spawnLivenessSource: undefined,
|
||||
spawnRuntimeAlive: undefined,
|
||||
spawnBootstrapStalled: undefined,
|
||||
runtimeAdvisory: leadMember.runtimeAdvisory,
|
||||
isLaunchSettling: false,
|
||||
isTeamAlive: data.isAlive,
|
||||
|
|
@ -462,7 +469,7 @@ export class TeamGraphAdapter {
|
|||
leadMember?.model,
|
||||
leadMember?.effort
|
||||
),
|
||||
launchVisualState: leadLaunchPresentation?.launchVisualState ?? undefined,
|
||||
launchVisualState: toGraphLaunchVisualState(leadLaunchPresentation?.launchVisualState),
|
||||
launchStatusLabel: leadLaunchPresentation?.launchStatusLabel ?? undefined,
|
||||
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
|
||||
avatarUrl: leadMember
|
||||
|
|
@ -538,6 +545,8 @@ export class TeamGraphAdapter {
|
|||
spawnLaunchState: spawn?.launchState,
|
||||
spawnLivenessSource: spawn?.livenessSource,
|
||||
spawnRuntimeAlive: spawn?.runtimeAlive,
|
||||
spawnBootstrapConfirmed: spawn?.bootstrapConfirmed,
|
||||
spawnBootstrapStalled: spawn?.bootstrapStalled,
|
||||
runtimeAdvisory: member.runtimeAdvisory,
|
||||
isLaunchSettling,
|
||||
isTeamAlive: data.isAlive,
|
||||
|
|
@ -562,7 +571,7 @@ export class TeamGraphAdapter {
|
|||
),
|
||||
spawnStatus: isTeamVisualOnline ? spawn?.status : undefined,
|
||||
launchVisualState: isTeamVisualOnline
|
||||
? (launchPresentation.launchVisualState ?? undefined)
|
||||
? toGraphLaunchVisualState(launchPresentation.launchVisualState)
|
||||
: undefined,
|
||||
launchStatusLabel: isTeamVisualOnline
|
||||
? (launchPresentation.launchStatusLabel ?? undefined)
|
||||
|
|
|
|||
|
|
@ -325,6 +325,8 @@ const MemberPopoverContent = ({
|
|||
spawnLaunchState: spawnEntry?.launchState,
|
||||
spawnLivenessSource: spawnEntry?.livenessSource,
|
||||
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
|
||||
spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed,
|
||||
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
|
||||
runtimeAdvisory: member.runtimeAdvisory,
|
||||
isLaunchSettling: provisioningPresentation?.hasMembersStillJoining ?? false,
|
||||
isTeamAlive: teamData?.isAlive,
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ export const GraphProvisioningHud = ({
|
|||
<StepProgressBar
|
||||
steps={MINI_STEPS}
|
||||
currentIndex={presentation.currentStepIndex}
|
||||
active={presentation.isActive}
|
||||
errorIndex={errorStepIndex}
|
||||
className="w-full origin-top scale-[0.88]"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
|
|||
import type { TeamMember } from '@shared/types';
|
||||
|
||||
export interface TeamTaskAgendaSourceDeps {
|
||||
configReader: TeamConfigReader;
|
||||
configReader: Pick<TeamConfigReader, 'getConfig'>;
|
||||
taskReader: TeamTaskReader;
|
||||
kanbanManager: TeamKanbanManager;
|
||||
membersMetaStore: TeamMembersMetaStore;
|
||||
|
|
|
|||
|
|
@ -124,8 +124,18 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
}): MemberWorkSyncFeatureFacade {
|
||||
const clock = new SystemClockAdapter();
|
||||
const hash = new NodeHashAdapter();
|
||||
const configReaderForReadOnlySync = {
|
||||
listTeams: () =>
|
||||
typeof deps.configReader.listTeams === 'function'
|
||||
? deps.configReader.listTeams()
|
||||
: Promise.resolve([]),
|
||||
getConfig: (teamName: string) =>
|
||||
typeof deps.configReader.getConfigSnapshot === 'function'
|
||||
? deps.configReader.getConfigSnapshot(teamName)
|
||||
: deps.configReader.getConfig(teamName),
|
||||
};
|
||||
const agendaSource = new TeamTaskAgendaSource({
|
||||
configReader: deps.configReader,
|
||||
configReader: configReaderForReadOnlySync,
|
||||
taskReader: deps.taskReader,
|
||||
kanbanManager: deps.kanbanManager,
|
||||
membersMetaStore: deps.membersMetaStore,
|
||||
|
|
@ -150,7 +160,7 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
const runtimeTurnSettledTargetResolver =
|
||||
deps.runtimeTurnSettledTargetResolver ??
|
||||
new TeamRuntimeTurnSettledTargetResolver({
|
||||
teamSource: deps.configReader,
|
||||
teamSource: configReaderForReadOnlySync,
|
||||
membersMetaStore: deps.membersMetaStore,
|
||||
});
|
||||
const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths);
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ export interface MixedSecondaryLaneMemberStateInput {
|
|||
pidSource?: TeamAgentRuntimePidSource;
|
||||
runtimeDiagnostic?: string;
|
||||
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
||||
bootstrapStalled?: boolean;
|
||||
firstSpawnAcceptedAt?: string;
|
||||
diagnostics?: string[];
|
||||
} | null;
|
||||
pendingReason?: string;
|
||||
|
|
@ -87,15 +89,90 @@ function preservesStrongRuntimeAlive(value: {
|
|||
);
|
||||
}
|
||||
|
||||
function hasMaterializedOpenCodeRuntimeMarker(value: {
|
||||
runtimeAlive?: boolean;
|
||||
runtimePid?: number;
|
||||
runtimeSessionId?: string;
|
||||
sessionId?: string;
|
||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
}): boolean {
|
||||
return (
|
||||
value.runtimeAlive === true ||
|
||||
(typeof value.runtimePid === 'number' &&
|
||||
Number.isFinite(value.runtimePid) &&
|
||||
value.runtimePid > 0) ||
|
||||
(typeof value.runtimeSessionId === 'string' && value.runtimeSessionId.trim().length > 0) ||
|
||||
(typeof value.sessionId === 'string' && value.sessionId.trim().length > 0) ||
|
||||
value.livenessKind === 'runtime_process' ||
|
||||
value.livenessKind === 'runtime_process_candidate' ||
|
||||
value.livenessKind === 'registered_only'
|
||||
);
|
||||
}
|
||||
|
||||
const OPENCODE_MEMBER_SESSION_RECORDED_AT_PATTERN =
|
||||
/\bmember_session_recorded\s+at\s+([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.+-]+Z?)\b/i;
|
||||
|
||||
function normalizeIsoTimestamp(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Date.parse(trimmed);
|
||||
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
||||
}
|
||||
|
||||
function selectEarliestIsoTimestamp(values: readonly unknown[]): string | undefined {
|
||||
let selected: { value: string; timeMs: number } | null = null;
|
||||
for (const value of values) {
|
||||
const normalized = normalizeIsoTimestamp(value);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
const timeMs = Date.parse(normalized);
|
||||
if (!selected || timeMs < selected.timeMs) {
|
||||
selected = { value: normalized, timeMs };
|
||||
}
|
||||
}
|
||||
return selected?.value;
|
||||
}
|
||||
|
||||
function extractOpenCodeMemberSessionRecordedAt(
|
||||
diagnostics: readonly string[] | undefined
|
||||
): string[] {
|
||||
return (diagnostics ?? []).flatMap((diagnostic) => {
|
||||
const match = OPENCODE_MEMBER_SESSION_RECORDED_AT_PATTERN.exec(diagnostic);
|
||||
return match?.[1] ? [match[1]] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function resolveOpenCodeSecondaryFirstSpawnAcceptedAt(
|
||||
evidence: NonNullable<MixedSecondaryLaneMemberStateInput['evidence']>,
|
||||
fallbackUpdatedAt: string
|
||||
): string | undefined {
|
||||
if (evidence.agentToolAccepted !== true) {
|
||||
return undefined;
|
||||
}
|
||||
return selectEarliestIsoTimestamp([
|
||||
evidence.firstSpawnAcceptedAt,
|
||||
...extractOpenCodeMemberSessionRecordedAt(evidence.diagnostics),
|
||||
fallbackUpdatedAt,
|
||||
]);
|
||||
}
|
||||
|
||||
function buildDiagnostics(
|
||||
member: Pick<
|
||||
PersistedTeamLaunchMemberState,
|
||||
| 'agentToolAccepted'
|
||||
| 'runtimeAlive'
|
||||
| 'bootstrapConfirmed'
|
||||
| 'hardFailure'
|
||||
| 'hardFailureReason'
|
||||
| 'sources'
|
||||
| 'pendingPermissionRequestIds'
|
||||
| 'bootstrapStalled'
|
||||
>
|
||||
): string[] {
|
||||
const diagnostics: string[] = [];
|
||||
|
|
@ -104,6 +181,10 @@ function buildDiagnostics(
|
|||
if (member.bootstrapConfirmed) diagnostics.push('late heartbeat received');
|
||||
if ((member.pendingPermissionRequestIds?.length ?? 0) > 0) {
|
||||
diagnostics.push('waiting for permission approval');
|
||||
} else if (member.bootstrapStalled) {
|
||||
diagnostics.push('opencode_bootstrap_stalled');
|
||||
} else if (member.hardFailure && member.runtimeAlive && !member.bootstrapConfirmed) {
|
||||
diagnostics.push('bootstrap failed while runtime process was still alive');
|
||||
} else if (member.runtimeAlive && !member.bootstrapConfirmed) {
|
||||
diagnostics.push('waiting for teammate check-in');
|
||||
}
|
||||
|
|
@ -225,6 +306,9 @@ function createSecondaryLaneMemberState(
|
|||
});
|
||||
const hardFailure = evidence?.hardFailure === true || launchState === 'failed_to_start';
|
||||
const hardFailureReason = hardFailure ? evidence?.hardFailureReason : undefined;
|
||||
const firstSpawnAcceptedAt = evidence
|
||||
? resolveOpenCodeSecondaryFirstSpawnAcceptedAt(evidence, params.updatedAt)
|
||||
: undefined;
|
||||
const base: PersistedTeamLaunchMemberState = {
|
||||
name: params.member.name.trim(),
|
||||
providerId,
|
||||
|
|
@ -268,7 +352,16 @@ function createSecondaryLaneMemberState(
|
|||
pidSource: evidence?.pidSource,
|
||||
runtimeDiagnostic: evidence?.runtimeDiagnostic,
|
||||
runtimeDiagnosticSeverity: evidence?.runtimeDiagnosticSeverity,
|
||||
firstSpawnAcceptedAt: evidence?.agentToolAccepted ? params.updatedAt : undefined,
|
||||
bootstrapStalled:
|
||||
providerId === 'opencode' &&
|
||||
evidence?.bootstrapStalled === true &&
|
||||
launchState === 'runtime_pending_bootstrap' &&
|
||||
hasMaterializedOpenCodeRuntimeMarker(evidence) &&
|
||||
evidence.bootstrapConfirmed !== true &&
|
||||
hardFailure !== true
|
||||
? true
|
||||
: undefined,
|
||||
firstSpawnAcceptedAt,
|
||||
lastHeartbeatAt: evidence?.bootstrapConfirmed ? params.updatedAt : undefined,
|
||||
runtimeLastSeenAt: strongRuntimeAlive ? params.updatedAt : undefined,
|
||||
lastRuntimeAliveAt: strongRuntimeAlive ? params.updatedAt : undefined,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { validateTeammateName, validateTeamName } from '@main/ipc/guards';
|
||||
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { extractUserFlags, PROTECTED_CLI_FLAGS } from '@shared/utils/cliArgsParser';
|
||||
import {
|
||||
|
|
@ -551,6 +552,7 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
|
|||
parseLaunchRequest(teamName, request.body),
|
||||
() => undefined
|
||||
);
|
||||
TeamConfigReader.invalidateListTeamsCache();
|
||||
return reply.send(response);
|
||||
} catch (error) {
|
||||
const statusCode = getStatusCode(error);
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ import {
|
|||
resolveAgentTeamsMcpLaunchSpec,
|
||||
TeamMcpConfigBuilder,
|
||||
} from '@main/services/team/TeamMcpConfigBuilder';
|
||||
import { TeamTranscriptProjectResolver } from '@main/services/team/TeamTranscriptProjectResolver';
|
||||
import { killTrackedCliProcesses } from '@main/utils/childProcess';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import {
|
||||
|
|
@ -119,6 +120,7 @@ import {
|
|||
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||
import { clearAutoResumeService } from './services/team/AutoResumeService';
|
||||
import { LaunchIoGovernor } from './services/team/LaunchIoGovernor';
|
||||
import { OpenCodeBridgeCommandClient } from './services/team/opencode/bridge/OpenCodeBridgeCommandClient';
|
||||
import {
|
||||
createOpenCodeBridgeCommandLeaseStore,
|
||||
|
|
@ -135,6 +137,8 @@ import {
|
|||
clearTeamControlApiState,
|
||||
writeTeamControlApiState,
|
||||
} from './services/team/TeamControlApiState';
|
||||
import { getTeamDataWorkerClient } from './services/team/TeamDataWorkerClient';
|
||||
import { getTeamFsWorkerClient } from './services/team/TeamFsWorkerClient';
|
||||
import { TeamInboxReader } from './services/team/TeamInboxReader';
|
||||
import { TeamMemberRuntimeAdvisoryService } from './services/team/TeamMemberRuntimeAdvisoryService';
|
||||
import {
|
||||
|
|
@ -494,6 +498,9 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
|||
summary,
|
||||
body: extracted.body,
|
||||
dedupeKey: `inbox:${teamName}:${memberName}:${msgId}`,
|
||||
target: isCrossTeam
|
||||
? { kind: 'team', teamName, section: 'messages' }
|
||||
: { kind: 'member', teamName, memberName: fromLabel, focus: 'messages' },
|
||||
suppressToast: effectiveSuppressToast,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
|
@ -553,6 +560,7 @@ async function notifyNewSentMessages(teamName: string): Promise<void> {
|
|||
summary,
|
||||
body: extracted.body,
|
||||
dedupeKey: `sent:${teamName}:${msg.timestamp ?? String(prevCount + i)}`,
|
||||
target: { kind: 'member', teamName, memberName: fromLabel, focus: 'messages' },
|
||||
suppressToast,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
|
@ -588,6 +596,7 @@ let runtimeProviderManagementFeature: RuntimeProviderManagementFeatureFacade;
|
|||
let memberWorkSyncFeature: MemberWorkSyncFeatureFacade | null = null;
|
||||
let teamDataService: TeamDataService;
|
||||
let teamProvisioningService: TeamProvisioningService;
|
||||
let launchIoGovernor: LaunchIoGovernor | null = null;
|
||||
let cliInstallerService: CliInstallerService;
|
||||
let ptyTerminalService: PtyTerminalService;
|
||||
let httpServer: HttpServer;
|
||||
|
|
@ -608,6 +617,8 @@ let shutdownComplete = false;
|
|||
const startupTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const SHUTDOWN_STEP_TIMEOUT_MS = 5_000;
|
||||
const STARTUP_RECOVERY_DELAY_MS = 10_000;
|
||||
const STARTUP_RECOVERY_CONCURRENCY = 1;
|
||||
|
||||
function isShutdownStarted(): boolean {
|
||||
return shutdownComplete || shutdownPromise !== null;
|
||||
|
|
@ -625,6 +636,23 @@ function scheduleStartupTask(action: () => void, delayMs: number): void {
|
|||
startupTimers.add(timer);
|
||||
}
|
||||
|
||||
async function runStartupJobsBounded<T>(
|
||||
items: readonly T[],
|
||||
concurrency: number,
|
||||
run: (item: T) => Promise<void>
|
||||
): Promise<void> {
|
||||
const workerCount = Math.max(1, Math.min(concurrency, items.length));
|
||||
const workers = Array.from({ length: workerCount }, async (_, workerIndex) => {
|
||||
for (let index = workerIndex; index < items.length; index += workerCount) {
|
||||
if (isShutdownStarted()) {
|
||||
return;
|
||||
}
|
||||
await run(items[index]);
|
||||
}
|
||||
});
|
||||
await Promise.allSettled(workers);
|
||||
}
|
||||
|
||||
function clearStartupTimers(): void {
|
||||
for (const timer of startupTimers) {
|
||||
clearTimeout(timer);
|
||||
|
|
@ -805,13 +833,31 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
if (typeof row.teamName !== 'string' || row.teamName.trim().length === 0) return;
|
||||
const teamName = row.teamName.trim();
|
||||
const detail = typeof row.detail === 'string' ? row.detail : '';
|
||||
launchIoGovernor?.noteTeamChange(row as TeamChangeEvent);
|
||||
memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent);
|
||||
|
||||
if (row.type === 'config') {
|
||||
if (detail === 'config.json') {
|
||||
TeamConfigReader.invalidateTeam(teamName);
|
||||
getTeamDataWorkerClient().invalidateTeamConfig(teamName);
|
||||
} else if (detail === 'team.meta.json' || detail === 'members.meta.json') {
|
||||
TeamConfigReader.invalidateListTeamsCache();
|
||||
getTeamDataWorkerClient().invalidateTeamConfig(teamName);
|
||||
}
|
||||
}
|
||||
|
||||
if (row.type === 'task') {
|
||||
TeamTaskReader.invalidateAllTasksCache();
|
||||
}
|
||||
|
||||
if (
|
||||
teamDataService &&
|
||||
(row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config')
|
||||
) {
|
||||
teamDataService.invalidateMessageFeed(teamName);
|
||||
if (row.type === 'inbox' || row.type === 'lead-message') {
|
||||
getTeamDataWorkerClient().invalidateTeamMessageFeed(teamName);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Inbox change events: relay to lead + native OS notifications ---
|
||||
|
|
@ -1032,6 +1078,10 @@ async function initializeServices(): Promise<void> {
|
|||
// Set notification manager on local context's file watcher
|
||||
localContext.fileWatcher.setNotificationManager(notificationManager);
|
||||
|
||||
launchIoGovernor = new LaunchIoGovernor({
|
||||
logger: createLogger('Service:LaunchIoGovernor'),
|
||||
});
|
||||
|
||||
// Wire file watcher events for local context
|
||||
wireFileWatcherEvents(localContext);
|
||||
|
||||
|
|
@ -1054,7 +1104,12 @@ async function initializeServices(): Promise<void> {
|
|||
ptyTerminalService = new PtyTerminalService();
|
||||
const teamMemberLogsFinder = new TeamMemberLogsFinder();
|
||||
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
|
||||
const teamTranscriptSourceLocator = new TeamTranscriptSourceLocator();
|
||||
const taskLogConfigReader = new TeamConfigReader();
|
||||
const teamTranscriptSourceLocator = new TeamTranscriptSourceLocator(
|
||||
new TeamTranscriptProjectResolver({
|
||||
getConfig: (teamName) => taskLogConfigReader.getConfigSnapshot(teamName),
|
||||
})
|
||||
);
|
||||
teamLogSourceTracker.onLogSourceChange((teamName) => {
|
||||
teamTranscriptSourceLocator.invalidateTeam(teamName);
|
||||
});
|
||||
|
|
@ -1197,11 +1252,27 @@ async function initializeServices(): Promise<void> {
|
|||
});
|
||||
|
||||
const forwardTeamChange = (event: TeamChangeEvent): void => {
|
||||
launchIoGovernor?.noteTeamChange(event);
|
||||
if (event.type === 'config') {
|
||||
if (event.detail === 'config.json') {
|
||||
TeamConfigReader.invalidateTeam(event.teamName);
|
||||
getTeamDataWorkerClient().invalidateTeamConfig(event.teamName);
|
||||
} else if (event.detail === 'team.meta.json' || event.detail === 'members.meta.json') {
|
||||
TeamConfigReader.invalidateListTeamsCache();
|
||||
getTeamDataWorkerClient().invalidateTeamConfig(event.teamName);
|
||||
}
|
||||
}
|
||||
if (event.type === 'task') {
|
||||
TeamTaskReader.invalidateAllTasksCache();
|
||||
}
|
||||
if (
|
||||
teamDataService &&
|
||||
(event.type === 'inbox' || event.type === 'lead-message' || event.type === 'config')
|
||||
) {
|
||||
teamDataService.invalidateMessageFeed(event.teamName);
|
||||
if (event.type === 'inbox' || event.type === 'lead-message') {
|
||||
getTeamDataWorkerClient().invalidateTeamMessageFeed(event.teamName);
|
||||
}
|
||||
}
|
||||
safeSendToRenderer(mainWindow, TEAM_CHANGE, event);
|
||||
httpServer?.broadcast('team-change', event);
|
||||
|
|
@ -1225,18 +1296,25 @@ async function initializeServices(): Promise<void> {
|
|||
teamLogSourceTracker.onLogSourceChange((teamName) => {
|
||||
teammateToolTracker?.handleLogSourceChange(teamName);
|
||||
});
|
||||
void teamDataService
|
||||
.listTeams()
|
||||
.then(async (teams) => {
|
||||
await Promise.all(
|
||||
teams.map((team) =>
|
||||
teamProvisioningService.scanOpenCodePromptDeliveryWatchdog(team.teamName)
|
||||
)
|
||||
scheduleStartupTask(() => {
|
||||
void teamDataService
|
||||
.listTeams()
|
||||
.then(async (teams) => {
|
||||
const activeTeamNames = teams
|
||||
.filter((team) => !team.deletedAt)
|
||||
.map((team) => team.teamName);
|
||||
await runStartupJobsBounded(
|
||||
activeTeamNames,
|
||||
STARTUP_RECOVERY_CONCURRENCY,
|
||||
async (teamName) => {
|
||||
await teamProvisioningService.scanOpenCodePromptDeliveryWatchdog(teamName);
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) =>
|
||||
logger.warn(`[Init] OpenCode prompt delivery watchdog recovery failed: ${String(error)}`)
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) =>
|
||||
logger.warn(`[Init] OpenCode prompt delivery watchdog recovery failed: ${String(error)}`)
|
||||
);
|
||||
}, STARTUP_RECOVERY_DELAY_MS);
|
||||
teamTaskStallMonitor.start();
|
||||
|
||||
// Allow SchedulerService to push schedule events to renderer
|
||||
|
|
@ -1291,16 +1369,25 @@ async function initializeServices(): Promise<void> {
|
|||
? memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment(input)
|
||||
: Promise.resolve(null)
|
||||
);
|
||||
void teamDataService
|
||||
.listTeams()
|
||||
.then(async (teams) => {
|
||||
const activeTeamNames = teams.filter((team) => !team.deletedAt).map((team) => team.teamName);
|
||||
await memberWorkSyncFeature?.replayPendingReports(activeTeamNames);
|
||||
await memberWorkSyncFeature?.enqueueStartupScan(activeTeamNames);
|
||||
})
|
||||
.catch((error: unknown) =>
|
||||
logger.warn(`[Init] Member work sync startup scan failed: ${String(error)}`)
|
||||
);
|
||||
scheduleStartupTask(() => {
|
||||
void teamDataService
|
||||
.listTeams()
|
||||
.then(async (teams) => {
|
||||
const lifecycleActiveTeamNames = teams
|
||||
.filter(
|
||||
(team) =>
|
||||
!team.deletedAt &&
|
||||
(teamProvisioningService.isTeamAlive(team.teamName) ||
|
||||
teamProvisioningService.hasProvisioningRun(team.teamName))
|
||||
)
|
||||
.map((team) => team.teamName);
|
||||
await memberWorkSyncFeature?.replayPendingReports(lifecycleActiveTeamNames);
|
||||
await memberWorkSyncFeature?.enqueueStartupScan(lifecycleActiveTeamNames);
|
||||
})
|
||||
.catch((error: unknown) =>
|
||||
logger.warn(`[Init] Member work sync startup scan failed: ${String(error)}`)
|
||||
);
|
||||
}, STARTUP_RECOVERY_DELAY_MS + 2_000);
|
||||
codexAccountFeature = createCodexAccountFeature({
|
||||
logger: createLogger('Feature:CodexAccount'),
|
||||
configManager,
|
||||
|
|
@ -1363,7 +1450,8 @@ async function initializeServices(): Promise<void> {
|
|||
skillsMutationService,
|
||||
skillsWatcherService,
|
||||
crossTeamService,
|
||||
teamBackupService ?? undefined
|
||||
teamBackupService ?? undefined,
|
||||
launchIoGovernor ?? undefined
|
||||
);
|
||||
registerCodexAccountIpc(ipcMain, codexAccountFeature);
|
||||
registerRecentProjectsIpc(ipcMain, recentProjectsFeature);
|
||||
|
|
@ -1758,6 +1846,30 @@ function createWindow(): void {
|
|||
updaterService.startPeriodicCheck(60 * 60 * 1000);
|
||||
}
|
||||
|
||||
scheduleStartupTask(
|
||||
() => {
|
||||
void getTeamFsWorkerClient()
|
||||
.prewarm()
|
||||
.catch((error: unknown) =>
|
||||
logger.debug(
|
||||
`[startup] team-fs-worker prewarm skipped: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
);
|
||||
void getTeamDataWorkerClient()
|
||||
.prewarm()
|
||||
.catch((error: unknown) =>
|
||||
logger.debug(
|
||||
`[startup] team-data-worker prewarm skipped: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
);
|
||||
},
|
||||
process.platform === 'win32' ? 2500 : 1000
|
||||
);
|
||||
|
||||
// Defer non-critical startup work to avoid thread pool contention.
|
||||
// The window is now visible and responsive; these run in the background.
|
||||
scheduleStartupTask(() => {
|
||||
|
|
@ -2001,6 +2113,8 @@ app.on('before-quit', (event) => {
|
|||
|
||||
event.preventDefault();
|
||||
|
||||
notificationManager.closeActiveNativeNotifications('app-before-quit');
|
||||
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.hide();
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ import type { McpHealthDiagnosticsService } from '../services/extensions/state/M
|
|||
import type { HttpServer } from '../services/infrastructure/HttpServer';
|
||||
import type { SchedulerService } from '../services/schedule/SchedulerService';
|
||||
import type { CrossTeamService } from '../services/team/CrossTeamService';
|
||||
import type { LaunchIoGovernor } from '../services/team/LaunchIoGovernor';
|
||||
import type { TeamBackupService } from '../services/team/TeamBackupService';
|
||||
|
||||
/**
|
||||
|
|
@ -169,7 +170,8 @@ export function initializeIpcHandlers(
|
|||
skillsMutationService?: SkillsMutationService,
|
||||
skillsWatcherService?: SkillsWatcherService,
|
||||
crossTeamService?: CrossTeamService,
|
||||
teamBackupService?: TeamBackupService
|
||||
teamBackupService?: TeamBackupService,
|
||||
launchIoGovernor?: LaunchIoGovernor
|
||||
): void {
|
||||
// Initialize domain handlers with registry
|
||||
initializeProjectHandlers(registry);
|
||||
|
|
@ -192,7 +194,8 @@ export function initializeIpcHandlers(
|
|||
boardTaskActivityDetailService,
|
||||
boardTaskLogStreamService,
|
||||
boardTaskExactLogsService,
|
||||
boardTaskExactLogDetailService
|
||||
boardTaskExactLogDetailService,
|
||||
launchIoGovernor
|
||||
);
|
||||
initializeConfigHandlers({
|
||||
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
TEAM_GET_MEMBER_LOGS,
|
||||
TEAM_GET_MEMBER_STATS,
|
||||
TEAM_GET_MESSAGES_PAGE,
|
||||
TEAM_GET_OPENCODE_RUNTIME_DELIVERY_STATUS,
|
||||
TEAM_GET_PROJECT_BRANCH,
|
||||
TEAM_GET_SAVED_REQUEST,
|
||||
TEAM_GET_TASK_ACTIVITY,
|
||||
|
|
@ -60,6 +61,7 @@ import {
|
|||
TEAM_RESTART_MEMBER,
|
||||
TEAM_RESTORE,
|
||||
TEAM_RESTORE_TASK,
|
||||
TEAM_RETRY_FAILED_OPENCODE_SECONDARY_LANES,
|
||||
TEAM_SAVE_TASK_ATTACHMENT,
|
||||
TEAM_SEND_MESSAGE,
|
||||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||||
|
|
@ -123,7 +125,12 @@ import {
|
|||
import {
|
||||
getAutoResumeService,
|
||||
initializeAutoResumeService,
|
||||
planRateLimitAutoResume,
|
||||
} from '../services/team/AutoResumeService';
|
||||
import {
|
||||
cloneLaunchIoGovernorPayload,
|
||||
type LaunchIoGovernor,
|
||||
} from '../services/team/LaunchIoGovernor';
|
||||
import {
|
||||
buildReplaceMembersDiff,
|
||||
buildReplaceMembersSummaryMessage,
|
||||
|
|
@ -183,6 +190,8 @@ import type {
|
|||
MemberLogSummary,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
MessagesPage,
|
||||
OpenCodeRuntimeDeliveryStatus,
|
||||
RetryFailedOpenCodeSecondaryLanesResult,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
TaskAttachmentMeta,
|
||||
|
|
@ -197,6 +206,7 @@ import type {
|
|||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamFastMode,
|
||||
TeamGetDataOptions,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamMemberActivityMeta,
|
||||
|
|
@ -221,6 +231,61 @@ import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser';
|
|||
const logger = createLogger('IPC:teams');
|
||||
const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 12_000;
|
||||
|
||||
type VisibleDirectReplyProtocol = 'send_message' | 'agent_teams_message_send';
|
||||
|
||||
function resolveVisibleDirectReplyProtocol(input: {
|
||||
providerId?: TeamProviderId;
|
||||
isLeadRecipient: boolean;
|
||||
replyRecipient: string;
|
||||
}): VisibleDirectReplyProtocol {
|
||||
if (
|
||||
!input.isLeadRecipient &&
|
||||
input.replyRecipient.trim().toLowerCase() === 'user' &&
|
||||
input.providerId === 'codex'
|
||||
) {
|
||||
return 'agent_teams_message_send';
|
||||
}
|
||||
|
||||
return 'send_message';
|
||||
}
|
||||
const TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS = 250;
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (value == null || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function validateTeamGetDataOptions(
|
||||
value: unknown
|
||||
): { valid: true; value: TeamGetDataOptions | undefined } | { valid: false; error: string } {
|
||||
if (value === undefined) {
|
||||
return { valid: true, value: undefined };
|
||||
}
|
||||
if (!isPlainObject(value)) {
|
||||
return { valid: false, error: 'options must be an object' };
|
||||
}
|
||||
|
||||
const allowed = new Set(['includeMemberBranches']);
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.has(key)) {
|
||||
return { valid: false, error: `Unknown getData option: ${key}` };
|
||||
}
|
||||
}
|
||||
|
||||
const includeMemberBranches = value.includeMemberBranches;
|
||||
if (includeMemberBranches !== undefined && typeof includeMemberBranches !== 'boolean') {
|
||||
return { valid: false, error: 'includeMemberBranches must be a boolean' };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
value: includeMemberBranches === false ? { includeMemberBranches: false } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory set of rate-limit message keys already processed.
|
||||
* Independent of NotificationManager storage — survives notification deletion/pruning.
|
||||
|
|
@ -359,6 +424,21 @@ function buildLeadDirectDelegateAckBlock(actionMode?: AgentActionMode): string |
|
|||
const seenApiErrorKeys = new Set<string>();
|
||||
const SEEN_API_ERROR_KEYS_MAX = 500;
|
||||
|
||||
function formatNotificationClockTime(date: Date): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function buildRateLimitNotificationBody(plan: ReturnType<typeof planRateLimitAutoResume>): string {
|
||||
if (plan.kind === 'scheduled') {
|
||||
return `Auto-resume scheduled at ${formatNotificationClockTime(new Date(plan.fireAtMs))}`;
|
||||
}
|
||||
return 'Manual restart needed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check messages for rate limit indicators and fire notifications for new ones.
|
||||
* Uses both in-memory seenRateLimitKeys (to prevent resurrection after deletion)
|
||||
|
|
@ -390,6 +470,18 @@ function checkRateLimitMessages(
|
|||
|
||||
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
|
||||
const dedupeKey = `rate-limit:${teamName}:${rawKey}`;
|
||||
const isLeadAutoResumeCandidate =
|
||||
!msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
|
||||
const autoResumeSessionMatches =
|
||||
msg.source !== 'lead_session' ||
|
||||
(Boolean(currentLeadSessionId) && msg.leadSessionId === currentLeadSessionId);
|
||||
const autoResumePlan = planRateLimitAutoResume({
|
||||
enabled: autoResumeEnabled,
|
||||
canAutoResume: teamIsAlive && isLeadAutoResumeCandidate && autoResumeSessionMatches,
|
||||
messageText: msg.text,
|
||||
observedAt,
|
||||
messageTimestamp: new Date(msg.timestamp),
|
||||
});
|
||||
|
||||
// In-memory guard: prevents resurrection after user deletes the notification.
|
||||
if (!seenRateLimitKeys.has(dedupeKey)) {
|
||||
|
|
@ -407,9 +499,10 @@ function checkRateLimitMessages(
|
|||
teamName,
|
||||
teamDisplayName,
|
||||
from: msg.from,
|
||||
summary: `Rate limit: ${msg.from}`,
|
||||
body: msg.text.slice(0, 200),
|
||||
summary: 'Rate limit',
|
||||
body: buildRateLimitNotificationBody(autoResumePlan),
|
||||
dedupeKey,
|
||||
target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' },
|
||||
projectPath,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
|
@ -419,18 +512,10 @@ function checkRateLimitMessages(
|
|||
// Persisted history for an offline/stopped team may still contain the old
|
||||
// rate-limit message, but arming a new timer from that stale history would
|
||||
// resurrect the nudge into a later manual restart.
|
||||
const isLeadAutoResumeCandidate =
|
||||
!msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
|
||||
|
||||
if (autoResumeEnabled && teamIsAlive && isLeadAutoResumeCandidate) {
|
||||
if (autoResumePlan.kind === 'scheduled') {
|
||||
// Only let persisted lead_session history rebuild auto-resume when it
|
||||
// clearly belongs to the currently running lead session. Otherwise an old
|
||||
// rate-limit from a previous manual run can resurrect into a newer restart.
|
||||
if (msg.source === 'lead_session') {
|
||||
if (!currentLeadSessionId) continue;
|
||||
if (msg.leadSessionId !== currentLeadSessionId) continue;
|
||||
}
|
||||
|
||||
// Pass the original message timestamp so relative reset windows survive restarts
|
||||
// and old history does not rebuild a fresh auto-resume timer from "now".
|
||||
getAutoResumeService().handleRateLimitMessage(
|
||||
|
|
@ -477,13 +562,14 @@ function checkApiErrorMessages(
|
|||
|
||||
void NotificationManager.getInstance()
|
||||
.addTeamNotification({
|
||||
teamEventType: 'rate_limit', // reuse rate_limit type — closest fit
|
||||
teamEventType: 'api_error',
|
||||
teamName,
|
||||
teamDisplayName,
|
||||
from: msg.from,
|
||||
summary: `API Error ${statusCode}: ${msg.from}`,
|
||||
body: msg.text.slice(0, 400),
|
||||
summary: `API Error ${statusCode}`,
|
||||
body: 'Manual restart needed',
|
||||
dedupeKey,
|
||||
target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' },
|
||||
projectPath,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
|
@ -511,6 +597,7 @@ let teamBackupService: TeamBackupService | null = null;
|
|||
let teammateToolTracker: TeammateToolTracker | null = null;
|
||||
let teamLogSourceTracker: TeamLogSourceTracker | null = null;
|
||||
let branchStatusService: BranchStatusService | null = null;
|
||||
let launchIoGovernor: LaunchIoGovernor | null = null;
|
||||
let boardTaskActivityService: BoardTaskActivityService | null = null;
|
||||
let boardTaskActivityDetailService: BoardTaskActivityDetailService | null = null;
|
||||
let boardTaskLogStreamService: BoardTaskLogStreamService | null = null;
|
||||
|
|
@ -563,7 +650,8 @@ export function initializeTeamHandlers(
|
|||
taskActivityDetailService?: BoardTaskActivityDetailService,
|
||||
taskLogStreamService?: BoardTaskLogStreamService,
|
||||
taskExactLogsService?: BoardTaskExactLogsService,
|
||||
taskExactLogDetailService?: BoardTaskExactLogDetailService
|
||||
taskExactLogDetailService?: BoardTaskExactLogDetailService,
|
||||
ioGovernor?: LaunchIoGovernor
|
||||
): void {
|
||||
teamDataService = service;
|
||||
teamProvisioningService = provisioningService;
|
||||
|
|
@ -574,6 +662,7 @@ export function initializeTeamHandlers(
|
|||
teammateToolTracker = toolTracker ?? null;
|
||||
teamLogSourceTracker = logSourceTracker ?? null;
|
||||
branchStatusService = branchTracker ?? null;
|
||||
launchIoGovernor = ioGovernor ?? null;
|
||||
boardTaskActivityService = taskActivityService ?? null;
|
||||
boardTaskActivityDetailService = taskActivityDetailService ?? null;
|
||||
boardTaskLogStreamService = taskLogStreamService ?? null;
|
||||
|
|
@ -599,6 +688,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus);
|
||||
ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning);
|
||||
ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage);
|
||||
ipcMain.handle(TEAM_GET_OPENCODE_RUNTIME_DELIVERY_STATUS, handleGetOpenCodeRuntimeDeliveryStatus);
|
||||
ipcMain.handle(TEAM_GET_MESSAGES_PAGE, handleGetMessagesPage);
|
||||
ipcMain.handle(TEAM_GET_MEMBER_ACTIVITY_META, handleGetMemberActivityMeta);
|
||||
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
|
||||
|
|
@ -641,6 +731,10 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_LEAD_CONTEXT, handleLeadContext);
|
||||
ipcMain.handle(TEAM_MEMBER_SPAWN_STATUSES, handleMemberSpawnStatuses);
|
||||
ipcMain.handle(TEAM_GET_AGENT_RUNTIME, handleGetAgentRuntime);
|
||||
ipcMain.handle(
|
||||
TEAM_RETRY_FAILED_OPENCODE_SECONDARY_LANES,
|
||||
handleRetryFailedOpenCodeSecondaryLanes
|
||||
);
|
||||
ipcMain.handle(TEAM_RESTART_MEMBER, handleRestartMember);
|
||||
ipcMain.handle(TEAM_SKIP_MEMBER_FOR_LAUNCH, handleSkipMemberForLaunch);
|
||||
ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask);
|
||||
|
|
@ -680,6 +774,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_PROVISIONING_STATUS);
|
||||
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
|
||||
ipcMain.removeHandler(TEAM_SEND_MESSAGE);
|
||||
ipcMain.removeHandler(TEAM_GET_OPENCODE_RUNTIME_DELIVERY_STATUS);
|
||||
ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE);
|
||||
ipcMain.removeHandler(TEAM_GET_MEMBER_ACTIVITY_META);
|
||||
ipcMain.removeHandler(TEAM_CREATE_TASK);
|
||||
|
|
@ -722,6 +817,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_LEAD_CONTEXT);
|
||||
ipcMain.removeHandler(TEAM_MEMBER_SPAWN_STATUSES);
|
||||
ipcMain.removeHandler(TEAM_GET_AGENT_RUNTIME);
|
||||
ipcMain.removeHandler(TEAM_RETRY_FAILED_OPENCODE_SECONDARY_LANES);
|
||||
ipcMain.removeHandler(TEAM_RESTART_MEMBER);
|
||||
ipcMain.removeHandler(TEAM_SKIP_MEMBER_FOR_LAUNCH);
|
||||
ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK);
|
||||
|
|
@ -895,7 +991,14 @@ async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<Te
|
|||
setCurrentMainOp('team:list');
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
return await wrapTeamHandler('list', () => getTeamDataService().listTeams());
|
||||
return await wrapTeamHandler('list', () => {
|
||||
const loadFresh = () => getTeamDataService().listTeams();
|
||||
return launchIoGovernor
|
||||
? launchIoGovernor.runSummaryOperation('teams:list', loadFresh, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
})
|
||||
: loadFresh();
|
||||
});
|
||||
} finally {
|
||||
const ms = Date.now() - startedAt;
|
||||
if (ms >= 1500) {
|
||||
|
|
@ -907,44 +1010,74 @@ async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<Te
|
|||
|
||||
async function handleGetData(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
teamName: unknown,
|
||||
rawOptions?: unknown
|
||||
): Promise<IpcResult<TeamViewSnapshot>> {
|
||||
const validated = validateTeamName(teamName);
|
||||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
const optionsResult = validateTeamGetDataOptions(rawOptions);
|
||||
if (!optionsResult.valid) {
|
||||
return { success: false, error: optionsResult.error };
|
||||
}
|
||||
const tn = validated.value!;
|
||||
const getDataOptions = optionsResult.value;
|
||||
const startedAt = Date.now();
|
||||
let data: TeamViewSnapshot;
|
||||
let dataSource: 'worker' | 'main-fallback' | 'main-unavailable' = 'main-unavailable';
|
||||
let workerAvailable = false;
|
||||
const readFromMain = (): Promise<TeamViewSnapshot> =>
|
||||
getDataOptions === undefined
|
||||
? getTeamDataService().getTeamData(tn)
|
||||
: getTeamDataService().getTeamData(tn, getDataOptions);
|
||||
setCurrentMainOp('team:getData');
|
||||
try {
|
||||
// Prefer worker thread to keep main event loop responsive
|
||||
const worker = getTeamDataWorkerClient();
|
||||
if (worker.isAvailable()) {
|
||||
workerAvailable = worker.isAvailable();
|
||||
const missingState = await classifyMissingTeamData(tn);
|
||||
if (missingState === 'provisioning') {
|
||||
return { success: false, error: 'TEAM_PROVISIONING' };
|
||||
}
|
||||
if (missingState === 'draft') {
|
||||
return { success: false, error: 'TEAM_DRAFT' };
|
||||
}
|
||||
|
||||
if (workerAvailable) {
|
||||
try {
|
||||
data = await worker.getTeamData(tn);
|
||||
data =
|
||||
getDataOptions === undefined
|
||||
? await worker.getTeamData(tn)
|
||||
: await worker.getTeamData(tn, getDataOptions);
|
||||
dataSource = 'worker';
|
||||
} catch (workerErr) {
|
||||
logger.warn(
|
||||
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
|
||||
);
|
||||
noteHeavyTeamDataWorkerFallback('teams:getData');
|
||||
data = await getTeamDataService().getTeamData(tn);
|
||||
data = await readFromMain();
|
||||
dataSource = 'main-fallback';
|
||||
}
|
||||
} else {
|
||||
noteHeavyTeamDataWorkerFallback('teams:getData');
|
||||
data = await getTeamDataService().getTeamData(tn);
|
||||
data = await readFromMain();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
message === `Team not found: ${tn}` &&
|
||||
getTeamProvisioningService().hasProvisioningRun(tn)
|
||||
getTeamProvisioningService().hasProvisioningRun?.(tn) === true
|
||||
) {
|
||||
return { success: false, error: 'TEAM_PROVISIONING' };
|
||||
}
|
||||
// Draft team: team.meta.json exists but config.json doesn't (provisioning failed before TeamCreate)
|
||||
if (message === `Team not found: ${tn}`) {
|
||||
const meta = await teamMetaStore.getMeta(tn);
|
||||
const meta = await withTimeoutValue(
|
||||
teamMetaStore.getMeta(tn).catch(() => null),
|
||||
TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS,
|
||||
null
|
||||
);
|
||||
if (meta) {
|
||||
return { success: false, error: 'TEAM_DRAFT' };
|
||||
}
|
||||
|
|
@ -957,7 +1090,10 @@ async function handleGetData(
|
|||
const getDataMs = Date.now() - startedAt;
|
||||
|
||||
if (getDataMs >= 1500) {
|
||||
logger.warn(`[teams:getData] slow team=${tn} ms=${getDataMs}`);
|
||||
const branchMode = getDataOptions?.includeMemberBranches === false ? 'skipped' : 'full';
|
||||
logger.warn(
|
||||
`[teams:getData] slow team=${tn} ms=${getDataMs} source=${dataSource} workerAvailable=${workerAvailable} branchMode=${branchMode}`
|
||||
);
|
||||
}
|
||||
const teamDataService = getTeamDataService();
|
||||
if (data.processes.some((process) => !process.stoppedAt)) {
|
||||
|
|
@ -1015,6 +1151,33 @@ async function handleGetData(
|
|||
return { success: true, data: { ...data, isAlive } };
|
||||
}
|
||||
|
||||
async function classifyMissingTeamData(teamName: string): Promise<'provisioning' | 'draft' | null> {
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
const configExists = await withTimeoutValue(
|
||||
fs.promises
|
||||
.access(configPath, fs.constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch((error: unknown) => {
|
||||
const code = typeof error === 'object' && error ? (error as { code?: unknown }).code : null;
|
||||
return code === 'ENOENT' ? false : null;
|
||||
}),
|
||||
TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS,
|
||||
null
|
||||
);
|
||||
if (configExists !== false) {
|
||||
return null;
|
||||
}
|
||||
if (getTeamProvisioningService().hasProvisioningRun?.(teamName) === true) {
|
||||
return 'provisioning';
|
||||
}
|
||||
const meta = await withTimeoutValue(
|
||||
teamMetaStore.getMeta(teamName).catch(() => null),
|
||||
TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS,
|
||||
null
|
||||
);
|
||||
return meta ? 'draft' : null;
|
||||
}
|
||||
|
||||
async function handleGetTaskChangePresence(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
|
|
@ -1116,6 +1279,7 @@ async function handleDeleteTeam(
|
|||
getAutoResumeService().cancelPendingAutoResume(validated.value!);
|
||||
await getTeamProvisioningService().stopTeam(validated.value!);
|
||||
await getTeamDataService().deleteTeam(validated.value!);
|
||||
getTeamDataWorkerClient().invalidateTeamConfig(validated.value!);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1127,7 +1291,10 @@ async function handleRestoreTeam(
|
|||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
return wrapTeamHandler('restoreTeam', () => getTeamDataService().restoreTeam(validated.value!));
|
||||
return wrapTeamHandler('restoreTeam', async () => {
|
||||
await getTeamDataService().restoreTeam(validated.value!);
|
||||
getTeamDataWorkerClient().invalidateTeamConfig(validated.value!);
|
||||
});
|
||||
}
|
||||
|
||||
async function handlePermanentlyDeleteTeam(
|
||||
|
|
@ -1141,6 +1308,7 @@ async function handlePermanentlyDeleteTeam(
|
|||
return wrapTeamHandler('permanentlyDeleteTeam', async () => {
|
||||
getAutoResumeService().cancelPendingAutoResume(validated.value!);
|
||||
await getTeamDataService().permanentlyDeleteTeam(validated.value!);
|
||||
getTeamDataWorkerClient().invalidateTeamConfig(validated.value!);
|
||||
// Clean up app-owned data (attachments, task-attachments) that lives outside ~/.claude/
|
||||
const appData = getAppDataPath();
|
||||
await fs.promises
|
||||
|
|
@ -1208,6 +1376,7 @@ async function handleUpdateConfig(
|
|||
}
|
||||
}
|
||||
|
||||
getTeamDataWorkerClient().invalidateTeamConfig(tn);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
|
@ -1797,6 +1966,21 @@ function sendProvisioningProgress(
|
|||
safeSendToRenderer(targetWindow, TEAM_PROVISIONING_PROGRESS, progress);
|
||||
}
|
||||
|
||||
function noteLaunchIntentFailed(teamName: string, source: string): void {
|
||||
if (!launchIoGovernor) {
|
||||
return;
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
launchIoGovernor.noteProvisioningProgress({
|
||||
runId: `${source}:failed-before-progress`,
|
||||
teamName,
|
||||
state: 'failed',
|
||||
message: 'Launch failed before provisioning progress',
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
} as TeamProvisioningProgress);
|
||||
}
|
||||
|
||||
async function handleCreateTeam(
|
||||
event: IpcMainInvokeEvent,
|
||||
request: unknown
|
||||
|
|
@ -1807,11 +1991,18 @@ async function handleCreateTeam(
|
|||
}
|
||||
const progressTargetWindow = BrowserWindow.fromWebContents(event.sender);
|
||||
|
||||
return wrapTeamHandler('create', () => {
|
||||
return wrapTeamHandler('create', async () => {
|
||||
addMainBreadcrumb('team', 'create', { teamName: validation.value.teamName });
|
||||
return getTeamProvisioningService().createTeam(validation.value, (progress) => {
|
||||
sendProvisioningProgress(progressTargetWindow, progress);
|
||||
});
|
||||
launchIoGovernor?.noteLaunchIntent(validation.value.teamName, 'create');
|
||||
try {
|
||||
return await getTeamProvisioningService().createTeam(validation.value, (progress) => {
|
||||
launchIoGovernor?.noteProvisioningProgress(progress);
|
||||
sendProvisioningProgress(progressTargetWindow, progress);
|
||||
});
|
||||
} catch (error) {
|
||||
noteLaunchIntentFailed(validation.value.teamName, 'create');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1944,11 +2135,18 @@ async function handleLaunchTeam(
|
|||
members: savedRequest.members,
|
||||
};
|
||||
|
||||
return wrapTeamHandler('create', () =>
|
||||
getTeamProvisioningService().createTeam(createRequest, (progress) => {
|
||||
sendProvisioningProgress(progressTargetWindow, progress);
|
||||
})
|
||||
);
|
||||
return wrapTeamHandler('create', async () => {
|
||||
launchIoGovernor?.noteLaunchIntent(tn, 'draft-launch');
|
||||
try {
|
||||
return await getTeamProvisioningService().createTeam(createRequest, (progress) => {
|
||||
launchIoGovernor?.noteProvisioningProgress(progress);
|
||||
sendProvisioningProgress(progressTargetWindow, progress);
|
||||
});
|
||||
} catch (error) {
|
||||
noteLaunchIntentFailed(tn, 'draft-launch');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const persistedMeta = await teamMetaStore.getMeta(tn).catch(() => null);
|
||||
|
|
@ -1986,33 +2184,41 @@ async function handleLaunchTeam(
|
|||
const launchLimitContext =
|
||||
typeof payload.limitContext === 'boolean' ? payload.limitContext : persistedMeta?.limitContext;
|
||||
|
||||
return wrapTeamHandler('launch', () => {
|
||||
return wrapTeamHandler('launch', async () => {
|
||||
addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! });
|
||||
return getTeamProvisioningService().launchTeam(
|
||||
{
|
||||
teamName: validatedTeamName.value!,
|
||||
cwd,
|
||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||
providerId: launchProviderId,
|
||||
providerBackendId: launchProviderBackendValidation.value,
|
||||
model: rawLaunchModel,
|
||||
effort: effortValidation.value,
|
||||
fastMode: fastModeValidation.value,
|
||||
limitContext: launchLimitContext,
|
||||
clearContext: payload.clearContext === true ? true : undefined,
|
||||
skipPermissions:
|
||||
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
||||
worktree:
|
||||
typeof payload.worktree === 'string' ? payload.worktree.trim() || undefined : undefined,
|
||||
extraCliArgs:
|
||||
typeof payload.extraCliArgs === 'string'
|
||||
? payload.extraCliArgs.trim() || undefined
|
||||
: undefined,
|
||||
},
|
||||
(progress) => {
|
||||
sendProvisioningProgress(progressTargetWindow, progress);
|
||||
}
|
||||
);
|
||||
launchIoGovernor?.noteLaunchIntent(validatedTeamName.value!, 'launch');
|
||||
try {
|
||||
return await getTeamProvisioningService().launchTeam(
|
||||
{
|
||||
teamName: validatedTeamName.value!,
|
||||
cwd,
|
||||
prompt:
|
||||
typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||
providerId: launchProviderId,
|
||||
providerBackendId: launchProviderBackendValidation.value,
|
||||
model: rawLaunchModel,
|
||||
effort: effortValidation.value,
|
||||
fastMode: fastModeValidation.value,
|
||||
limitContext: launchLimitContext,
|
||||
clearContext: payload.clearContext === true ? true : undefined,
|
||||
skipPermissions:
|
||||
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
||||
worktree:
|
||||
typeof payload.worktree === 'string' ? payload.worktree.trim() || undefined : undefined,
|
||||
extraCliArgs:
|
||||
typeof payload.extraCliArgs === 'string'
|
||||
? payload.extraCliArgs.trim() || undefined
|
||||
: undefined,
|
||||
},
|
||||
(progress) => {
|
||||
launchIoGovernor?.noteProvisioningProgress(progress);
|
||||
sendProvisioningProgress(progressTargetWindow, progress);
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
noteLaunchIntentFailed(validatedTeamName.value!, 'launch');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2289,7 +2495,11 @@ function buildMessageDeliveryText(
|
|||
opts: {
|
||||
actionMode?: AgentActionMode;
|
||||
isLeadRecipient: boolean;
|
||||
memberName?: string;
|
||||
messageId?: string;
|
||||
protocol?: VisibleDirectReplyProtocol;
|
||||
replyRecipient?: string;
|
||||
teamName?: string;
|
||||
}
|
||||
): string {
|
||||
const hiddenBlocks: string[] = [];
|
||||
|
|
@ -2298,22 +2508,49 @@ function buildMessageDeliveryText(
|
|||
hiddenBlocks.push(actionModeBlock);
|
||||
}
|
||||
if (!opts.isLeadRecipient) {
|
||||
const replyRecipient =
|
||||
const rawReplyRecipient =
|
||||
typeof opts.replyRecipient === 'string' && opts.replyRecipient.trim().length > 0
|
||||
? opts.replyRecipient.trim()
|
||||
: 'user';
|
||||
const senderDescriptor = replyRecipient === 'user' ? 'the human user' : `"${replyRecipient}"`;
|
||||
const isUserReplyRecipient = rawReplyRecipient.toLowerCase() === 'user';
|
||||
const replyRecipient = isUserReplyRecipient ? 'user' : rawReplyRecipient;
|
||||
const senderDescriptor = isUserReplyRecipient ? 'the human user' : `"${replyRecipient}"`;
|
||||
const protocol = opts.protocol ?? 'send_message';
|
||||
const canUseAgentTeamsMessageSend =
|
||||
protocol === 'agent_teams_message_send' &&
|
||||
isUserReplyRecipient &&
|
||||
typeof opts.teamName === 'string' &&
|
||||
opts.teamName.trim().length > 0 &&
|
||||
typeof opts.memberName === 'string' &&
|
||||
opts.memberName.trim().length > 0 &&
|
||||
typeof opts.messageId === 'string' &&
|
||||
opts.messageId.trim().length > 0;
|
||||
const replyInstructionLines = canUseAgentTeamsMessageSend
|
||||
? [
|
||||
'CRITICAL: Reply using the Agent Teams MCP message_send tool, not SendMessage.',
|
||||
'Use tool agent-teams_message_send or mcp__agent-teams__message_send, whichever exposed name is available.',
|
||||
`CRITICAL: The tool input must include teamName="${opts.teamName!.trim()}", to="user", from="${opts.memberName!.trim()}", text, summary, source="runtime_delivery", and relayOfMessageId="${opts.messageId!.trim()}".`,
|
||||
'Do NOT answer only with normal assistant text when the Agent Teams message_send tool is available because that will not appear in the UI message thread.',
|
||||
]
|
||||
: [
|
||||
'CRITICAL: Reply using the SendMessage tool, not plain assistant text.',
|
||||
`CRITICAL: The destination must be exactly to="${replyRecipient}".`,
|
||||
'CRITICAL: The SendMessage tool input must use the exact field names `to`, `summary`, and `message`.',
|
||||
'Do NOT answer only with normal assistant text because that will not appear in the UI message thread.',
|
||||
];
|
||||
hiddenBlocks.push(
|
||||
[
|
||||
AGENT_BLOCK_OPEN,
|
||||
`You received a direct message from ${senderDescriptor} via the UI.`,
|
||||
'CRITICAL: Reply using the SendMessage tool, not plain assistant text.',
|
||||
`CRITICAL: The destination must be exactly to="${replyRecipient}".`,
|
||||
'CRITICAL: The SendMessage tool input must use the exact field names `to`, `summary`, and `message`.',
|
||||
'Do NOT answer only with normal assistant text because that will not appear in the UI message thread.',
|
||||
...replyInstructionLines,
|
||||
`Please reply back to recipient "${replyRecipient}" with a short, human-readable answer.`,
|
||||
'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").',
|
||||
...(replyRecipient === 'user'
|
||||
...(canUseAgentTeamsMessageSend
|
||||
? [
|
||||
'If neither Agent Teams MCP message_send tool name is available before any visible-message tool attempt, write exactly the concise reply text as normal assistant text so the runtime can relay it.',
|
||||
]
|
||||
: []),
|
||||
...(isUserReplyRecipient
|
||||
? [
|
||||
'CRITICAL: If the user asks you to check with the lead or another teammate before you can fully answer, FIRST send a short acknowledgement to "user" so the human sees you started (for example: "Принял, сейчас уточню и вернусь с ответом.").',
|
||||
'Only after that first acknowledgement may you message the lead or another teammate.',
|
||||
|
|
@ -2352,35 +2589,47 @@ async function handleGetMessagesPage(
|
|||
|
||||
return wrapTeamHandler('getMessagesPage', async () => {
|
||||
let page: MessagesPage;
|
||||
const notificationContext = await getTeamDataService().getTeamNotificationContext(vTeam.value!);
|
||||
const teamName = vTeam.value!;
|
||||
const scanNotifications = (messagesPage: MessagesPage): void => {
|
||||
const notificationContextPromise: Promise<{ displayName: string; projectPath?: string }> =
|
||||
getTeamDataService()
|
||||
.getTeamNotificationContext(teamName)
|
||||
.catch(() => ({ displayName: teamName }));
|
||||
void notificationContextPromise
|
||||
.then((notificationContext) => {
|
||||
scanTeamMessageNotifications(
|
||||
messagesPage.messages,
|
||||
teamName,
|
||||
notificationContext.displayName,
|
||||
notificationContext.projectPath
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logger.debug(
|
||||
`[teams:getMessagesPage] notification scan skipped team=${teamName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
});
|
||||
};
|
||||
const liveMessages =
|
||||
cursor == null ? getTeamProvisioningService().getLiveLeadProcessMessages(vTeam.value!) : [];
|
||||
cursor == null ? getTeamProvisioningService().getLiveLeadProcessMessages(teamName) : [];
|
||||
|
||||
if (liveMessages.length > 0) {
|
||||
page = await getTeamDataService().getMessagesPage(vTeam.value!, {
|
||||
page = await getTeamDataService().getMessagesPage(teamName, {
|
||||
cursor,
|
||||
limit,
|
||||
liveMessages,
|
||||
});
|
||||
scanTeamMessageNotifications(
|
||||
page.messages,
|
||||
vTeam.value!,
|
||||
notificationContext.displayName,
|
||||
notificationContext.projectPath
|
||||
);
|
||||
scanNotifications(page);
|
||||
return page;
|
||||
}
|
||||
|
||||
const worker = getTeamDataWorkerClient();
|
||||
if (worker.isAvailable()) {
|
||||
try {
|
||||
page = await worker.getMessagesPage(vTeam.value!, { cursor, limit });
|
||||
scanTeamMessageNotifications(
|
||||
page.messages,
|
||||
vTeam.value!,
|
||||
notificationContext.displayName,
|
||||
notificationContext.projectPath
|
||||
);
|
||||
page = await worker.getMessagesPage(teamName, { cursor, limit });
|
||||
scanNotifications(page);
|
||||
return page;
|
||||
} catch (workerErr) {
|
||||
logger.warn(
|
||||
|
|
@ -2391,13 +2640,8 @@ async function handleGetMessagesPage(
|
|||
}
|
||||
}
|
||||
noteHeavyTeamDataWorkerFallback('teams:getMessagesPage');
|
||||
page = await getTeamDataService().getMessagesPage(vTeam.value!, { cursor, limit });
|
||||
scanTeamMessageNotifications(
|
||||
page.messages,
|
||||
vTeam.value!,
|
||||
notificationContext.displayName,
|
||||
notificationContext.projectPath
|
||||
);
|
||||
page = await getTeamDataService().getMessagesPage(teamName, { cursor, limit });
|
||||
scanNotifications(page);
|
||||
return page;
|
||||
});
|
||||
}
|
||||
|
|
@ -2657,22 +2901,37 @@ async function handleSendMessage(
|
|||
typeof payload.from === 'string' && payload.from.trim().length > 0
|
||||
? payload.from.trim()
|
||||
: 'user';
|
||||
const isOpenCodeRecipient =
|
||||
!isLeadRecipient && (await provisioning.isOpenCodeRuntimeRecipient(tn, memberName));
|
||||
const storedFrom = replyRecipient.toLowerCase() === 'user' ? 'user' : replyRecipient;
|
||||
const recipientProviderId = !isLeadRecipient
|
||||
? await provisioning.resolveRuntimeRecipientProviderId(tn, memberName)
|
||||
: undefined;
|
||||
const isOpenCodeRecipient = recipientProviderId === 'opencode';
|
||||
const directReplyProtocol = resolveVisibleDirectReplyProtocol({
|
||||
isLeadRecipient,
|
||||
replyRecipient,
|
||||
...(recipientProviderId ? { providerId: recipientProviderId } : {}),
|
||||
});
|
||||
const inboxMessageId =
|
||||
directReplyProtocol === 'agent_teams_message_send' ? crypto.randomUUID() : undefined;
|
||||
const memberDeliveryText = buildMessageDeliveryText(baseText, {
|
||||
actionMode,
|
||||
isLeadRecipient,
|
||||
memberName,
|
||||
protocol: directReplyProtocol,
|
||||
replyRecipient,
|
||||
teamName: tn,
|
||||
...(inboxMessageId ? { messageId: inboxMessageId } : {}),
|
||||
});
|
||||
const inboxText = isOpenCodeRecipient ? baseText : memberDeliveryText;
|
||||
const result = await getTeamDataService().sendMessage(tn, {
|
||||
member: memberName,
|
||||
text: inboxText,
|
||||
summary: payload.summary,
|
||||
from: payload.from,
|
||||
from: storedFrom,
|
||||
actionMode,
|
||||
source: 'user_sent',
|
||||
taskRefs: validatedTaskRefs.value,
|
||||
...(inboxMessageId ? { messageId: inboxMessageId } : {}),
|
||||
});
|
||||
|
||||
// Teammate inbox relay DISABLED (2026-03-23).
|
||||
|
|
@ -2779,6 +3038,30 @@ async function handleSendMessage(
|
|||
});
|
||||
}
|
||||
|
||||
async function handleGetOpenCodeRuntimeDeliveryStatus(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
messageId: unknown
|
||||
): Promise<IpcResult<OpenCodeRuntimeDeliveryStatus | null>> {
|
||||
const validatedTeamName = validateTeamName(teamName);
|
||||
if (!validatedTeamName.valid) {
|
||||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||||
}
|
||||
if (typeof messageId !== 'string' || messageId.trim().length === 0) {
|
||||
return { success: false, error: 'messageId must be a non-empty string' };
|
||||
}
|
||||
const safeMessageId = messageId.trim();
|
||||
if (safeMessageId.includes('/') || safeMessageId.includes('\\') || safeMessageId.includes('..')) {
|
||||
return { success: false, error: 'Invalid messageId' };
|
||||
}
|
||||
return wrapTeamHandler('getOpenCodeRuntimeDeliveryStatus', async () =>
|
||||
getTeamProvisioningService().getOpenCodeRuntimeDeliveryStatus(
|
||||
validatedTeamName.value!,
|
||||
safeMessageId
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleCreateTask(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
|
|
@ -3307,8 +3590,8 @@ async function handleCreateConfig(
|
|||
});
|
||||
}
|
||||
|
||||
return wrapTeamHandler('createConfig', () =>
|
||||
getTeamDataService().createTeamConfig({
|
||||
return wrapTeamHandler('createConfig', async () => {
|
||||
await getTeamDataService().createTeamConfig({
|
||||
teamName,
|
||||
displayName: payload.displayName?.trim() || undefined,
|
||||
description: payload.description?.trim() || undefined,
|
||||
|
|
@ -3332,8 +3615,9 @@ async function handleCreateConfig(
|
|||
typeof payload.extraCliArgs === 'string' && payload.extraCliArgs.trim()
|
||||
? payload.extraCliArgs.trim()
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
getTeamDataWorkerClient().invalidateTeamConfig(teamName);
|
||||
});
|
||||
}
|
||||
|
||||
function getTeamMemberLogsFinder(): TeamMemberLogsFinder {
|
||||
|
|
@ -3643,8 +3927,28 @@ async function handleRestartMember(
|
|||
if (!validatedMemberName.valid) {
|
||||
return { success: false, error: validatedMemberName.error ?? 'Invalid memberName' };
|
||||
}
|
||||
return wrapTeamHandler('restartMember', async () =>
|
||||
getTeamProvisioningService().restartMember(validatedTeamName.value!, validatedMemberName.value!)
|
||||
return wrapTeamHandler('restartMember', async () => {
|
||||
try {
|
||||
await getTeamProvisioningService().restartMember(
|
||||
validatedTeamName.value!,
|
||||
validatedMemberName.value!
|
||||
);
|
||||
} finally {
|
||||
getTeamDataService().invalidateMessageFeed(validatedTeamName.value!);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRetryFailedOpenCodeSecondaryLanes(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
): Promise<IpcResult<RetryFailedOpenCodeSecondaryLanesResult>> {
|
||||
const validatedTeamName = validateTeamName(teamName);
|
||||
if (!validatedTeamName.valid) {
|
||||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||||
}
|
||||
return wrapTeamHandler('retryFailedOpenCodeSecondaryLanes', async () =>
|
||||
getTeamProvisioningService().retryFailedOpenCodeSecondaryLanes(validatedTeamName.value!)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -3724,7 +4028,14 @@ async function handleGetAllTasks(_event: IpcMainInvokeEvent): Promise<IpcResult<
|
|||
setCurrentMainOp('team:getAllTasks');
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
return await wrapTeamHandler('getAllTasks', () => getTeamDataService().getAllTasks());
|
||||
return await wrapTeamHandler('getAllTasks', () => {
|
||||
const loadFresh = () => getTeamDataService().getAllTasks();
|
||||
return launchIoGovernor
|
||||
? launchIoGovernor.runSummaryOperation('teams:getAllTasks', loadFresh, {
|
||||
clone: cloneLaunchIoGovernorPayload,
|
||||
})
|
||||
: loadFresh();
|
||||
});
|
||||
} finally {
|
||||
const ms = Date.now() - startedAt;
|
||||
if (ms >= 1500) {
|
||||
|
|
@ -4021,6 +4332,7 @@ async function handleReplaceMembers(
|
|||
: [];
|
||||
|
||||
await teamDataService.replaceMembers(tn, { members });
|
||||
teamDataService.invalidateMessageFeed(tn);
|
||||
|
||||
if (!isTeamAlive) {
|
||||
return;
|
||||
|
|
@ -4324,6 +4636,7 @@ async function handleShowMessageNotification(
|
|||
summary: d.summary ?? `${d.from} → ${d.to ?? 'team'}`,
|
||||
body: d.body,
|
||||
dedupeKey,
|
||||
target: d.target,
|
||||
suppressToast: d.suppressToast,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
|
|
|||
|
|
@ -39,7 +39,11 @@ import {
|
|||
type SessionsPaginationOptions,
|
||||
type WorktreeSource,
|
||||
} from '@main/types';
|
||||
import { analyzeSessionFileMetadata, extractCwd } from '@main/utils/jsonl';
|
||||
import {
|
||||
analyzeSessionFileMetadata,
|
||||
extractCwd,
|
||||
type SessionFileMetadata,
|
||||
} from '@main/utils/jsonl';
|
||||
import {
|
||||
buildSessionPath,
|
||||
buildSubagentsPath,
|
||||
|
|
@ -60,6 +64,7 @@ import { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvid
|
|||
import { ProjectPathResolver } from './ProjectPathResolver';
|
||||
import { resolveProjectStorageDir as resolveProjectStorageDirFromCandidates } from './projectStorageDir';
|
||||
import { SessionContentFilter } from './SessionContentFilter';
|
||||
import { type SessionFileSignature, SessionMetadataIndex } from './SessionMetadataIndex';
|
||||
import { SessionSearcher } from './SessionSearcher';
|
||||
import { SubagentLocator } from './SubagentLocator';
|
||||
import { subprojectRegistry } from './SubprojectRegistry';
|
||||
|
|
@ -77,10 +82,24 @@ const SEARCH_PROJECT_CACHE_TTL_MS = 30_000;
|
|||
// for lookups and navigation; a small cap preserves that behavior without huge payloads.
|
||||
const MAX_SESSION_IDS_EXPORTED = 200;
|
||||
|
||||
export interface ProjectScannerOptions {
|
||||
/**
|
||||
* Directory for the persisted session-list metadata index.
|
||||
* Defaults to a sibling of the configured projects directory.
|
||||
*/
|
||||
sessionIndexDir?: string;
|
||||
/** Test hook: set to 0 to persist index files without debounce. */
|
||||
sessionIndexPersistDelayMs?: number;
|
||||
}
|
||||
|
||||
function splitPathSegments(value: string): string[] {
|
||||
return value.split(/[/\\]+/).filter(Boolean);
|
||||
}
|
||||
|
||||
function getDefaultSessionIndexDir(projectsDir: string): string {
|
||||
return path.join(path.dirname(projectsDir), '.agent-teams-session-index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast, zero-I/O worktree detection based on path patterns only.
|
||||
* Used by scanWithWorktreeGrouping to provide accurate worktree metadata
|
||||
|
|
@ -164,8 +183,14 @@ export class ProjectScanner {
|
|||
private readonly subagentLocator: SubagentLocator;
|
||||
private readonly sessionSearcher: SessionSearcher;
|
||||
private readonly projectPathResolver: ProjectPathResolver;
|
||||
private readonly sessionMetadataIndex: SessionMetadataIndex | null;
|
||||
|
||||
constructor(projectsDir?: string, todosDir?: string, fsProvider?: FileSystemProvider) {
|
||||
constructor(
|
||||
projectsDir?: string,
|
||||
todosDir?: string,
|
||||
fsProvider?: FileSystemProvider,
|
||||
options?: ProjectScannerOptions
|
||||
) {
|
||||
this.projectsDir = projectsDir ?? getProjectsBasePath();
|
||||
this.todosDir = todosDir ?? getTodosBasePath();
|
||||
this.fsProvider = fsProvider ?? new LocalFileSystemProvider();
|
||||
|
|
@ -175,6 +200,13 @@ export class ProjectScanner {
|
|||
this.subagentLocator = new SubagentLocator(this.projectsDir, this.fsProvider);
|
||||
this.sessionSearcher = new SessionSearcher(this.projectsDir, this.fsProvider);
|
||||
this.projectPathResolver = new ProjectPathResolver(this.projectsDir, this.fsProvider);
|
||||
this.sessionMetadataIndex =
|
||||
this.fsProvider.type === 'local'
|
||||
? new SessionMetadataIndex({
|
||||
rootDir: options?.sessionIndexDir ?? getDefaultSessionIndexDir(this.projectsDir),
|
||||
persistDelayMs: options?.sessionIndexPersistDelayMs,
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
|
@ -643,7 +675,14 @@ export class ProjectScanner {
|
|||
}
|
||||
|
||||
const entries = await this.fsProvider.readdir(projectPath);
|
||||
let sessionFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'));
|
||||
const allSessionFiles = entries.filter(
|
||||
(entry) => entry.isFile() && entry.name.endsWith('.jsonl')
|
||||
);
|
||||
await this.pruneSessionMetadataIndex(
|
||||
projectPath,
|
||||
new Set(allSessionFiles.map((file) => path.join(projectPath, file.name)))
|
||||
);
|
||||
let sessionFiles = allSessionFiles;
|
||||
|
||||
// Filter to only sessions belonging to this subproject
|
||||
if (sessionFilter) {
|
||||
|
|
@ -733,7 +772,14 @@ export class ProjectScanner {
|
|||
|
||||
// Step 1: Get all session files with their timestamps (lightweight stat calls)
|
||||
const entries = await this.fsProvider.readdir(projectPath);
|
||||
let sessionFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'));
|
||||
const allSessionFiles = entries.filter(
|
||||
(entry) => entry.isFile() && entry.name.endsWith('.jsonl')
|
||||
);
|
||||
await this.pruneSessionMetadataIndex(
|
||||
projectPath,
|
||||
new Set(allSessionFiles.map((file) => path.join(projectPath, file.name)))
|
||||
);
|
||||
let sessionFiles = allSessionFiles;
|
||||
|
||||
// Filter to only sessions belonging to this subproject
|
||||
if (sessionFilter) {
|
||||
|
|
@ -967,18 +1013,14 @@ export class ProjectScanner {
|
|||
const effectiveMtime = prefetchedMtimeMs ?? stats?.mtimeMs ?? Date.now();
|
||||
const effectiveSize = prefetchedSize ?? stats?.size ?? -1;
|
||||
const birthtimeMs = prefetchedBirthtimeMs ?? stats?.birthtimeMs ?? effectiveMtime;
|
||||
const cachedMetadata = this.sessionMetadataCache.get(filePath);
|
||||
const metadata =
|
||||
cachedMetadata?.mtimeMs === effectiveMtime && cachedMetadata.size === effectiveSize
|
||||
? cachedMetadata.metadata
|
||||
: await analyzeSessionFileMetadata(filePath, this.fsProvider);
|
||||
if (cachedMetadata?.mtimeMs !== effectiveMtime || cachedMetadata.size !== effectiveSize) {
|
||||
this.sessionMetadataCache.set(filePath, {
|
||||
mtimeMs: effectiveMtime,
|
||||
size: effectiveSize,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
const signature = this.buildSessionFileSignature(
|
||||
sessionId,
|
||||
filePath,
|
||||
effectiveMtime,
|
||||
effectiveSize,
|
||||
birthtimeMs
|
||||
);
|
||||
const metadata = await this.getSessionFileMetadata(signature);
|
||||
|
||||
// Check for subagents (todoData skipped here — loaded on-demand in detail view)
|
||||
const hasSubagents = await this.subagentLocator.hasSubagents(projectId, sessionId);
|
||||
|
|
@ -1035,28 +1077,25 @@ export class ProjectScanner {
|
|||
const effectiveMtime = prefetchedMtimeMs ?? stats?.mtimeMs ?? Date.now();
|
||||
const effectiveSize = prefetchedSize ?? stats?.size ?? -1;
|
||||
const birthtimeMs = prefetchedBirthtimeMs ?? stats?.birthtimeMs ?? effectiveMtime;
|
||||
let metadata: Awaited<ReturnType<typeof analyzeSessionFileMetadata>>;
|
||||
const cachedMetadata = this.sessionMetadataCache.get(filePath);
|
||||
if (cachedMetadata?.mtimeMs === effectiveMtime && cachedMetadata.size === effectiveSize) {
|
||||
metadata = cachedMetadata.metadata;
|
||||
} else {
|
||||
try {
|
||||
metadata = await analyzeSessionFileMetadata(filePath, this.fsProvider);
|
||||
this.sessionMetadataCache.set(filePath, {
|
||||
mtimeMs: effectiveMtime,
|
||||
size: effectiveSize,
|
||||
metadata,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to analyze session metadata for ${filePath}:`, error);
|
||||
metadata = {
|
||||
firstUserMessage: null,
|
||||
messageCount: 0,
|
||||
isOngoing: false,
|
||||
gitBranch: null,
|
||||
model: null,
|
||||
};
|
||||
}
|
||||
let metadata: SessionFileMetadata;
|
||||
const signature = this.buildSessionFileSignature(
|
||||
sessionId,
|
||||
filePath,
|
||||
effectiveMtime,
|
||||
effectiveSize,
|
||||
birthtimeMs
|
||||
);
|
||||
try {
|
||||
metadata = await this.getSessionFileMetadata(signature);
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to analyze session metadata for ${filePath}:`, error);
|
||||
metadata = {
|
||||
firstUserMessage: null,
|
||||
messageCount: 0,
|
||||
isOngoing: false,
|
||||
gitBranch: null,
|
||||
model: null,
|
||||
};
|
||||
}
|
||||
const metadataLevel: SessionMetadataLevel = 'light';
|
||||
const previewTimestampMs = this.parseTimestampMs(metadata.firstUserMessage?.timestamp);
|
||||
|
|
@ -1421,6 +1460,94 @@ export class ProjectScanner {
|
|||
}
|
||||
}
|
||||
|
||||
async flushSessionMetadataIndexForTesting(): Promise<void> {
|
||||
await this.sessionMetadataIndex?.flushForTesting();
|
||||
}
|
||||
|
||||
private buildSessionFileSignature(
|
||||
sessionId: string,
|
||||
filePath: string,
|
||||
mtimeMs: number,
|
||||
size: number,
|
||||
birthtimeMs?: number
|
||||
): SessionFileSignature {
|
||||
return {
|
||||
sessionId,
|
||||
filePath,
|
||||
mtimeMs,
|
||||
size,
|
||||
birthtimeMs,
|
||||
};
|
||||
}
|
||||
|
||||
private async getSessionFileMetadata(
|
||||
signature: SessionFileSignature
|
||||
): Promise<SessionFileMetadata> {
|
||||
const cachedMetadata = this.sessionMetadataCache.get(signature.filePath);
|
||||
if (cachedMetadata?.mtimeMs === signature.mtimeMs && cachedMetadata.size === signature.size) {
|
||||
return cachedMetadata.metadata;
|
||||
}
|
||||
|
||||
let indexedMetadata: SessionFileMetadata | undefined;
|
||||
if (this.sessionMetadataIndex) {
|
||||
try {
|
||||
indexedMetadata = await this.sessionMetadataIndex.getMetadata(signature);
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Failed to read session metadata index for ${signature.filePath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (indexedMetadata) {
|
||||
this.sessionMetadataCache.set(signature.filePath, {
|
||||
mtimeMs: signature.mtimeMs,
|
||||
size: signature.size,
|
||||
metadata: indexedMetadata,
|
||||
});
|
||||
return indexedMetadata;
|
||||
}
|
||||
|
||||
const metadata = await analyzeSessionFileMetadata(signature.filePath, this.fsProvider);
|
||||
this.sessionMetadataCache.set(signature.filePath, {
|
||||
mtimeMs: signature.mtimeMs,
|
||||
size: signature.size,
|
||||
metadata,
|
||||
});
|
||||
if (this.sessionMetadataIndex) {
|
||||
try {
|
||||
await this.sessionMetadataIndex.setMetadata(signature, metadata);
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Failed to update session metadata index for ${signature.filePath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private async pruneSessionMetadataIndex(
|
||||
projectStorageDir: string,
|
||||
existingFilePaths: Set<string>
|
||||
): Promise<void> {
|
||||
if (!this.sessionMetadataIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sessionMetadataIndex.pruneMissing(projectStorageDir, existingFilePaths);
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Failed to prune session metadata index for ${projectStorageDir}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve best-available file timestamps from directory entry metadata or stat fallback.
|
||||
*/
|
||||
|
|
@ -1545,11 +1672,39 @@ export class ProjectScanner {
|
|||
const stats = hasPrefetched ? null : await this.fsProvider.stat(filePath);
|
||||
const effectiveMtime = mtimeMs ?? stats?.mtimeMs ?? Date.now();
|
||||
const effectiveSize = size ?? stats?.size ?? -1;
|
||||
const signature = this.buildSessionFileSignature(
|
||||
extractSessionId(path.basename(filePath)),
|
||||
filePath,
|
||||
effectiveMtime,
|
||||
effectiveSize,
|
||||
stats?.birthtimeMs
|
||||
);
|
||||
const cached = this.contentPresenceCache.get(filePath);
|
||||
if (cached?.mtimeMs === effectiveMtime && cached.size === effectiveSize) {
|
||||
return cached.hasContent;
|
||||
}
|
||||
|
||||
let indexed: boolean | undefined;
|
||||
if (this.sessionMetadataIndex) {
|
||||
try {
|
||||
indexed = await this.sessionMetadataIndex.getContentPresence(signature);
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Failed to read content-presence index for ${filePath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (typeof indexed === 'boolean') {
|
||||
this.contentPresenceCache.set(filePath, {
|
||||
mtimeMs: effectiveMtime,
|
||||
size: effectiveSize,
|
||||
hasContent: indexed,
|
||||
});
|
||||
return indexed;
|
||||
}
|
||||
|
||||
const hasContent = await this.sessionContentFilter.hasNonNoiseMessages(
|
||||
filePath,
|
||||
this.fsProvider
|
||||
|
|
@ -1559,6 +1714,17 @@ export class ProjectScanner {
|
|||
size: effectiveSize,
|
||||
hasContent,
|
||||
});
|
||||
if (this.sessionMetadataIndex) {
|
||||
try {
|
||||
await this.sessionMetadataIndex.setContentPresence(signature, hasContent);
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Failed to update content-presence index for ${filePath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return hasContent;
|
||||
} catch {
|
||||
return false;
|
||||
|
|
|
|||
511
src/main/services/discovery/SessionMetadataIndex.ts
Normal file
511
src/main/services/discovery/SessionMetadataIndex.ts
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
/**
|
||||
* SessionMetadataIndex - persisted read-through cache for session listing metadata.
|
||||
*
|
||||
* The index is never a source of truth. Callers may use an entry only when the
|
||||
* current file signature (mtimeMs + size) matches the indexed signature.
|
||||
*/
|
||||
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { SessionFileMetadata } from '@main/utils/jsonl';
|
||||
|
||||
const logger = createLogger('Discovery:SessionMetadataIndex');
|
||||
|
||||
const SESSION_METADATA_INDEX_SCHEMA_VERSION = 1;
|
||||
const DEFAULT_PERSIST_DELAY_MS = 250;
|
||||
|
||||
export interface SessionMetadataIndexOptions {
|
||||
rootDir: string;
|
||||
persistDelayMs?: number;
|
||||
}
|
||||
|
||||
export interface SessionFileSignature {
|
||||
sessionId: string;
|
||||
filePath: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
birthtimeMs?: number;
|
||||
}
|
||||
|
||||
interface SessionMetadataIndexEntry extends SessionFileSignature {
|
||||
hasContent?: boolean;
|
||||
metadata?: SessionFileMetadata;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface SessionMetadataIndexFile {
|
||||
schemaVersion: number;
|
||||
projectStorageDir: string;
|
||||
updatedAt: number;
|
||||
sessions: Record<string, SessionMetadataIndexEntry>;
|
||||
}
|
||||
|
||||
interface LoadedProjectIndex {
|
||||
file: SessionMetadataIndexFile;
|
||||
dirty: boolean;
|
||||
persistTimer: ReturnType<typeof setTimeout> | null;
|
||||
persistPromise: Promise<void> | null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasOwnProperty(value: Record<string, unknown>, property: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(value, property);
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function isNonNegativeFiniteNumber(value: unknown): value is number {
|
||||
return isFiniteNumber(value) && value >= 0;
|
||||
}
|
||||
|
||||
function isNonNegativeInteger(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
function sanitizePathSegment(value: string): string {
|
||||
const replaced = value.replace(/[^a-zA-Z0-9._-]/g, '-');
|
||||
let start = 0;
|
||||
let end = replaced.length;
|
||||
while (start < end && replaced[start] === '-') {
|
||||
start += 1;
|
||||
}
|
||||
while (end > start && replaced[end - 1] === '-') {
|
||||
end -= 1;
|
||||
}
|
||||
const sanitized = replaced.slice(start, end);
|
||||
return sanitized.length > 0 ? sanitized.slice(0, 80) : 'project';
|
||||
}
|
||||
|
||||
function hashProjectStorageDir(projectStorageDir: string): string {
|
||||
return crypto.createHash('sha256').update(projectStorageDir).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
function createEmptyIndex(projectStorageDir: string): SessionMetadataIndexFile {
|
||||
return {
|
||||
schemaVersion: SESSION_METADATA_INDEX_SCHEMA_VERSION,
|
||||
projectStorageDir,
|
||||
updatedAt: Date.now(),
|
||||
sessions: {},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFirstUserMessage(
|
||||
value: unknown
|
||||
): SessionFileMetadata['firstUserMessage'] | undefined {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
if (isRecord(value) && typeof value.text === 'string' && typeof value.timestamp === 'string') {
|
||||
return {
|
||||
text: value.text,
|
||||
timestamp: value.timestamp,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizePhaseBreakdown(
|
||||
value: unknown
|
||||
): SessionFileMetadata['phaseBreakdown'] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const phases: NonNullable<SessionFileMetadata['phaseBreakdown']> = [];
|
||||
for (const phase of value) {
|
||||
if (
|
||||
!isRecord(phase) ||
|
||||
!isNonNegativeInteger(phase.phaseNumber) ||
|
||||
!isNonNegativeInteger(phase.contribution) ||
|
||||
!isNonNegativeInteger(phase.peakTokens) ||
|
||||
(hasOwnProperty(phase, 'postCompaction') && !isNonNegativeInteger(phase.postCompaction))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
phases.push({
|
||||
phaseNumber: phase.phaseNumber,
|
||||
contribution: phase.contribution,
|
||||
peakTokens: phase.peakTokens,
|
||||
...(isNonNegativeInteger(phase.postCompaction)
|
||||
? { postCompaction: phase.postCompaction }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
return phases;
|
||||
}
|
||||
|
||||
function normalizeMetadata(value: unknown): SessionFileMetadata | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const firstUserMessage = normalizeFirstUserMessage(value.firstUserMessage);
|
||||
if (
|
||||
firstUserMessage === undefined ||
|
||||
!isNonNegativeInteger(value.messageCount) ||
|
||||
typeof value.isOngoing !== 'boolean' ||
|
||||
!(value.gitBranch === null || typeof value.gitBranch === 'string')
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const metadata: SessionFileMetadata = {
|
||||
firstUserMessage,
|
||||
messageCount: value.messageCount,
|
||||
isOngoing: value.isOngoing,
|
||||
gitBranch: value.gitBranch,
|
||||
};
|
||||
|
||||
if (hasOwnProperty(value, 'model')) {
|
||||
if (!(value.model === null || typeof value.model === 'string')) {
|
||||
return undefined;
|
||||
}
|
||||
metadata.model = value.model;
|
||||
}
|
||||
if (hasOwnProperty(value, 'contextConsumption')) {
|
||||
if (!isNonNegativeInteger(value.contextConsumption)) {
|
||||
return undefined;
|
||||
}
|
||||
metadata.contextConsumption = value.contextConsumption;
|
||||
}
|
||||
if (hasOwnProperty(value, 'compactionCount')) {
|
||||
if (!isNonNegativeInteger(value.compactionCount)) {
|
||||
return undefined;
|
||||
}
|
||||
metadata.compactionCount = value.compactionCount;
|
||||
}
|
||||
|
||||
if (hasOwnProperty(value, 'phaseBreakdown')) {
|
||||
const phaseBreakdown = normalizePhaseBreakdown(value.phaseBreakdown);
|
||||
if (!phaseBreakdown) {
|
||||
return undefined;
|
||||
}
|
||||
metadata.phaseBreakdown = phaseBreakdown;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function normalizeEntry(value: unknown): SessionMetadataIndexEntry | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
typeof value.sessionId !== 'string' ||
|
||||
typeof value.filePath !== 'string' ||
|
||||
!isNonNegativeFiniteNumber(value.mtimeMs) ||
|
||||
!isNonNegativeInteger(value.size) ||
|
||||
!isNonNegativeFiniteNumber(value.updatedAt)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry: SessionMetadataIndexEntry = {
|
||||
sessionId: value.sessionId,
|
||||
filePath: value.filePath,
|
||||
mtimeMs: value.mtimeMs,
|
||||
size: value.size,
|
||||
updatedAt: value.updatedAt,
|
||||
};
|
||||
|
||||
if (hasOwnProperty(value, 'birthtimeMs')) {
|
||||
if (!isNonNegativeFiniteNumber(value.birthtimeMs)) {
|
||||
return null;
|
||||
}
|
||||
entry.birthtimeMs = value.birthtimeMs;
|
||||
}
|
||||
if (hasOwnProperty(value, 'hasContent')) {
|
||||
if (typeof value.hasContent !== 'boolean') {
|
||||
return null;
|
||||
}
|
||||
entry.hasContent = value.hasContent;
|
||||
}
|
||||
const metadata = normalizeMetadata(value.metadata);
|
||||
if (metadata) {
|
||||
entry.metadata = metadata;
|
||||
} else if (hasOwnProperty(value, 'metadata')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
function normalizeIndexFile(
|
||||
value: unknown,
|
||||
projectStorageDir: string
|
||||
): SessionMetadataIndexFile | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
value.schemaVersion !== SESSION_METADATA_INDEX_SCHEMA_VERSION ||
|
||||
value.projectStorageDir !== projectStorageDir ||
|
||||
!isRecord(value.sessions)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessions: Record<string, SessionMetadataIndexEntry> = {};
|
||||
for (const [key, rawEntry] of Object.entries(value.sessions)) {
|
||||
const entry = normalizeEntry(rawEntry);
|
||||
if (key === entry?.filePath) {
|
||||
sessions[key] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: SESSION_METADATA_INDEX_SCHEMA_VERSION,
|
||||
projectStorageDir,
|
||||
updatedAt: isNonNegativeFiniteNumber(value.updatedAt) ? value.updatedAt : Date.now(),
|
||||
sessions,
|
||||
};
|
||||
}
|
||||
|
||||
function isFreshEntry(
|
||||
entry: SessionMetadataIndexEntry | undefined,
|
||||
signature: Pick<SessionFileSignature, 'sessionId' | 'filePath' | 'mtimeMs' | 'size'>
|
||||
): entry is SessionMetadataIndexEntry {
|
||||
return (
|
||||
Boolean(entry) &&
|
||||
entry!.sessionId === signature.sessionId &&
|
||||
entry!.filePath === signature.filePath &&
|
||||
entry!.mtimeMs === signature.mtimeMs &&
|
||||
entry!.size === signature.size
|
||||
);
|
||||
}
|
||||
|
||||
export class SessionMetadataIndex {
|
||||
private readonly rootDir: string;
|
||||
private readonly persistDelayMs: number;
|
||||
private readonly indexes = new Map<string, LoadedProjectIndex>();
|
||||
private readonly loads = new Map<string, Promise<LoadedProjectIndex>>();
|
||||
|
||||
constructor(options: SessionMetadataIndexOptions) {
|
||||
this.rootDir = options.rootDir;
|
||||
this.persistDelayMs = options.persistDelayMs ?? DEFAULT_PERSIST_DELAY_MS;
|
||||
}
|
||||
|
||||
static getIndexPath(rootDir: string, projectStorageDir: string): string {
|
||||
const basename = sanitizePathSegment(path.basename(projectStorageDir));
|
||||
const hash = hashProjectStorageDir(projectStorageDir);
|
||||
return path.join(rootDir, `${basename}-${hash}.json`);
|
||||
}
|
||||
|
||||
async getContentPresence(signature: SessionFileSignature): Promise<boolean | undefined> {
|
||||
const index = await this.loadProjectIndex(path.dirname(signature.filePath));
|
||||
const entry = index.file.sessions[signature.filePath];
|
||||
if (!isFreshEntry(entry, signature)) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof entry.hasContent === 'boolean' ? entry.hasContent : undefined;
|
||||
}
|
||||
|
||||
async setContentPresence(signature: SessionFileSignature, hasContent: boolean): Promise<void> {
|
||||
const projectStorageDir = path.dirname(signature.filePath);
|
||||
const index = await this.loadProjectIndex(projectStorageDir);
|
||||
const entry = this.getOrCreateEntry(index.file, signature);
|
||||
entry.hasContent = hasContent;
|
||||
entry.updatedAt = Date.now();
|
||||
this.markDirty(projectStorageDir, index);
|
||||
}
|
||||
|
||||
async getMetadata(signature: SessionFileSignature): Promise<SessionFileMetadata | undefined> {
|
||||
const index = await this.loadProjectIndex(path.dirname(signature.filePath));
|
||||
const entry = index.file.sessions[signature.filePath];
|
||||
if (!isFreshEntry(entry, signature)) {
|
||||
return undefined;
|
||||
}
|
||||
return entry.metadata;
|
||||
}
|
||||
|
||||
async setMetadata(signature: SessionFileSignature, metadata: SessionFileMetadata): Promise<void> {
|
||||
const projectStorageDir = path.dirname(signature.filePath);
|
||||
const index = await this.loadProjectIndex(projectStorageDir);
|
||||
const entry = this.getOrCreateEntry(index.file, signature);
|
||||
entry.metadata = metadata;
|
||||
entry.updatedAt = Date.now();
|
||||
this.markDirty(projectStorageDir, index);
|
||||
}
|
||||
|
||||
async pruneMissing(projectStorageDir: string, existingFilePaths: Set<string>): Promise<void> {
|
||||
const index = await this.loadProjectIndex(projectStorageDir);
|
||||
let changed = false;
|
||||
for (const filePath of Object.keys(index.file.sessions)) {
|
||||
if (!existingFilePaths.has(filePath)) {
|
||||
delete index.file.sessions[filePath];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
this.markDirty(projectStorageDir, index);
|
||||
}
|
||||
}
|
||||
|
||||
async flushForTesting(): Promise<void> {
|
||||
for (const [projectStorageDir, index] of this.indexes.entries()) {
|
||||
if (index.persistTimer) {
|
||||
clearTimeout(index.persistTimer);
|
||||
index.persistTimer = null;
|
||||
}
|
||||
|
||||
while (index.dirty || index.persistPromise) {
|
||||
if (index.dirty) {
|
||||
this.startPersist(projectStorageDir, index);
|
||||
}
|
||||
await (index.persistPromise ?? Promise.resolve());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getOrCreateEntry(
|
||||
index: SessionMetadataIndexFile,
|
||||
signature: SessionFileSignature
|
||||
): SessionMetadataIndexEntry {
|
||||
const existing = index.sessions[signature.filePath];
|
||||
if (isFreshEntry(existing, signature)) {
|
||||
if (isNonNegativeFiniteNumber(signature.birthtimeMs)) {
|
||||
existing.birthtimeMs = signature.birthtimeMs;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
const entry: SessionMetadataIndexEntry = {
|
||||
sessionId: signature.sessionId,
|
||||
filePath: signature.filePath,
|
||||
mtimeMs: signature.mtimeMs,
|
||||
size: signature.size,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
if (isNonNegativeFiniteNumber(signature.birthtimeMs)) {
|
||||
entry.birthtimeMs = signature.birthtimeMs;
|
||||
}
|
||||
index.sessions[signature.filePath] = entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
private async loadProjectIndex(projectStorageDir: string): Promise<LoadedProjectIndex> {
|
||||
const cached = this.indexes.get(projectStorageDir);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const existingLoad = this.loads.get(projectStorageDir);
|
||||
if (existingLoad) {
|
||||
return existingLoad;
|
||||
}
|
||||
|
||||
const load = this.readProjectIndex(projectStorageDir).finally(() => {
|
||||
this.loads.delete(projectStorageDir);
|
||||
});
|
||||
this.loads.set(projectStorageDir, load);
|
||||
return load;
|
||||
}
|
||||
|
||||
private async readProjectIndex(projectStorageDir: string): Promise<LoadedProjectIndex> {
|
||||
const indexPath = SessionMetadataIndex.getIndexPath(this.rootDir, projectStorageDir);
|
||||
let file = createEmptyIndex(projectStorageDir);
|
||||
try {
|
||||
const raw = await fs.readFile(indexPath, 'utf8');
|
||||
const parsed = normalizeIndexFile(JSON.parse(raw), projectStorageDir);
|
||||
if (parsed) {
|
||||
file = parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
const code = isRecord(error) ? error.code : undefined;
|
||||
if (code !== 'ENOENT') {
|
||||
logger.debug(
|
||||
`Ignoring unreadable session metadata index ${indexPath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const loaded: LoadedProjectIndex = {
|
||||
file,
|
||||
dirty: false,
|
||||
persistTimer: null,
|
||||
persistPromise: null,
|
||||
};
|
||||
this.indexes.set(projectStorageDir, loaded);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
private markDirty(projectStorageDir: string, index: LoadedProjectIndex): void {
|
||||
index.file.updatedAt = Date.now();
|
||||
index.dirty = true;
|
||||
|
||||
if (this.persistDelayMs <= 0) {
|
||||
this.startPersist(projectStorageDir, index);
|
||||
return;
|
||||
}
|
||||
|
||||
if (index.persistTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
index.persistTimer = setTimeout(() => {
|
||||
index.persistTimer = null;
|
||||
this.startPersist(projectStorageDir, index);
|
||||
}, this.persistDelayMs);
|
||||
index.persistTimer.unref?.();
|
||||
}
|
||||
|
||||
private startPersist(projectStorageDir: string, index: LoadedProjectIndex): void {
|
||||
if (index.persistPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promise = this.persistProjectIndex(projectStorageDir, index).finally(() => {
|
||||
if (index.persistPromise === promise) {
|
||||
index.persistPromise = null;
|
||||
}
|
||||
if (index.dirty) {
|
||||
this.markDirty(projectStorageDir, index);
|
||||
}
|
||||
});
|
||||
index.persistPromise = promise;
|
||||
}
|
||||
|
||||
private async persistProjectIndex(
|
||||
projectStorageDir: string,
|
||||
index: LoadedProjectIndex
|
||||
): Promise<void> {
|
||||
if (!index.dirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const indexPath = SessionMetadataIndex.getIndexPath(this.rootDir, projectStorageDir);
|
||||
const tmpPath = `${indexPath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
|
||||
const serialized = `${JSON.stringify(index.file)}\n`;
|
||||
index.dirty = false;
|
||||
|
||||
try {
|
||||
await fs.mkdir(this.rootDir, { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(tmpPath, serialized, { encoding: 'utf8', mode: 0o600 });
|
||||
await fs.rename(tmpPath, indexPath);
|
||||
} catch (error) {
|
||||
try {
|
||||
await fs.unlink(tmpPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
logger.debug(
|
||||
`Failed to persist session metadata index ${indexPath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ import { randomUUID } from 'crypto';
|
|||
import { type ExtractedToolResult } from '../analysis/ToolResultExtractor';
|
||||
|
||||
import type { TriggerColor } from '@shared/constants/triggerColors';
|
||||
import type { TeamEventType } from '@shared/types/notifications';
|
||||
import type { NotificationTarget, TeamEventType } from '@shared/types/notifications';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
|
|
@ -54,6 +54,8 @@ export interface DetectedError {
|
|||
category?: 'error' | 'team';
|
||||
/** For team notifications: specific event sub-type */
|
||||
teamEventType?: TeamEventType;
|
||||
/** Structured destination for notification clicks. */
|
||||
target?: NotificationTarget;
|
||||
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
|
||||
dedupeKey?: string;
|
||||
/** Additional context about the error */
|
||||
|
|
|
|||
|
|
@ -1008,7 +1008,8 @@ export class FileWatcher extends EventEmitter {
|
|||
if (
|
||||
relative === 'config.json' ||
|
||||
relative === 'kanban-state.json' ||
|
||||
relative === 'team.meta.json'
|
||||
relative === 'team.meta.json' ||
|
||||
relative === 'members.meta.json'
|
||||
) {
|
||||
const event: TeamChangeEvent = {
|
||||
type: 'config',
|
||||
|
|
|
|||
|
|
@ -16,15 +16,19 @@
|
|||
*/
|
||||
|
||||
import { getAppIconPath } from '@main/utils/appIcon';
|
||||
import { getHomeDir } from '@main/utils/pathDecoder';
|
||||
import { getAppDataPath, getHomeDir, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
|
||||
import { stripMarkdown } from '@main/utils/textFormatting';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { getMemberColorByName, MEMBER_COLOR_HUE } from '@shared/constants/memberColors';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { Notification as ElectronNotification } from 'electron';
|
||||
import { nativeImage, Notification as ElectronNotification } from 'electron';
|
||||
import { EventEmitter } from 'events';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
|
||||
import { type DetectedError } from '../error/ErrorMessageBuilder';
|
||||
|
||||
|
|
@ -101,6 +105,16 @@ const LEGACY_NOTIFICATION_FILENAMES = [
|
|||
const LEGACY_NOTIFICATION_PATHS = LEGACY_NOTIFICATION_FILENAMES.map((filename) =>
|
||||
path.join(getHomeDir(), '.claude', filename)
|
||||
);
|
||||
const SENDER_ICON_CACHE = new Map<string, NotificationConstructorOptions['icon'] | undefined>();
|
||||
const WINDOWS_TOAST_AVATAR_CACHE = new Map<string, string | undefined>();
|
||||
const PARTICIPANT_AVATAR_COUNT = 13;
|
||||
const LEAD_PARTICIPANT_AVATAR_NUMBER = 1;
|
||||
|
||||
interface TeamNotificationAvatarMember {
|
||||
name: string;
|
||||
removedAt?: number | string | null;
|
||||
agentType?: string;
|
||||
}
|
||||
|
||||
interface LegacyNotificationData {
|
||||
path: string;
|
||||
|
|
@ -123,6 +137,412 @@ function getNotificationClass(): NotificationClass | null {
|
|||
return (ElectronNotification as NotificationClass | undefined) ?? null;
|
||||
}
|
||||
|
||||
function getNativeImage(): typeof nativeImage | null {
|
||||
return nativeImage && typeof nativeImage.createFromPath === 'function' ? nativeImage : null;
|
||||
}
|
||||
|
||||
function hashStringToIndex(str: string): number {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
function getParticipantAvatarNumberByIndex(index: number): number {
|
||||
const normalized =
|
||||
((Math.trunc(index) % PARTICIPANT_AVATAR_COUNT) + PARTICIPANT_AVATAR_COUNT) %
|
||||
PARTICIPANT_AVATAR_COUNT;
|
||||
return normalized + 1;
|
||||
}
|
||||
|
||||
function getFallbackParticipantAvatarNumber(name: string): number {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
if (normalized === 'team-lead' || normalized === 'lead') {
|
||||
return LEAD_PARTICIPANT_AVATAR_NUMBER;
|
||||
}
|
||||
return getParticipantAvatarNumberByIndex(hashStringToIndex(normalized));
|
||||
}
|
||||
|
||||
function getParticipantAvatarNumber(
|
||||
sender: string,
|
||||
members: readonly TeamNotificationAvatarMember[]
|
||||
): number {
|
||||
const senderName = sender.trim();
|
||||
if (!senderName) return getFallbackParticipantAvatarNumber(sender);
|
||||
|
||||
const map = new Map<string, number>();
|
||||
const activeMembers = members.filter((member) => !member.removedAt);
|
||||
const leadMembers = activeMembers.filter((member) => isLeadMember(member));
|
||||
const teammateMembers = activeMembers.filter((member) => !isLeadMember(member));
|
||||
|
||||
for (const [index, member] of leadMembers.entries()) {
|
||||
map.set(
|
||||
member.name,
|
||||
index === 0 ? LEAD_PARTICIPANT_AVATAR_NUMBER : getFallbackParticipantAvatarNumber(member.name)
|
||||
);
|
||||
}
|
||||
|
||||
for (const [index, member] of teammateMembers.entries()) {
|
||||
map.set(member.name, 2 + (index % (PARTICIPANT_AVATAR_COUNT - 1)));
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
if (!map.has(member.name)) {
|
||||
map.set(
|
||||
member.name,
|
||||
isLeadMember(member)
|
||||
? LEAD_PARTICIPANT_AVATAR_NUMBER
|
||||
: getFallbackParticipantAvatarNumber(member.name)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
map.set('user', getFallbackParticipantAvatarNumber('user'));
|
||||
map.set('system', getFallbackParticipantAvatarNumber('system'));
|
||||
|
||||
return map.get(senderName) ?? getFallbackParticipantAvatarNumber(senderName);
|
||||
}
|
||||
|
||||
function readTeamNotificationMembers(teamName: string): TeamNotificationAvatarMember[] {
|
||||
try {
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
if (!existsSync(configPath)) return [];
|
||||
|
||||
const parsed = JSON.parse(readFileSync(configPath, 'utf8')) as {
|
||||
members?: unknown;
|
||||
};
|
||||
if (!Array.isArray(parsed.members)) return [];
|
||||
|
||||
return parsed.members
|
||||
.map((member): TeamNotificationAvatarMember | null => {
|
||||
if (!member || typeof member !== 'object') return null;
|
||||
const record = member as Record<string, unknown>;
|
||||
const name = typeof record.name === 'string' ? record.name.trim() : '';
|
||||
if (!name) return null;
|
||||
return {
|
||||
name,
|
||||
removedAt:
|
||||
typeof record.removedAt === 'number' || typeof record.removedAt === 'string'
|
||||
? record.removedAt
|
||||
: null,
|
||||
agentType: typeof record.agentType === 'string' ? record.agentType : undefined,
|
||||
};
|
||||
})
|
||||
.filter((member): member is TeamNotificationAvatarMember => Boolean(member));
|
||||
} catch (error) {
|
||||
logger.debug(`[team-toast] failed to read team members for avatar: ${String(error)}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function resolveParticipantAvatarPath(avatarNumber: number): string | undefined {
|
||||
const filename = `${String(avatarNumber).padStart(2, '0')}.png`;
|
||||
const resourceRoot =
|
||||
typeof process.resourcesPath === 'string' && process.resourcesPath.length > 0
|
||||
? process.resourcesPath
|
||||
: null;
|
||||
const candidates = [
|
||||
path.join(process.cwd(), 'src/renderer/assets/participant-avatars', filename),
|
||||
...(resourceRoot ? [path.join(resourceRoot, 'participant-avatars', filename)] : []),
|
||||
];
|
||||
|
||||
return candidates.find((candidate) => existsSync(candidate));
|
||||
}
|
||||
|
||||
function escapeXmlAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escapeXmlText(value: string): string {
|
||||
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function formatSenderLabel(sender: string): string | null {
|
||||
const trimmed = sender.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.toLowerCase() === 'system') return 'System';
|
||||
return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;
|
||||
}
|
||||
|
||||
function cleanNotificationText(value: string): string {
|
||||
return stripMarkdown(stripAgentBlocks(value)).replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function truncateNotificationText(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function extractTaskRef(summary: string): string | null {
|
||||
const match = /#([A-Za-z0-9][A-Za-z0-9-]*)/.exec(summary);
|
||||
return match ? `#${match[1]}` : null;
|
||||
}
|
||||
|
||||
function extractTaskSubject(summary: string): string {
|
||||
return summary
|
||||
.replace(/^Comment on\s+#[^:]+:\s*/i, '')
|
||||
.replace(/^Comment on\s+#[^\s]+/i, '')
|
||||
.replace(/^Clarification needed\s+[-–—]\s+Task\s+#[^:]+:\s*/i, '')
|
||||
.replace(/^Clarification needed\s+[-–—]\s+Task\s+#[^\s]+/i, '')
|
||||
.replace(/^Review requested\s+#[^:]+:\s*/i, '')
|
||||
.replace(/^Review requested\s+#[^\s]+/i, '')
|
||||
.replace(/^Blocked\s+#[^:]+:\s*/i, '')
|
||||
.replace(/^Blocked\s+#[^\s]+/i, '')
|
||||
.replace(/^New task\s+#[^:]+:\s*/i, '')
|
||||
.replace(/^New task\s+#[^\s]+/i, '')
|
||||
.replace(/^Task\s+#[^:]+:\s*/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getTeamNotificationAction(
|
||||
payload: TeamNotificationPayload,
|
||||
taskRef: string | null
|
||||
): string {
|
||||
switch (payload.teamEventType) {
|
||||
case 'task_comment':
|
||||
return taskRef ? `commented on ${taskRef}` : 'commented on a task';
|
||||
case 'task_clarification':
|
||||
return taskRef ? `needs your reply on ${taskRef}` : 'needs your reply';
|
||||
case 'task_review_requested':
|
||||
return taskRef ? `requested review on ${taskRef}` : 'requested review';
|
||||
case 'task_blocked': {
|
||||
const sender = payload.from.trim().toLowerCase();
|
||||
if (sender === 'system') return taskRef ? `Task is blocked on ${taskRef}` : 'Task is blocked';
|
||||
return taskRef ? `is blocked on ${taskRef}` : 'is blocked';
|
||||
}
|
||||
case 'task_status_change':
|
||||
return taskRef ? `changed ${taskRef}` : 'changed task status';
|
||||
case 'task_created':
|
||||
return taskRef ? `created ${taskRef}` : 'created a task';
|
||||
case 'all_tasks_completed':
|
||||
return 'completed all tasks';
|
||||
case 'lead_inbox':
|
||||
case 'user_inbox':
|
||||
return 'sent a message';
|
||||
case 'cross_team_message':
|
||||
return 'sent a cross-team message';
|
||||
case 'rate_limit':
|
||||
return 'paused: rate limit';
|
||||
case 'api_error':
|
||||
return 'paused: API error';
|
||||
case 'schedule_completed':
|
||||
return 'completed a schedule';
|
||||
case 'schedule_failed':
|
||||
return 'schedule failed';
|
||||
case 'team_launched':
|
||||
return 'launched a team';
|
||||
default:
|
||||
return 'sent an update';
|
||||
}
|
||||
}
|
||||
|
||||
function getTeamNotificationWhere(
|
||||
payload: TeamNotificationPayload,
|
||||
taskRef: string | null
|
||||
): string {
|
||||
const team = cleanNotificationText(payload.teamDisplayName) || payload.teamDisplayName;
|
||||
const summary = cleanNotificationText(payload.summary);
|
||||
|
||||
if (payload.teamEventType.startsWith('task_')) {
|
||||
const subject = extractTaskSubject(summary);
|
||||
const taskContext = subject || taskRef;
|
||||
return taskContext ? `${taskContext} - ${team}` : team;
|
||||
}
|
||||
|
||||
return team;
|
||||
}
|
||||
|
||||
function buildTeamNotificationPresentation(
|
||||
payload: TeamNotificationPayload,
|
||||
body: string
|
||||
): { title: string; where: string; body: string } {
|
||||
const who = formatSenderLabel(payload.from) ?? cleanNotificationText(payload.teamDisplayName);
|
||||
const summary = cleanNotificationText(payload.summary);
|
||||
const taskRef = extractTaskRef(summary);
|
||||
const action = getTeamNotificationAction(payload, taskRef);
|
||||
const where = getTeamNotificationWhere(payload, taskRef);
|
||||
const normalizedBody = cleanNotificationText(body);
|
||||
|
||||
if (payload.teamEventType === 'team_launch_incomplete') {
|
||||
return {
|
||||
title: 'Team launch incomplete',
|
||||
where: truncateNotificationText(where, 120),
|
||||
body: truncateNotificationText(normalizedBody || summary, 300),
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.teamEventType === 'task_blocked' && payload.from.trim().toLowerCase() === 'system') {
|
||||
return {
|
||||
title: truncateNotificationText(action, 96),
|
||||
where: truncateNotificationText(where, 120),
|
||||
body: truncateNotificationText(normalizedBody || summary, 300),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: truncateNotificationText(`${who} ${action}`.trim(), 96),
|
||||
where: truncateNotificationText(where, 120),
|
||||
body: truncateNotificationText(normalizedBody || summary, 300),
|
||||
};
|
||||
}
|
||||
|
||||
function getSenderInitials(sender: string): string {
|
||||
const trimmed = sender.trim().replace(/^@+/, '');
|
||||
if (!trimmed) return '?';
|
||||
|
||||
const parts = trimmed.split(/[\s._:-]+/).filter(Boolean);
|
||||
const initials =
|
||||
parts.length >= 2
|
||||
? `${parts[0]?.[0] ?? ''}${parts[1]?.[0] ?? ''}`
|
||||
: trimmed.replace(/[\s._:-]+/g, '').slice(0, 2);
|
||||
|
||||
return initials.toLocaleUpperCase() || '?';
|
||||
}
|
||||
|
||||
function resolveSenderParticipantAvatarPath(
|
||||
sender: string,
|
||||
teamName: string,
|
||||
members: readonly TeamNotificationAvatarMember[] | undefined
|
||||
): string | undefined {
|
||||
const senderLabel = sender.trim();
|
||||
if (!senderLabel || senderLabel.toLowerCase() === 'system') return undefined;
|
||||
|
||||
const roster = members && members.length > 0 ? members : readTeamNotificationMembers(teamName);
|
||||
const avatarNumber = getParticipantAvatarNumber(senderLabel, roster);
|
||||
return resolveParticipantAvatarPath(avatarNumber);
|
||||
}
|
||||
|
||||
function getWindowsToastAvatarPath(avatarPath: string): string {
|
||||
const cached = WINDOWS_TOAST_AVATAR_CACHE.get(avatarPath);
|
||||
if (cached) return cached;
|
||||
|
||||
const NativeImage = getNativeImage();
|
||||
if (!NativeImage) {
|
||||
WINDOWS_TOAST_AVATAR_CACHE.set(avatarPath, avatarPath);
|
||||
return avatarPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const source = NativeImage.createFromPath(avatarPath);
|
||||
if (source.isEmpty()) {
|
||||
WINDOWS_TOAST_AVATAR_CACHE.set(avatarPath, avatarPath);
|
||||
return avatarPath;
|
||||
}
|
||||
|
||||
const resized = source.resize({ width: 96, height: 96 });
|
||||
if (resized.isEmpty()) {
|
||||
WINDOWS_TOAST_AVATAR_CACHE.set(avatarPath, avatarPath);
|
||||
return avatarPath;
|
||||
}
|
||||
|
||||
const cacheDir = path.join(getAppDataPath(), 'notification-avatars');
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
|
||||
const parsed = path.parse(avatarPath);
|
||||
const outPath = path.join(cacheDir, `${parsed.name}-96.png`);
|
||||
writeFileSync(outPath, resized.toPNG());
|
||||
WINDOWS_TOAST_AVATAR_CACHE.set(avatarPath, outPath);
|
||||
return outPath;
|
||||
} catch (error) {
|
||||
logger.debug(`[team-toast] failed to prepare Windows toast avatar: ${String(error)}`);
|
||||
WINDOWS_TOAST_AVATAR_CACHE.set(avatarPath, avatarPath);
|
||||
return avatarPath;
|
||||
}
|
||||
}
|
||||
|
||||
function buildSenderNotificationIcon(
|
||||
sender: string,
|
||||
teamName: string,
|
||||
members: readonly TeamNotificationAvatarMember[] | undefined
|
||||
): NotificationConstructorOptions['icon'] {
|
||||
const senderLabel = sender.trim();
|
||||
if (!senderLabel || senderLabel.toLowerCase() === 'system') return getAppIconPath();
|
||||
|
||||
const senderAvatarPath = resolveSenderParticipantAvatarPath(senderLabel, teamName, members);
|
||||
const cacheKey = `${teamName}:${senderLabel}:${senderAvatarPath ?? 'generated'}`.toLowerCase();
|
||||
if (SENDER_ICON_CACHE.has(cacheKey)) {
|
||||
return SENDER_ICON_CACHE.get(cacheKey);
|
||||
}
|
||||
|
||||
try {
|
||||
if (senderAvatarPath) {
|
||||
const NativeImage = getNativeImage();
|
||||
if (NativeImage) {
|
||||
const avatarIcon = NativeImage.createFromPath(senderAvatarPath);
|
||||
if (!avatarIcon.isEmpty()) {
|
||||
SENDER_ICON_CACHE.set(cacheKey, avatarIcon);
|
||||
return avatarIcon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const colorName = getMemberColorByName(senderLabel);
|
||||
const hue = MEMBER_COLOR_HUE[colorName] ?? 210;
|
||||
const initials = escapeXmlAttribute(getSenderInitials(senderLabel));
|
||||
const svg = [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">',
|
||||
`<rect width="256" height="256" rx="72" fill="hsl(${hue}, 68%, 38%)"/>`,
|
||||
`<circle cx="128" cy="128" r="102" fill="hsl(${hue}, 74%, 46%)"/>`,
|
||||
`<circle cx="91" cy="86" r="20" fill="hsl(${hue}, 84%, 72%)" opacity="0.9"/>`,
|
||||
`<path d="M54 178c23-31 48-46 74-46s51 15 74 46" fill="none" stroke="hsl(${hue}, 88%, 78%)" stroke-width="18" stroke-linecap="round" opacity="0.5"/>`,
|
||||
`<text x="128" y="148" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="78" font-weight="700" fill="#fff">${initials}</text>`,
|
||||
'</svg>',
|
||||
].join('');
|
||||
const NativeImage = getNativeImage();
|
||||
const icon = NativeImage?.createFromDataURL(
|
||||
`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
||||
);
|
||||
const resolvedIcon = icon && !icon.isEmpty() ? icon : getAppIconPath();
|
||||
SENDER_ICON_CACHE.set(cacheKey, resolvedIcon);
|
||||
return resolvedIcon;
|
||||
} catch (error) {
|
||||
logger.debug(`[team-toast] sender icon fallback for "${senderLabel}": ${String(error)}`);
|
||||
const fallbackIcon = getAppIconPath();
|
||||
SENDER_ICON_CACHE.set(cacheKey, fallbackIcon);
|
||||
return fallbackIcon;
|
||||
}
|
||||
}
|
||||
|
||||
function buildWindowsTeamToastXml(input: {
|
||||
title: string;
|
||||
summary?: string;
|
||||
body: string;
|
||||
sender: string;
|
||||
avatarPath?: string;
|
||||
silent: boolean;
|
||||
}): string {
|
||||
const textRows = [
|
||||
`<text>${escapeXmlText(input.title)}</text>`,
|
||||
input.summary ? `<text>${escapeXmlText(input.summary)}</text>` : null,
|
||||
input.body ? `<text>${escapeXmlText(input.body)}</text>` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
const avatarRow = input.avatarPath
|
||||
? `<image placement="appLogoOverride" hint-crop="circle" src="${escapeXmlAttribute(
|
||||
pathToFileURL(input.avatarPath).href
|
||||
)}" alt="${escapeXmlAttribute(`${input.sender} avatar`)}"/>`
|
||||
: null;
|
||||
|
||||
return [
|
||||
'<toast>',
|
||||
'<visual>',
|
||||
'<binding template="ToastGeneric">',
|
||||
...textRows,
|
||||
avatarRow,
|
||||
'</binding>',
|
||||
'</visual>',
|
||||
input.silent ? '<audio silent="true"/>' : null,
|
||||
'</toast>',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
}
|
||||
|
||||
async function migrateLegacyNotificationPath(): Promise<string> {
|
||||
try {
|
||||
await fsp.readFile(NOTIFICATIONS_PATH, 'utf8');
|
||||
|
|
@ -603,7 +1023,7 @@ export class NotificationManager extends EventEmitter {
|
|||
|
||||
/**
|
||||
* Shows a native notification for a team event.
|
||||
* Uses team-specific formatting (title = team name, subtitle = summary).
|
||||
* Uses a consistent who + what + where presentation for all team events.
|
||||
*/
|
||||
private showTeamNativeNotification(
|
||||
stored: StoredNotification,
|
||||
|
|
@ -618,20 +1038,45 @@ export class NotificationManager extends EventEmitter {
|
|||
try {
|
||||
const config = this.configManager.getConfig();
|
||||
const isMac = process.platform === 'darwin';
|
||||
const truncatedBody = stripMarkdown(stripAgentBlocks(payload.body)).slice(0, 300);
|
||||
const iconPath = isMac ? undefined : getAppIconPath();
|
||||
const presentation = buildTeamNotificationPresentation(payload, payload.body);
|
||||
const senderAvatarPath = resolveSenderParticipantAvatarPath(
|
||||
payload.from,
|
||||
payload.teamName,
|
||||
payload.members
|
||||
);
|
||||
const toastXml =
|
||||
process.platform === 'win32' && senderAvatarPath
|
||||
? buildWindowsTeamToastXml({
|
||||
title: presentation.title,
|
||||
summary: presentation.where,
|
||||
body: presentation.body,
|
||||
sender: payload.from,
|
||||
avatarPath: getWindowsToastAvatarPath(senderAvatarPath),
|
||||
silent: !config.notifications.soundEnabled,
|
||||
})
|
||||
: undefined;
|
||||
const senderIcon = toastXml
|
||||
? undefined
|
||||
: buildSenderNotificationIcon(payload.from, payload.teamName, payload.members);
|
||||
|
||||
logger.debug(
|
||||
`[team-toast] creating: title="${payload.teamDisplayName}" summary="${payload.summary ?? ''}" bodyLen=${truncatedBody.length}`
|
||||
`[team-toast] creating: title="${presentation.title}" where="${presentation.where}" bodyLen=${presentation.body.length}`
|
||||
);
|
||||
|
||||
const notification = new NotificationClass({
|
||||
title: payload.teamDisplayName,
|
||||
...(isMac ? { subtitle: payload.summary } : {}),
|
||||
body: !isMac && payload.summary ? `${payload.summary}\n${truncatedBody}` : truncatedBody,
|
||||
sound: config.notifications.soundEnabled ? 'default' : undefined,
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
});
|
||||
const notificationOptions: NotificationConstructorOptions = toastXml
|
||||
? { toastXml }
|
||||
: {
|
||||
title: presentation.title,
|
||||
...(isMac ? { subtitle: presentation.where } : {}),
|
||||
body:
|
||||
!isMac && presentation.where
|
||||
? `${presentation.where}\n${presentation.body}`
|
||||
: presentation.body,
|
||||
sound: config.notifications.soundEnabled ? 'default' : undefined,
|
||||
...(senderIcon ? { icon: senderIcon } : {}),
|
||||
};
|
||||
|
||||
const notification = new NotificationClass(notificationOptions);
|
||||
|
||||
// Hold a strong reference to prevent GC from collecting the notification
|
||||
this.activeNotifications.add(notification);
|
||||
|
|
@ -647,7 +1092,7 @@ export class NotificationManager extends EventEmitter {
|
|||
|
||||
notification.on('show', () => {
|
||||
logger.debug(
|
||||
`[team-toast] OS confirmed show: "${payload.teamDisplayName}" — ${payload.summary ?? ''}`
|
||||
`[team-toast] OS confirmed show: "${presentation.title}" - ${presentation.where}`
|
||||
);
|
||||
});
|
||||
notification.on('failed', (_, error) => {
|
||||
|
|
@ -666,6 +1111,17 @@ export class NotificationManager extends EventEmitter {
|
|||
* Shared click handler for native notifications — focuses window and emits deep-link.
|
||||
*/
|
||||
private handleNativeNotificationClick(stored: StoredNotification): void {
|
||||
const isDevRuntime =
|
||||
process.env.NODE_ENV !== 'production' ||
|
||||
Boolean((process as typeof process & { defaultApp?: boolean }).defaultApp);
|
||||
if (isDevRuntime) {
|
||||
const notificationType = stored.teamEventType ?? stored.category ?? 'error';
|
||||
const notificationTitle = stored.triggerName ?? stored.message;
|
||||
logger.info(
|
||||
`[notification-click] delivered in-process id=${stored.id} type=${notificationType} title="${notificationTitle}"`
|
||||
);
|
||||
}
|
||||
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.show();
|
||||
this.mainWindow.focus();
|
||||
|
|
@ -674,6 +1130,30 @@ export class NotificationManager extends EventEmitter {
|
|||
this.emit('notification-clicked', stored);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes active OS notifications so macOS does not keep stale dev toasts that
|
||||
* can relaunch raw Electron without the app path.
|
||||
*/
|
||||
closeActiveNativeNotifications(reason: string = 'manual'): number {
|
||||
const notifications = Array.from(this.activeNotifications);
|
||||
for (const notification of notifications) {
|
||||
try {
|
||||
(notification as NotificationInstance & { close?: () => void }).close?.();
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`[notification] failed to close active notification during ${reason}: ${String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
this.activeNotifications.clear();
|
||||
if (notifications.length > 0) {
|
||||
logger.debug(
|
||||
`[notification] closed ${notifications.length} active notification(s): ${reason}`
|
||||
);
|
||||
}
|
||||
return notifications.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard: checks if Electron's Notification API is available.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ const PROVIDER_API_KEY_ENV_VARS: Partial<Record<CliProviderId, string>> = {
|
|||
const CODEX_NATIVE_API_KEY_ENV_VAR = 'CODEX_API_KEY';
|
||||
const CODEX_CLI_PATH_ENV_VAR = 'CODEX_CLI_PATH';
|
||||
const CODEX_HOME_ENV_VAR = 'CODEX_HOME';
|
||||
const CODEX_FORCED_LOGIN_METHOD_ENV_VAR = 'CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD';
|
||||
const CODEX_NATIVE_BACKEND_ID = 'codex-native';
|
||||
|
||||
function isCodexExecBinary(binaryPath?: string | null): boolean {
|
||||
|
|
@ -106,6 +107,18 @@ function applyCodexRuntimeContextEnv(
|
|||
}
|
||||
}
|
||||
|
||||
function applyCodexForcedLoginMethodEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
loginMethod: 'chatgpt' | 'api' | null
|
||||
): void {
|
||||
if (loginMethod) {
|
||||
env[CODEX_FORCED_LOGIN_METHOD_ENV_VAR] = loginMethod;
|
||||
return;
|
||||
}
|
||||
|
||||
delete env[CODEX_FORCED_LOGIN_METHOD_ENV_VAR];
|
||||
}
|
||||
|
||||
export class ProviderConnectionService {
|
||||
private static instance: ProviderConnectionService | null = null;
|
||||
private codexAccountFeature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null = null;
|
||||
|
|
@ -162,6 +175,20 @@ export class ProviderConnectionService {
|
|||
return null;
|
||||
}
|
||||
|
||||
async getConfiguredAnthropicApiKeyForTeamRuntime(env: NodeJS.ProcessEnv): Promise<string | null> {
|
||||
if (this.getConfiguredAuthMode('anthropic') !== 'api_key') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storedKey = await this.apiKeyService.lookupPreferred('ANTHROPIC_API_KEY');
|
||||
if (storedKey?.value.trim()) {
|
||||
return storedKey.value.trim();
|
||||
}
|
||||
|
||||
const envKey = env.ANTHROPIC_API_KEY?.trim();
|
||||
return envKey || null;
|
||||
}
|
||||
|
||||
async applyConfiguredConnectionEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
providerId: CliProviderId,
|
||||
|
|
@ -213,6 +240,7 @@ export class ProviderConnectionService {
|
|||
if (readiness.effectiveAuthMode === 'chatgpt') {
|
||||
delete env.OPENAI_API_KEY;
|
||||
delete env[CODEX_NATIVE_API_KEY_ENV_VAR];
|
||||
applyCodexForcedLoginMethodEnv(env, 'chatgpt');
|
||||
return env;
|
||||
}
|
||||
|
||||
|
|
@ -220,6 +248,7 @@ export class ProviderConnectionService {
|
|||
if (readiness.effectiveAuthMode === 'api_key' && resolvedApiKey) {
|
||||
env.OPENAI_API_KEY = resolvedApiKey;
|
||||
env[CODEX_NATIVE_API_KEY_ENV_VAR] = resolvedApiKey;
|
||||
applyCodexForcedLoginMethodEnv(env, 'api');
|
||||
return env;
|
||||
}
|
||||
|
||||
|
|
@ -227,6 +256,7 @@ export class ProviderConnectionService {
|
|||
delete env.OPENAI_API_KEY;
|
||||
}
|
||||
delete env[CODEX_NATIVE_API_KEY_ENV_VAR];
|
||||
applyCodexForcedLoginMethodEnv(env, null);
|
||||
|
||||
return env;
|
||||
}
|
||||
|
|
@ -274,6 +304,7 @@ export class ProviderConnectionService {
|
|||
if (readiness.effectiveAuthMode === 'chatgpt') {
|
||||
delete env.OPENAI_API_KEY;
|
||||
delete env[CODEX_NATIVE_API_KEY_ENV_VAR];
|
||||
applyCodexForcedLoginMethodEnv(env, 'chatgpt');
|
||||
return env;
|
||||
}
|
||||
|
||||
|
|
@ -281,8 +312,11 @@ export class ProviderConnectionService {
|
|||
if (readiness.effectiveAuthMode === 'api_key' && resolvedApiKey) {
|
||||
env.OPENAI_API_KEY = resolvedApiKey;
|
||||
env[CODEX_NATIVE_API_KEY_ENV_VAR] = resolvedApiKey;
|
||||
applyCodexForcedLoginMethodEnv(env, 'api');
|
||||
return env;
|
||||
}
|
||||
|
||||
applyCodexForcedLoginMethodEnv(env, null);
|
||||
return env;
|
||||
}
|
||||
|
||||
|
|
|
|||
353
src/main/services/runtime/anthropicTeamApiKeyHelper.ts
Normal file
353
src/main/services/runtime/anthropicTeamApiKeyHelper.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import { execFile, execFileSync } from 'child_process';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV = 'CLAUDE_TEAM_ANTHROPIC_AUTH_MODE';
|
||||
export const CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER = 'api_key_helper';
|
||||
export const CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV =
|
||||
'CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH';
|
||||
export const DISABLE_ANTHROPIC_TEAM_API_KEY_HELPER_ENV =
|
||||
'CLAUDE_TEAM_DISABLE_ANTHROPIC_API_KEY_HELPER';
|
||||
|
||||
export const ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR',
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR',
|
||||
] as const;
|
||||
|
||||
export interface AnthropicTeamApiKeyHelperMaterial {
|
||||
teamName: string;
|
||||
directory: string;
|
||||
helperPath: string;
|
||||
keyPath: string;
|
||||
settingsPath: string;
|
||||
settingsObject: { apiKeyHelper: string };
|
||||
settingsArgs: string[];
|
||||
envPatch: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
if (value.length === 0) {
|
||||
return "''";
|
||||
}
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function isOwnedPathSegment(value: string): boolean {
|
||||
return /^[a-zA-Z0-9._-]{1,128}$/.test(value) && value !== '.' && value !== '..';
|
||||
}
|
||||
|
||||
function safePathSegment(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (isOwnedPathSegment(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
return crypto.createHash('sha256').update(value).digest('hex').slice(0, 32);
|
||||
}
|
||||
|
||||
export function buildAnthropicTeamAuthDirectoryName(teamName: string): string {
|
||||
const slug =
|
||||
teamName
|
||||
.normalize('NFKD')
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 80) || 'team';
|
||||
const hash = crypto.createHash('sha256').update(teamName).digest('hex').slice(0, 12);
|
||||
return `${slug}-${hash}`;
|
||||
}
|
||||
|
||||
function resolveInside(basePath: string, ...segments: string[]): string {
|
||||
const resolvedBase = path.resolve(basePath);
|
||||
const resolvedPath = path.resolve(resolvedBase, ...segments);
|
||||
const relative = path.relative(resolvedBase, resolvedPath);
|
||||
if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
|
||||
return resolvedPath;
|
||||
}
|
||||
throw new Error('Refusing to write Anthropic team auth material outside the auth root');
|
||||
}
|
||||
|
||||
async function ensureOwnedDirectory(dirPath: string): Promise<void> {
|
||||
try {
|
||||
const stat = await fs.promises.lstat(dirPath);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new Error(`Unsafe Anthropic team auth directory: ${dirPath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
await fs.promises.chmod(dirPath, 0o700).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertRegularOwnedFile(filePath: string, mode: number): Promise<void> {
|
||||
const stat = await fs.promises.lstat(filePath);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||||
throw new Error(`Unsafe Anthropic team auth file: ${filePath}`);
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
await fs.promises.chmod(filePath, mode).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function readLiveProcessCommandsForReferenceCheck(): string | null {
|
||||
if (process.platform === 'win32') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return execFileSync('ps', ['-ax', '-o', 'command='], {
|
||||
encoding: 'utf8',
|
||||
timeout: 2000,
|
||||
maxBuffer: 5 * 1024 * 1024,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function liveProcessMayReferencePath(targetPath: string, processCommands?: string | null): boolean {
|
||||
const output =
|
||||
processCommands !== undefined ? processCommands : readLiveProcessCommandsForReferenceCheck();
|
||||
return typeof output === 'string' && output.includes(targetPath);
|
||||
}
|
||||
|
||||
async function writeFileAtomic(filePath: string, contents: string, mode: number): Promise<void> {
|
||||
const dir = path.dirname(filePath);
|
||||
await ensureOwnedDirectory(dir);
|
||||
const existing = await fs.promises.lstat(filePath).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
});
|
||||
if (existing?.isSymbolicLink()) {
|
||||
throw new Error(`Refusing to replace symlinked Anthropic team auth file: ${filePath}`);
|
||||
}
|
||||
const tmpPath = path.join(dir, `.tmp.${crypto.randomUUID()}`);
|
||||
try {
|
||||
await fs.promises.writeFile(tmpPath, contents, { encoding: 'utf8', mode });
|
||||
if (process.platform !== 'win32') {
|
||||
await fs.promises.chmod(tmpPath, mode).catch(() => undefined);
|
||||
}
|
||||
await fs.promises.rename(tmpPath, filePath);
|
||||
await assertRegularOwnedFile(filePath, mode);
|
||||
} catch (error) {
|
||||
await fs.promises.rm(tmpPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function buildHelperScript(keyPath: string): string {
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
'set -eu',
|
||||
`KEY_FILE=${shellQuote(keyPath)}`,
|
||||
'if [ ! -r "$KEY_FILE" ]; then',
|
||||
" echo 'app-managed Anthropic API key is unavailable' >&2",
|
||||
' exit 1',
|
||||
'fi',
|
||||
'key="$(cat "$KEY_FILE")"',
|
||||
'if [ -z "$key" ]; then',
|
||||
" echo 'app-managed Anthropic API key is empty' >&2",
|
||||
' exit 1',
|
||||
'fi',
|
||||
'printf \'%s\\n\' "$key"',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildAuthMaterialPaths(input: {
|
||||
teamName: string;
|
||||
authMaterialId: string;
|
||||
baseClaudeDir: string;
|
||||
}): { authRoot: string; teamDir: string; runDir: string } {
|
||||
const authRoot = path.resolve(input.baseClaudeDir, 'team-runtime-auth');
|
||||
const teamDirName = buildAnthropicTeamAuthDirectoryName(input.teamName);
|
||||
const authMaterialSegment = safePathSegment(input.authMaterialId);
|
||||
const teamDir = resolveInside(authRoot, teamDirName);
|
||||
const runDir = resolveInside(authRoot, teamDirName, 'runs', authMaterialSegment);
|
||||
return { authRoot, teamDir, runDir };
|
||||
}
|
||||
|
||||
export async function materializeAnthropicTeamApiKeyHelper(input: {
|
||||
teamName: string;
|
||||
authMaterialId: string;
|
||||
apiKey: string;
|
||||
baseClaudeDir: string;
|
||||
}): Promise<AnthropicTeamApiKeyHelperMaterial> {
|
||||
const normalizedApiKey = input.apiKey.trim();
|
||||
if (!normalizedApiKey) {
|
||||
throw new Error('Cannot materialize Anthropic team API-key helper without an API key');
|
||||
}
|
||||
|
||||
const { authRoot, teamDir, runDir } = buildAuthMaterialPaths(input);
|
||||
await ensureOwnedDirectory(authRoot);
|
||||
await ensureOwnedDirectory(teamDir);
|
||||
await ensureOwnedDirectory(path.join(teamDir, 'runs'));
|
||||
await ensureOwnedDirectory(runDir);
|
||||
|
||||
const keyPath = path.join(runDir, 'key');
|
||||
const helperPath = path.join(runDir, 'helper.sh');
|
||||
const settingsPath = path.join(runDir, 'settings.json');
|
||||
const settingsObject = { apiKeyHelper: shellQuote(helperPath) };
|
||||
|
||||
await writeFileAtomic(keyPath, `${normalizedApiKey}\n`, 0o600);
|
||||
await writeFileAtomic(helperPath, buildHelperScript(keyPath), 0o700);
|
||||
await writeFileAtomic(settingsPath, `${JSON.stringify(settingsObject, null, 2)}\n`, 0o600);
|
||||
|
||||
return {
|
||||
teamName: input.teamName,
|
||||
directory: runDir,
|
||||
helperPath,
|
||||
keyPath,
|
||||
settingsPath,
|
||||
settingsObject,
|
||||
settingsArgs: ['--settings', settingsPath],
|
||||
envPatch: {
|
||||
[CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV]: CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER,
|
||||
[CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV]: settingsPath,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyAnthropicTeamApiKeyHelperMaterial(input: {
|
||||
helperPath: string;
|
||||
expectedApiKey: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const result = await execFileAsync('/bin/sh', ['-c', shellQuote(input.helperPath)], {
|
||||
timeout: input.timeoutMs ?? 5000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
if (result.stdout.trim() !== input.expectedApiKey.trim()) {
|
||||
throw new Error('App-managed Anthropic API-key helper verification failed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupAnthropicTeamApiKeyHelperMaterial(input: {
|
||||
directory: string;
|
||||
skipIfLiveProcessReferences?: boolean;
|
||||
}): Promise<void> {
|
||||
if (input.skipIfLiveProcessReferences === true && liveProcessMayReferencePath(input.directory)) {
|
||||
return;
|
||||
}
|
||||
const entries = await fs.promises
|
||||
.readdir(input.directory, { withFileTypes: true })
|
||||
.catch(() => []);
|
||||
const expected = new Set(['helper.sh', 'key', 'settings.json']);
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const fileName = entry.name;
|
||||
const isExpected =
|
||||
expected.has(fileName) || /^runtime-settings-[a-zA-Z0-9._-]+\.json$/.test(fileName);
|
||||
if (!isExpected) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(input.directory, fileName);
|
||||
const stat = await fs.promises.lstat(filePath).catch(() => null);
|
||||
if (!stat || stat.isSymbolicLink() || !stat.isFile()) {
|
||||
continue;
|
||||
}
|
||||
await fs.promises.rm(filePath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
await fs.promises.rmdir(input.directory).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function cleanupAnthropicTeamApiKeyHelperForTeam(input: {
|
||||
teamName: string;
|
||||
baseClaudeDir: string;
|
||||
}): Promise<void> {
|
||||
const { teamDir } = buildAuthMaterialPaths({
|
||||
teamName: input.teamName,
|
||||
authMaterialId: 'cleanup-placeholder',
|
||||
baseClaudeDir: input.baseClaudeDir,
|
||||
});
|
||||
const stat = await fs.promises.lstat(teamDir).catch(() => null);
|
||||
if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
const processCommands = readLiveProcessCommandsForReferenceCheck();
|
||||
const runsDir = path.join(teamDir, 'runs');
|
||||
const runsStat = await fs.promises.lstat(runsDir).catch(() => null);
|
||||
if (runsStat?.isDirectory() && !runsStat.isSymbolicLink()) {
|
||||
const entries = await fs.promises.readdir(runsDir, { withFileTypes: true }).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !isOwnedPathSegment(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
const runDir = path.join(runsDir, entry.name);
|
||||
const runStat = await fs.promises.lstat(runDir).catch(() => null);
|
||||
if (!runStat?.isDirectory() || runStat.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
if (liveProcessMayReferencePath(runDir, processCommands)) {
|
||||
continue;
|
||||
}
|
||||
await cleanupAnthropicTeamApiKeyHelperMaterial({ directory: runDir });
|
||||
}
|
||||
await fs.promises.rmdir(runsDir).catch(() => undefined);
|
||||
}
|
||||
await fs.promises.rmdir(teamDir).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function cleanupStaleAnthropicTeamApiKeyHelpers(input: {
|
||||
baseClaudeDir: string;
|
||||
maxAgeMs: number;
|
||||
}): Promise<void> {
|
||||
const authRoot = path.resolve(input.baseClaudeDir, 'team-runtime-auth');
|
||||
const rootStat = await fs.promises.lstat(authRoot).catch(() => null);
|
||||
if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const processCommands = readLiveProcessCommandsForReferenceCheck();
|
||||
if (processCommands === null) {
|
||||
return;
|
||||
}
|
||||
const teamEntries = await fs.promises.readdir(authRoot, { withFileTypes: true }).catch(() => []);
|
||||
for (const teamEntry of teamEntries) {
|
||||
if (!teamEntry.isDirectory() || !isOwnedPathSegment(teamEntry.name)) {
|
||||
continue;
|
||||
}
|
||||
const teamDir = path.join(authRoot, teamEntry.name);
|
||||
const teamStat = await fs.promises.lstat(teamDir).catch(() => null);
|
||||
if (!teamStat?.isDirectory() || teamStat.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
const runsDir = path.join(teamDir, 'runs');
|
||||
const runsStat = await fs.promises.lstat(runsDir).catch(() => null);
|
||||
if (!runsStat?.isDirectory() || runsStat.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
const runEntries = await fs.promises.readdir(runsDir, { withFileTypes: true }).catch(() => []);
|
||||
for (const runEntry of runEntries) {
|
||||
if (!runEntry.isDirectory() || !isOwnedPathSegment(runEntry.name)) {
|
||||
continue;
|
||||
}
|
||||
const runDir = path.join(runsDir, runEntry.name);
|
||||
const runStat = await fs.promises.lstat(runDir).catch(() => null);
|
||||
if (!runStat?.isDirectory() || runStat.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
if (now - runStat.mtimeMs < input.maxAgeMs) {
|
||||
continue;
|
||||
}
|
||||
if (liveProcessMayReferencePath(runDir, processCommands)) {
|
||||
continue;
|
||||
}
|
||||
await cleanupAnthropicTeamApiKeyHelperMaterial({ directory: runDir });
|
||||
}
|
||||
await fs.promises.rmdir(runsDir).catch(() => undefined);
|
||||
await fs.promises.rmdir(teamDir).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
type JsonObject = Record<string, unknown>;
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
|
||||
type JsonArray = unknown[];
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ function isJsonObject(value: unknown): value is JsonObject {
|
|||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseJsonSettingsObject(raw: string): JsonObject | null {
|
||||
export function parseJsonSettingsObject(raw: string): JsonObject | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return isJsonObject(parsed) ? parsed : null;
|
||||
|
|
@ -73,7 +73,7 @@ function mergeHooksObject(target: JsonObject, source: JsonObject): JsonObject {
|
|||
continue;
|
||||
}
|
||||
if (isJsonObject(currentValue) && isJsonObject(sourceValue)) {
|
||||
merged[hookName] = deepMergeJsonObjects(currentValue, sourceValue);
|
||||
merged[hookName] = mergeJsonSettingsObjects(currentValue, sourceValue);
|
||||
continue;
|
||||
}
|
||||
merged[hookName] = sourceValue;
|
||||
|
|
@ -81,7 +81,7 @@ function mergeHooksObject(target: JsonObject, source: JsonObject): JsonObject {
|
|||
return merged;
|
||||
}
|
||||
|
||||
function deepMergeJsonObjects(target: JsonObject, source: JsonObject): JsonObject {
|
||||
export function mergeJsonSettingsObjects(target: JsonObject, source: JsonObject): JsonObject {
|
||||
const merged: JsonObject = { ...target };
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
const current = merged[key];
|
||||
|
|
@ -90,7 +90,7 @@ function deepMergeJsonObjects(target: JsonObject, source: JsonObject): JsonObjec
|
|||
continue;
|
||||
}
|
||||
if (isJsonObject(current) && isJsonObject(value)) {
|
||||
merged[key] = deepMergeJsonObjects(current, value);
|
||||
merged[key] = mergeJsonSettingsObjects(current, value);
|
||||
continue;
|
||||
}
|
||||
merged[key] = value;
|
||||
|
|
@ -120,7 +120,7 @@ export function mergeJsonSettingsArgs(args: string[]): string[] {
|
|||
if (firstSettingsIndex === null) {
|
||||
firstSettingsIndex = output.length;
|
||||
}
|
||||
mergedSettings = deepMergeJsonObjects(mergedSettings ?? {}, parsed);
|
||||
mergedSettings = mergeJsonSettingsObjects(mergedSettings ?? {}, parsed);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -137,7 +137,7 @@ export function mergeJsonSettingsArgs(args: string[]): string[] {
|
|||
if (firstSettingsIndex === null) {
|
||||
firstSettingsIndex = output.length;
|
||||
}
|
||||
mergedSettings = deepMergeJsonObjects(mergedSettings ?? {}, parsed);
|
||||
mergedSettings = mergeJsonSettingsObjects(mergedSettings ?? {}, parsed);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
162
src/main/services/runtime/teamRuntimeSettingsBundle.ts
Normal file
162
src/main/services/runtime/teamRuntimeSettingsBundle.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { randomUUID } from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { mergeJsonSettingsObjects, parseJsonSettingsObject } from './cliSettingsArgs';
|
||||
|
||||
import type { AnthropicTeamApiKeyHelperMaterial } from './anthropicTeamApiKeyHelper';
|
||||
import type { TeamProviderId } from '@shared/types';
|
||||
|
||||
export type TeamRuntimeSettingsJson = Record<string, unknown>;
|
||||
|
||||
export interface TeamRuntimeSettingsBundle {
|
||||
settingsPath: string;
|
||||
settingsObject: TeamRuntimeSettingsJson;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
export interface SplitSettingsJsonArgsResult {
|
||||
settingsFragments: TeamRuntimeSettingsJson[];
|
||||
passthroughArgs: string[];
|
||||
}
|
||||
|
||||
export function splitSettingsJsonArgs(args: string[]): SplitSettingsJsonArgsResult {
|
||||
const settingsFragments: TeamRuntimeSettingsJson[] = [];
|
||||
const passthroughArgs: string[] = [];
|
||||
|
||||
let index = 0;
|
||||
while (index < args.length) {
|
||||
const arg = args[index];
|
||||
if (arg === '--settings') {
|
||||
const value = args[index + 1];
|
||||
if (typeof value === 'string') {
|
||||
const parsed = parseJsonSettingsObject(value);
|
||||
if (parsed) {
|
||||
settingsFragments.push(parsed);
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
passthroughArgs.push(arg, value);
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const settingsPrefix = '--settings=';
|
||||
if (arg.startsWith(settingsPrefix)) {
|
||||
const value = arg.slice(settingsPrefix.length);
|
||||
const parsed = parseJsonSettingsObject(value);
|
||||
if (parsed) {
|
||||
settingsFragments.push(parsed);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
passthroughArgs.push(arg);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return { settingsFragments, passthroughArgs };
|
||||
}
|
||||
|
||||
function sanitizeProviderId(providerId: TeamProviderId): string {
|
||||
return providerId.replace(/[^a-zA-Z0-9._-]+/g, '_') || 'provider';
|
||||
}
|
||||
|
||||
function stripCompetingAnthropicEnv(settings: TeamRuntimeSettingsJson): TeamRuntimeSettingsJson {
|
||||
const env = settings.env;
|
||||
if (!env || typeof env !== 'object' || Array.isArray(env)) {
|
||||
return settings;
|
||||
}
|
||||
const nextEnv = { ...(env as Record<string, unknown>) };
|
||||
delete nextEnv.ANTHROPIC_API_KEY;
|
||||
delete nextEnv.ANTHROPIC_AUTH_TOKEN;
|
||||
delete nextEnv.CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR;
|
||||
delete nextEnv.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
delete nextEnv.CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR;
|
||||
return { ...settings, env: nextEnv };
|
||||
}
|
||||
|
||||
async function writeSettingsFile(
|
||||
filePath: string,
|
||||
settings: TeamRuntimeSettingsJson
|
||||
): Promise<void> {
|
||||
const dir = path.dirname(filePath);
|
||||
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
if (process.platform !== 'win32') {
|
||||
await fs.promises.chmod(dir, 0o700).catch(() => undefined);
|
||||
}
|
||||
const existing = await fs.promises.lstat(filePath).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (existing?.isSymbolicLink()) {
|
||||
throw new Error(`Refusing to replace symlinked team runtime settings file: ${filePath}`);
|
||||
}
|
||||
const tmpPath = path.join(dir, `.tmp.settings.${randomUUID()}`);
|
||||
try {
|
||||
await fs.promises.writeFile(tmpPath, `${JSON.stringify(settings, null, 2)}\n`, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
if (process.platform !== 'win32') {
|
||||
await fs.promises.chmod(tmpPath, 0o600).catch(() => undefined);
|
||||
}
|
||||
await fs.promises.rename(tmpPath, filePath);
|
||||
const written = await fs.promises.lstat(filePath);
|
||||
if (!written.isFile() || written.isSymbolicLink()) {
|
||||
throw new Error(`Unsafe team runtime settings file: ${filePath}`);
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
await fs.promises.chmod(filePath, 0o600).catch(() => undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
await fs.promises.rm(tmpPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function materializeTeamRuntimeSettingsBundle(input: {
|
||||
teamName: string;
|
||||
providerId: TeamProviderId;
|
||||
baseSettings?: (TeamRuntimeSettingsJson | null | undefined)[];
|
||||
anthropicHelper?: AnthropicTeamApiKeyHelperMaterial | null;
|
||||
}): Promise<TeamRuntimeSettingsBundle | null> {
|
||||
const fragments = [...(input.baseSettings ?? [])].filter(
|
||||
(fragment): fragment is TeamRuntimeSettingsJson =>
|
||||
!!fragment && typeof fragment === 'object' && !Array.isArray(fragment)
|
||||
);
|
||||
if (input.anthropicHelper) {
|
||||
fragments.push(input.anthropicHelper.settingsObject);
|
||||
}
|
||||
if (fragments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const settingsObject = stripCompetingAnthropicEnv(
|
||||
fragments.reduce<TeamRuntimeSettingsJson>(
|
||||
(merged, fragment) => mergeJsonSettingsObjects(merged, fragment),
|
||||
{}
|
||||
)
|
||||
);
|
||||
if (Object.keys(settingsObject).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseDirectory = input.anthropicHelper?.directory;
|
||||
if (!baseDirectory) {
|
||||
return null;
|
||||
}
|
||||
const settingsPath = path.join(
|
||||
baseDirectory,
|
||||
`runtime-settings-${sanitizeProviderId(input.providerId)}.json`
|
||||
);
|
||||
await writeSettingsFile(settingsPath, settingsObject);
|
||||
return {
|
||||
settingsPath,
|
||||
settingsObject,
|
||||
args: ['--settings', settingsPath],
|
||||
};
|
||||
}
|
||||
|
|
@ -178,7 +178,9 @@ export class ScheduledTaskExecutor {
|
|||
cwd: request.config.cwd,
|
||||
// shellEnv spread after buildEnrichedEnv ensures freshly-resolved values
|
||||
// take precedence over the cached snapshot inside buildEnrichedEnv.
|
||||
env,
|
||||
// CLAUDECODE stripped last to prevent nested-session detection regardless
|
||||
// of what buildProviderAwareCliEnv merges in.
|
||||
env: { ...env, CLAUDECODE: undefined },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,64 @@ interface PendingAutoResumeEntry {
|
|||
sourceRunId: string | null;
|
||||
}
|
||||
|
||||
export type RateLimitAutoResumePlan =
|
||||
| {
|
||||
kind: 'scheduled';
|
||||
resetTime: Date;
|
||||
delayMs: number;
|
||||
fireAtMs: number;
|
||||
rawDelayMs: number;
|
||||
}
|
||||
| {
|
||||
kind: 'manual';
|
||||
reason: 'disabled' | 'not_resumable' | 'reset_unparseable' | 'stale' | 'too_far';
|
||||
};
|
||||
|
||||
export function planRateLimitAutoResume(input: {
|
||||
enabled: boolean;
|
||||
canAutoResume: boolean;
|
||||
messageText: string;
|
||||
observedAt: Date;
|
||||
messageTimestamp?: Date;
|
||||
}): RateLimitAutoResumePlan {
|
||||
if (!input.enabled) return { kind: 'manual', reason: 'disabled' };
|
||||
if (!input.canAutoResume) return { kind: 'manual', reason: 'not_resumable' };
|
||||
|
||||
const observedAtMs = input.observedAt.getTime();
|
||||
const messageTimestamp = input.messageTimestamp ?? input.observedAt;
|
||||
const messageAtMs = Number.isFinite(messageTimestamp.getTime())
|
||||
? messageTimestamp.getTime()
|
||||
: observedAtMs;
|
||||
const parseReferenceTime = Number.isFinite(messageTimestamp.getTime())
|
||||
? messageTimestamp
|
||||
: input.observedAt;
|
||||
|
||||
const resetTime = parseRateLimitResetTime(input.messageText, parseReferenceTime);
|
||||
if (!resetTime) return { kind: 'manual', reason: 'reset_unparseable' };
|
||||
|
||||
const resetAtMs = resetTime.getTime();
|
||||
const rawDelayMs = resetAtMs - observedAtMs;
|
||||
const targetFireAtMs = resetAtMs + AUTO_RESUME_BUFFER_MS;
|
||||
const messageAgeMs = Math.max(0, observedAtMs - messageAtMs);
|
||||
|
||||
if (targetFireAtMs <= observedAtMs && messageAgeMs > AUTO_RESUME_HISTORY_FRESH_MS) {
|
||||
return { kind: 'manual', reason: 'stale' };
|
||||
}
|
||||
|
||||
const delayMs = Math.max(0, targetFireAtMs - observedAtMs);
|
||||
if (delayMs > AUTO_RESUME_MAX_DELAY_MS) {
|
||||
return { kind: 'manual', reason: 'too_far' };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'scheduled',
|
||||
resetTime,
|
||||
delayMs,
|
||||
fireAtMs: observedAtMs + delayMs,
|
||||
rawDelayMs,
|
||||
};
|
||||
}
|
||||
|
||||
type AutoResumeProvisioning = Pick<
|
||||
TeamProvisioningService,
|
||||
'getCurrentRunId' | 'isTeamAlive' | 'sendMessageToTeam'
|
||||
|
|
@ -40,31 +98,13 @@ export class AutoResumeService {
|
|||
observedAt: Date = new Date(),
|
||||
messageTimestamp: Date = observedAt
|
||||
): void {
|
||||
const cfg = this.configManager.getConfig();
|
||||
if (!cfg.notifications.autoResumeOnRateLimit) return;
|
||||
|
||||
const observedAtMs = observedAt.getTime();
|
||||
const messageAtMs = Number.isFinite(messageTimestamp.getTime())
|
||||
? messageTimestamp.getTime()
|
||||
: observedAtMs;
|
||||
const parseReferenceTime = Number.isFinite(messageTimestamp.getTime())
|
||||
? messageTimestamp
|
||||
: observedAt;
|
||||
|
||||
const resetTime = parseRateLimitResetTime(messageText, parseReferenceTime);
|
||||
if (!resetTime) {
|
||||
logger.info(
|
||||
`[auto-resume] Rate limit detected for "${teamName}" but reset time was not parseable - skipping auto-resume`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const resetAtMs = resetTime.getTime();
|
||||
const rawDelayMs = resetAtMs - observedAtMs;
|
||||
const targetFireAtMs = resetAtMs + AUTO_RESUME_BUFFER_MS;
|
||||
const messageAgeMs = Math.max(0, observedAtMs - messageAtMs);
|
||||
const existing = this.pendingTimers.get(teamName);
|
||||
const sourceRunId = this.provisioningService.getCurrentRunId(teamName);
|
||||
const cfg = this.configManager.getConfig();
|
||||
|
||||
if (existing && messageAtMs < existing.sourceMessageAtMs) {
|
||||
logger.info(
|
||||
|
|
@ -73,35 +113,37 @@ export class AutoResumeService {
|
|||
return;
|
||||
}
|
||||
|
||||
if (targetFireAtMs <= observedAtMs && messageAgeMs > AUTO_RESUME_HISTORY_FRESH_MS) {
|
||||
logger.info(
|
||||
`[auto-resume] Parsed reset time for "${teamName}" passed its buffered fire deadline ${Math.round((observedAtMs - targetFireAtMs) / 1000)}s ago - skipping stale history replay`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const plan = planRateLimitAutoResume({
|
||||
enabled: cfg.notifications.autoResumeOnRateLimit,
|
||||
canAutoResume: true,
|
||||
messageText,
|
||||
observedAt,
|
||||
messageTimestamp,
|
||||
});
|
||||
|
||||
if (rawDelayMs < 0) {
|
||||
logger.warn(
|
||||
`[auto-resume] Parsed reset time for "${teamName}" is ${Math.round(-rawDelayMs / 1000)}s in the past - using remaining buffered delay`
|
||||
);
|
||||
}
|
||||
|
||||
const delayMs = Math.max(0, targetFireAtMs - observedAtMs);
|
||||
const fireAtMs = observedAtMs + delayMs;
|
||||
|
||||
if (delayMs > AUTO_RESUME_MAX_DELAY_MS) {
|
||||
if (existing) {
|
||||
if (plan.kind === 'manual') {
|
||||
if (plan.reason === 'too_far' && existing) {
|
||||
clearTimeout(existing.timer);
|
||||
this.pendingTimers.delete(teamName);
|
||||
}
|
||||
logger.warn(
|
||||
`[auto-resume] Parsed reset time for "${teamName}" is ${Math.round(delayMs / 60000)}m away - exceeds ceiling, skipping`
|
||||
if (plan.reason === 'too_far') {
|
||||
logger.warn(`[auto-resume] Parsed reset time for "${teamName}" exceeds ceiling - skipping`);
|
||||
return;
|
||||
}
|
||||
logger.info(
|
||||
`[auto-resume] Rate limit detected for "${teamName}" but auto-resume is manual (${plan.reason})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (plan.rawDelayMs < 0) {
|
||||
logger.warn(
|
||||
`[auto-resume] Parsed reset time for "${teamName}" is ${Math.round(-plan.rawDelayMs / 1000)}s in the past - using remaining buffered delay`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
existing?.fireAtMs === fireAtMs &&
|
||||
existing?.fireAtMs === plan.fireAtMs &&
|
||||
existing.sourceMessageAtMs === messageAtMs &&
|
||||
existing.sourceRunId === sourceRunId
|
||||
) {
|
||||
|
|
@ -112,22 +154,22 @@ export class AutoResumeService {
|
|||
clearTimeout(existing.timer);
|
||||
this.pendingTimers.delete(teamName);
|
||||
logger.info(
|
||||
`[auto-resume] Rescheduling resume for "${teamName}" to ${resetTime.toISOString()}`
|
||||
`[auto-resume] Rescheduling resume for "${teamName}" to ${plan.resetTime.toISOString()}`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`[auto-resume] Scheduling resume for "${teamName}" at ${resetTime.toISOString()} (in ${Math.round(delayMs / 1000)}s)`
|
||||
`[auto-resume] Scheduling resume for "${teamName}" at ${plan.resetTime.toISOString()} (in ${Math.round(plan.delayMs / 1000)}s)`
|
||||
);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingTimers.delete(teamName);
|
||||
void this.fireResumeNudge(teamName, sourceRunId);
|
||||
}, delayMs);
|
||||
}, plan.delayMs);
|
||||
|
||||
this.pendingTimers.set(teamName, {
|
||||
timer,
|
||||
fireAtMs,
|
||||
fireAtMs: plan.fireAtMs,
|
||||
sourceMessageAtMs: messageAtMs,
|
||||
sourceRunId,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -123,6 +123,12 @@ export class ChangeExtractorService {
|
|||
this.taskChangeComputer = new TaskChangeComputer(logsFinder, boundaryParser);
|
||||
}
|
||||
|
||||
private readConfigForObservation(teamName: string) {
|
||||
return typeof this.configReader.getConfigSnapshot === 'function'
|
||||
? this.configReader.getConfigSnapshot(teamName)
|
||||
: this.configReader.getConfig(teamName);
|
||||
}
|
||||
|
||||
setTaskChangePresenceServices(
|
||||
repository: TaskChangePresenceRepository,
|
||||
tracker: TeamLogSourceTracker
|
||||
|
|
@ -671,7 +677,7 @@ export class ChangeExtractorService {
|
|||
try {
|
||||
const [meta, config] = await Promise.all([
|
||||
this.teamMetaStore.getMeta(teamName).catch(() => null),
|
||||
this.configReader.getConfig(teamName).catch(() => null),
|
||||
this.readConfigForObservation(teamName).catch(() => null),
|
||||
]);
|
||||
const hasOpenCodeMember = (config?.members ?? []).some(
|
||||
(member) => member.providerId === 'opencode'
|
||||
|
|
@ -996,7 +1002,7 @@ export class ChangeExtractorService {
|
|||
/** Получить projectPath из конфига команды */
|
||||
private async resolveProjectPath(teamName: string): Promise<string | undefined> {
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await this.readConfigForObservation(teamName);
|
||||
return config?.projectPath?.trim() || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { getClaudeBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||
import { CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SOURCE, formatCrossTeamText } from '@shared/constants';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
|
||||
import { buildActionModeAgentBlock } from './actionModeInstructions';
|
||||
import { CascadeGuard } from './CascadeGuard';
|
||||
|
|
@ -117,8 +116,11 @@ export class CrossTeamService {
|
|||
throw new Error(`Target team not found: ${toTeam}`);
|
||||
}
|
||||
|
||||
// 2. Resolve lead
|
||||
const leadName = (await this.dataService.getLeadMemberName(toTeam)) ?? 'team-lead';
|
||||
// 2. Resolve lead. Reuse the verified target config before falling back to meta storage.
|
||||
const leadName =
|
||||
targetConfig.members?.find((m) => isLeadMember(m))?.name?.trim() ||
|
||||
(await this.dataService.getLeadMemberName(toTeam)) ||
|
||||
'team-lead';
|
||||
|
||||
// 3. Format
|
||||
const from = `${fromTeam}.${fromMember}`;
|
||||
|
|
@ -203,39 +205,34 @@ export class CrossTeamService {
|
|||
}
|
||||
|
||||
async listAvailableTargets(excludeTeam?: string): Promise<CrossTeamTarget[]> {
|
||||
const teamsDir = getTeamsBasePath();
|
||||
let entries: string[];
|
||||
let teams: Awaited<ReturnType<TeamDataService['listTeams']>>;
|
||||
try {
|
||||
entries = await fs.promises.readdir(teamsDir);
|
||||
teams = await this.dataService.listTeams();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const targets: CrossTeamTarget[] = [];
|
||||
for (const entry of entries) {
|
||||
if (excludeTeam && entry === excludeTeam) continue;
|
||||
if (!TEAM_NAME_PATTERN.test(entry)) continue;
|
||||
|
||||
let config: TeamConfig | null;
|
||||
try {
|
||||
config = await this.configReader.getConfig(entry);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!config || config.deletedAt) continue;
|
||||
|
||||
const lead = config.members?.find((m) => isLeadMember(m));
|
||||
|
||||
targets.push({
|
||||
teamName: entry,
|
||||
displayName: config.name || entry,
|
||||
description: config.description,
|
||||
color: config.color,
|
||||
leadName: lead?.name,
|
||||
leadColor: lead?.color,
|
||||
isOnline: this.provisioning?.isTeamAlive(entry) ?? false,
|
||||
const targets: CrossTeamTarget[] = teams
|
||||
.filter((team) => {
|
||||
if (excludeTeam && team.teamName === excludeTeam) return false;
|
||||
if (!TEAM_NAME_PATTERN.test(team.teamName)) return false;
|
||||
return !team.deletedAt && !team.pendingCreate;
|
||||
})
|
||||
.map((team) => {
|
||||
const summaryLead =
|
||||
team.leadName || team.leadColor
|
||||
? { name: team.leadName, color: team.leadColor }
|
||||
: team.members?.find((member) => isLeadMember(member));
|
||||
return {
|
||||
teamName: team.teamName,
|
||||
displayName: team.displayName || team.teamName,
|
||||
description: team.description,
|
||||
color: team.color,
|
||||
...(summaryLead?.name ? { leadName: summaryLead.name } : {}),
|
||||
...(summaryLead?.color ? { leadColor: summaryLead.color } : {}),
|
||||
isOnline: this.provisioning?.isTeamAlive(team.teamName) ?? false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return targets.sort((a, b) => {
|
||||
if (a.isOnline && !b.isOnline) return -1;
|
||||
|
|
|
|||
366
src/main/services/team/LaunchIoGovernor.ts
Normal file
366
src/main/services/team/LaunchIoGovernor.ts
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
import type {
|
||||
GlobalTask,
|
||||
TeamChangeEvent,
|
||||
TeamProvisioningProgress,
|
||||
TeamSummary,
|
||||
} from '@shared/types';
|
||||
|
||||
export type LaunchIoGovernorOperationKey = 'teams:list' | 'teams:getAllTasks';
|
||||
|
||||
type GovernedPayload = TeamSummary[] | GlobalTask[];
|
||||
type CloneFn<T> = (value: T) => T;
|
||||
|
||||
interface LaunchIoGovernorLogger {
|
||||
debug?: (message: string) => void;
|
||||
warn?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface LaunchIoGovernorOptions {
|
||||
quietWindowMs?: number;
|
||||
maxStaleAgeMs?: number;
|
||||
stuckLaunchPressureMs?: number;
|
||||
warningCooldownMs?: number;
|
||||
now?: () => number;
|
||||
logger?: LaunchIoGovernorLogger;
|
||||
}
|
||||
|
||||
interface ActiveLaunch {
|
||||
teamName: string;
|
||||
source: string;
|
||||
startedAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface CachedValue<T> {
|
||||
value: T;
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
interface OperationState<T> {
|
||||
key: LaunchIoGovernorOperationKey;
|
||||
cache: CachedValue<T> | null;
|
||||
dirty: boolean;
|
||||
generation: number;
|
||||
inFlight: Promise<T> | null;
|
||||
loadFresh: (() => Promise<T>) | null;
|
||||
clone: CloneFn<T> | null;
|
||||
scheduledRefresh: ReturnType<typeof setTimeout> | null;
|
||||
lastWarningAt: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_LAUNCH_IO_QUIET_WINDOW_MS = 3_000;
|
||||
export const DEFAULT_LAUNCH_IO_MAX_STALE_AGE_MS = 15_000;
|
||||
export const DEFAULT_LAUNCH_IO_STUCK_PRESSURE_MS = 10 * 60_000;
|
||||
const DEFAULT_WARNING_COOLDOWN_MS = 10_000;
|
||||
|
||||
const TERMINAL_PROVISIONING_STATES = new Set(['ready', 'failed', 'cancelled', 'disconnected']);
|
||||
|
||||
export function cloneLaunchIoGovernorPayload<T extends GovernedPayload>(value: T): T {
|
||||
if (typeof structuredClone === 'function') {
|
||||
return structuredClone(value);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
export class LaunchIoGovernor {
|
||||
private readonly quietWindowMs: number;
|
||||
private readonly maxStaleAgeMs: number;
|
||||
private readonly stuckLaunchPressureMs: number;
|
||||
private readonly warningCooldownMs: number;
|
||||
private readonly now: () => number;
|
||||
private readonly logger: LaunchIoGovernorLogger;
|
||||
private readonly activeLaunches = new Map<string, ActiveLaunch>();
|
||||
private readonly operations = new Map<
|
||||
LaunchIoGovernorOperationKey,
|
||||
OperationState<GovernedPayload>
|
||||
>();
|
||||
private quietUntil = 0;
|
||||
|
||||
constructor(options: LaunchIoGovernorOptions = {}) {
|
||||
this.quietWindowMs = options.quietWindowMs ?? DEFAULT_LAUNCH_IO_QUIET_WINDOW_MS;
|
||||
this.maxStaleAgeMs = options.maxStaleAgeMs ?? DEFAULT_LAUNCH_IO_MAX_STALE_AGE_MS;
|
||||
this.stuckLaunchPressureMs =
|
||||
options.stuckLaunchPressureMs ?? DEFAULT_LAUNCH_IO_STUCK_PRESSURE_MS;
|
||||
this.warningCooldownMs = options.warningCooldownMs ?? DEFAULT_WARNING_COOLDOWN_MS;
|
||||
this.now = options.now ?? (() => Date.now());
|
||||
this.logger = options.logger ?? {};
|
||||
this.operations.set('teams:list', this.createOperationState('teams:list'));
|
||||
this.operations.set('teams:getAllTasks', this.createOperationState('teams:getAllTasks'));
|
||||
}
|
||||
|
||||
noteLaunchIntent(teamName: string, source = 'unknown'): void {
|
||||
const normalized = this.normalizeTeamName(teamName);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const now = this.now();
|
||||
this.pruneStuckLaunches(now);
|
||||
this.activeLaunches.set(normalized, {
|
||||
teamName: normalized,
|
||||
source,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
this.markDirty('teams:list');
|
||||
this.scheduleDirtyRefreshes(false);
|
||||
}
|
||||
|
||||
noteProvisioningProgress(progress: TeamProvisioningProgress): void {
|
||||
const teamName = this.normalizeTeamName(progress.teamName);
|
||||
if (!teamName) {
|
||||
return;
|
||||
}
|
||||
const now = this.now();
|
||||
this.pruneStuckLaunches(now);
|
||||
this.markDirty('teams:list');
|
||||
|
||||
if (TERMINAL_PROVISIONING_STATES.has(String(progress.state))) {
|
||||
this.activeLaunches.delete(teamName);
|
||||
this.quietUntil = Math.max(this.quietUntil, now + this.quietWindowMs);
|
||||
this.scheduleDirtyRefreshes(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = this.activeLaunches.get(teamName);
|
||||
this.activeLaunches.set(teamName, {
|
||||
teamName,
|
||||
source: existing?.source ?? 'progress',
|
||||
startedAt: existing?.startedAt ?? now,
|
||||
updatedAt: now,
|
||||
});
|
||||
this.scheduleDirtyRefreshes(false);
|
||||
}
|
||||
|
||||
noteTeamChange(event: TeamChangeEvent): void {
|
||||
if (event.type === 'config') {
|
||||
this.markDirty('teams:list');
|
||||
this.markDirty('teams:getAllTasks');
|
||||
} else if (event.type === 'task') {
|
||||
this.markDirty('teams:getAllTasks');
|
||||
}
|
||||
if (this.hasLaunchPressure(this.now())) {
|
||||
this.scheduleDirtyRefreshes(false);
|
||||
}
|
||||
}
|
||||
|
||||
async runSummaryOperation<T extends GovernedPayload>(
|
||||
key: LaunchIoGovernorOperationKey,
|
||||
loadFresh: () => Promise<T>,
|
||||
options: { clone: CloneFn<T> }
|
||||
): Promise<T> {
|
||||
const state = this.getOperationState<T>(key);
|
||||
state.loadFresh = loadFresh;
|
||||
state.clone = options.clone;
|
||||
|
||||
if (this.canServeStale(state)) {
|
||||
if (state.dirty) {
|
||||
this.scheduleDeferredRefresh(key, state, false);
|
||||
}
|
||||
return options.clone(state.cache!.value);
|
||||
}
|
||||
|
||||
return this.runFresh(key, state, false);
|
||||
}
|
||||
|
||||
clearForTests(): void {
|
||||
for (const state of this.operations.values()) {
|
||||
if (state.scheduledRefresh) {
|
||||
clearTimeout(state.scheduledRefresh);
|
||||
}
|
||||
}
|
||||
this.activeLaunches.clear();
|
||||
this.quietUntil = 0;
|
||||
this.operations.clear();
|
||||
this.operations.set('teams:list', this.createOperationState('teams:list'));
|
||||
this.operations.set('teams:getAllTasks', this.createOperationState('teams:getAllTasks'));
|
||||
}
|
||||
|
||||
hasLaunchPressureForTests(): boolean {
|
||||
return this.hasLaunchPressure(this.now());
|
||||
}
|
||||
|
||||
private createOperationState<T extends GovernedPayload>(
|
||||
key: LaunchIoGovernorOperationKey
|
||||
): OperationState<T> {
|
||||
return {
|
||||
key,
|
||||
cache: null,
|
||||
dirty: false,
|
||||
generation: 0,
|
||||
inFlight: null,
|
||||
loadFresh: null,
|
||||
clone: null,
|
||||
scheduledRefresh: null,
|
||||
lastWarningAt: Number.NEGATIVE_INFINITY,
|
||||
};
|
||||
}
|
||||
|
||||
private getOperationState<T extends GovernedPayload>(
|
||||
key: LaunchIoGovernorOperationKey
|
||||
): OperationState<T> {
|
||||
const state = this.operations.get(key);
|
||||
if (!state) {
|
||||
throw new Error(`Unknown launch IO governor operation: ${key}`);
|
||||
}
|
||||
return state as unknown as OperationState<T>;
|
||||
}
|
||||
|
||||
private canServeStale<T extends GovernedPayload>(state: OperationState<T>): boolean {
|
||||
const now = this.now();
|
||||
if (!this.hasLaunchPressure(now) || !state.cache) {
|
||||
return false;
|
||||
}
|
||||
return now - state.cache.cachedAt <= this.maxStaleAgeMs;
|
||||
}
|
||||
|
||||
private async runFresh<T extends GovernedPayload>(
|
||||
key: LaunchIoGovernorOperationKey,
|
||||
state: OperationState<T>,
|
||||
background: boolean
|
||||
): Promise<T> {
|
||||
if (!state.loadFresh || !state.clone) {
|
||||
throw new Error(`Launch IO governor operation ${key} has no loader`);
|
||||
}
|
||||
|
||||
if (state.inFlight) {
|
||||
try {
|
||||
const joined = await state.inFlight;
|
||||
return state.clone(joined);
|
||||
} catch (error) {
|
||||
if (background) {
|
||||
this.warnRefreshFailure(key, state, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const generationAtStart = state.generation;
|
||||
const loadFresh = state.loadFresh;
|
||||
const clone = state.clone;
|
||||
const promise = loadFresh();
|
||||
state.inFlight = promise;
|
||||
|
||||
try {
|
||||
const fresh = await promise;
|
||||
if (state.generation === generationAtStart) {
|
||||
state.cache = {
|
||||
value: clone(fresh),
|
||||
cachedAt: this.now(),
|
||||
};
|
||||
state.dirty = false;
|
||||
}
|
||||
return clone(fresh);
|
||||
} catch (error) {
|
||||
if (background) {
|
||||
this.warnRefreshFailure(key, state, error);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (state.inFlight === promise) {
|
||||
state.inFlight = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private markDirty(key: LaunchIoGovernorOperationKey): void {
|
||||
const state = this.getOperationState(key);
|
||||
state.dirty = true;
|
||||
state.generation += 1;
|
||||
}
|
||||
|
||||
private scheduleDirtyRefreshes(force: boolean): void {
|
||||
for (const [key, state] of this.operations) {
|
||||
if (state.dirty) {
|
||||
this.scheduleDeferredRefresh(key, state, force);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleDeferredRefresh<T extends GovernedPayload>(
|
||||
key: LaunchIoGovernorOperationKey,
|
||||
state: OperationState<T>,
|
||||
force: boolean
|
||||
): void {
|
||||
if (!state.loadFresh || !state.clone) {
|
||||
return;
|
||||
}
|
||||
if (state.scheduledRefresh) {
|
||||
if (!force) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(state.scheduledRefresh);
|
||||
state.scheduledRefresh = null;
|
||||
}
|
||||
|
||||
const delayMs = this.getDelayUntilFreshAllowed(this.now());
|
||||
state.scheduledRefresh = setTimeout(() => {
|
||||
state.scheduledRefresh = null;
|
||||
void this.flushOperation(key);
|
||||
}, delayMs);
|
||||
state.scheduledRefresh.unref?.();
|
||||
}
|
||||
|
||||
private async flushOperation(key: LaunchIoGovernorOperationKey): Promise<void> {
|
||||
const state = this.getOperationState(key);
|
||||
const now = this.now();
|
||||
if (this.hasLaunchPressure(now)) {
|
||||
this.scheduleDeferredRefresh(key, state, true);
|
||||
return;
|
||||
}
|
||||
if (!state.dirty || !state.loadFresh || !state.clone) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.runFresh(key, state, true);
|
||||
} catch {
|
||||
// runFresh already emitted a bounded warning. Keep dirty=true so the next
|
||||
// request or quiet-window timer can retry without losing the last-good cache.
|
||||
}
|
||||
}
|
||||
|
||||
private getDelayUntilFreshAllowed(now: number): number {
|
||||
this.pruneStuckLaunches(now);
|
||||
if (this.activeLaunches.size > 0) {
|
||||
return this.quietWindowMs;
|
||||
}
|
||||
return Math.max(0, this.quietUntil - now);
|
||||
}
|
||||
|
||||
private hasLaunchPressure(now: number): boolean {
|
||||
this.pruneStuckLaunches(now);
|
||||
return this.activeLaunches.size > 0 || now < this.quietUntil;
|
||||
}
|
||||
|
||||
private pruneStuckLaunches(now: number): void {
|
||||
for (const [teamName, launch] of this.activeLaunches) {
|
||||
if (now - launch.updatedAt > this.stuckLaunchPressureMs) {
|
||||
this.activeLaunches.delete(teamName);
|
||||
this.logger.warn?.(
|
||||
`[LaunchIoGovernor] launch pressure expired team=${teamName} source=${launch.source} ageMs=${now - launch.startedAt}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private warnRefreshFailure<T extends GovernedPayload>(
|
||||
key: LaunchIoGovernorOperationKey,
|
||||
state: OperationState<T>,
|
||||
error: unknown
|
||||
): void {
|
||||
const now = this.now();
|
||||
if (now - state.lastWarningAt < this.warningCooldownMs) {
|
||||
return;
|
||||
}
|
||||
state.lastWarningAt = now;
|
||||
const ageMs = state.cache ? now - state.cache.cachedAt : null;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn?.(
|
||||
`[LaunchIoGovernor] deferred refresh failed op=${key} ageMs=${ageMs ?? 'none'} dirty=${state.dirty} activeLaunchCount=${this.activeLaunches.size} error=${errorMessage}`
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeTeamName(teamName: string | undefined | null): string | null {
|
||||
const normalized = teamName?.trim();
|
||||
return normalized && normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@ import {
|
|||
} from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { TeamConfigReader } from './TeamConfigReader';
|
||||
|
||||
const logger = createLogger('TeamBackupService');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -602,6 +604,7 @@ export class TeamBackupService {
|
|||
if (config._backupIdentityId === identityId) return;
|
||||
config._backupIdentityId = identityId;
|
||||
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
||||
TeamConfigReader.invalidateTeam(teamName);
|
||||
} catch {
|
||||
// best-effort — config may not exist yet
|
||||
}
|
||||
|
|
@ -661,6 +664,7 @@ export class TeamBackupService {
|
|||
await fs.promises.mkdir(path.dirname(configDest), { recursive: true });
|
||||
const content = await fs.promises.readFile(configBackup, 'utf8');
|
||||
await atomicWriteAsync(configDest, content);
|
||||
TeamConfigReader.invalidateTeam(teamName);
|
||||
count++;
|
||||
} catch (err: unknown) {
|
||||
logger.warn(`[Backup] Failed to restore config.json for ${teamName}: ${String(err)}`);
|
||||
|
|
|
|||
|
|
@ -37,25 +37,67 @@ const LARGE_CONFIG_BYTES = 512 * 1024;
|
|||
const CONFIG_HEAD_BYTES = 64 * 1024;
|
||||
const MAX_CONFIG_READ_BYTES = 10 * 1024 * 1024; // 10MB hard limit for full config reads
|
||||
const PER_TEAM_READ_TIMEOUT_MS = 5_000;
|
||||
const GET_CONFIG_CACHE_TTL_MS = 750;
|
||||
const GET_CONFIG_SLOW_READ_WARN_MS = 500;
|
||||
const CONFIG_SNAPSHOT_RECENT_STAT_FAILURE_FALLBACK_MS = 5_000;
|
||||
const COARSE_FS_FULL_VERIFY_MS = 1_500;
|
||||
const LIST_TEAMS_CACHE_TTL_MS = 5_000;
|
||||
const MAX_SESSION_HISTORY_IN_SUMMARY = 2000;
|
||||
const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200;
|
||||
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
|
||||
const TEAM_LAUNCH_STATE_FILE = 'launch-state.json';
|
||||
|
||||
export interface TeamConfigFingerprint {
|
||||
size: string;
|
||||
mode: string;
|
||||
dev?: string;
|
||||
ino?: string;
|
||||
mtimeNs?: string;
|
||||
ctimeNs?: string;
|
||||
birthtimeNs?: string;
|
||||
mtimeMs: number;
|
||||
ctimeMs: number;
|
||||
birthtimeMs: number;
|
||||
}
|
||||
|
||||
interface InternalTeamConfigFingerprint extends TeamConfigFingerprint {
|
||||
isFile: boolean;
|
||||
highResolution: boolean;
|
||||
numericSize: number;
|
||||
}
|
||||
|
||||
interface CachedTeamConfig {
|
||||
value: TeamConfig;
|
||||
expiresAt: number;
|
||||
fingerprint: InternalTeamConfigFingerprint | null;
|
||||
verifiedAt: number;
|
||||
fullVerifiedAt: number;
|
||||
}
|
||||
|
||||
type TeamConfigReadMode = 'verified' | 'snapshot';
|
||||
|
||||
interface ConfigReadTiming {
|
||||
teamName: string;
|
||||
mode: TeamConfigReadMode;
|
||||
configPath: string;
|
||||
size: number | null;
|
||||
statMs: number | null;
|
||||
readMs: number | null;
|
||||
parseMs: number | null;
|
||||
totalMs: number;
|
||||
likelyCause: string;
|
||||
fingerprintHighResolution: boolean | null;
|
||||
cacheGeneration: number | null;
|
||||
currentGeneration: number;
|
||||
caller: string | null;
|
||||
}
|
||||
|
||||
interface CachedTeamList {
|
||||
value: TeamSummary[];
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface InFlightTeamList {
|
||||
promise: Promise<TeamSummary[]>;
|
||||
generationAtStart: number;
|
||||
}
|
||||
|
||||
function normalizeProjectPathCandidate(value: unknown): string | undefined {
|
||||
|
|
@ -175,13 +217,130 @@ function cloneConfig(config: TeamConfig): TeamConfig {
|
|||
return structuredClone(config);
|
||||
}
|
||||
|
||||
function cloneTeamSummaries(teams: readonly TeamSummary[]): TeamSummary[] {
|
||||
return structuredClone([...teams]);
|
||||
}
|
||||
|
||||
function classifyConfigReadTiming(timing: {
|
||||
statMs: number | null;
|
||||
readMs: number | null;
|
||||
parseMs: number | null;
|
||||
}): string {
|
||||
const statMs = timing.statMs ?? 0;
|
||||
const readMs = timing.readMs ?? 0;
|
||||
const parseMs = timing.parseMs ?? 0;
|
||||
if (readMs >= 1_000 && readMs >= statMs * 2 && readMs >= parseMs * 2) {
|
||||
return 'io_read_slow';
|
||||
}
|
||||
if (statMs >= 1_000 && statMs >= readMs * 2 && statMs >= parseMs * 2) {
|
||||
return 'io_stat_slow';
|
||||
}
|
||||
if (parseMs >= 500 && parseMs >= readMs && parseMs >= statMs) {
|
||||
return 'json_parse_slow';
|
||||
}
|
||||
if (statMs + readMs >= 1_000) {
|
||||
return 'filesystem_pressure';
|
||||
}
|
||||
return 'mixed_or_unknown';
|
||||
}
|
||||
|
||||
function captureConfigReadCaller(): string | null {
|
||||
const stack = new Error().stack?.split('\n').slice(2) ?? [];
|
||||
const frame = stack.find((line) => {
|
||||
const normalized = line.trim();
|
||||
return (
|
||||
normalized.length > 0 &&
|
||||
!normalized.includes('TeamConfigReader.') &&
|
||||
!normalized.includes('TeamConfigReader.ts') &&
|
||||
!normalized.includes('captureConfigReadCaller') &&
|
||||
!normalized.includes('node:internal')
|
||||
);
|
||||
});
|
||||
return frame?.trim().slice(0, 240) ?? null;
|
||||
}
|
||||
|
||||
export class TeamConfigReader {
|
||||
private static readonly configCacheByPath = new Map<string, CachedTeamConfig>();
|
||||
private static readonly configReadInFlightByPath = new Map<string, Promise<TeamConfig | null>>();
|
||||
private static readonly configStatInFlightByPath = new Map<
|
||||
string,
|
||||
Promise<InternalTeamConfigFingerprint | null>
|
||||
>();
|
||||
private static readonly configGenerationByPath = new Map<string, number>();
|
||||
private static readonly listTeamsCacheByBasePath = new Map<string, CachedTeamList>();
|
||||
private static readonly listTeamsInFlightByBasePath = new Map<string, InFlightTeamList>();
|
||||
private static listTeamsGeneration = 0;
|
||||
|
||||
static clearCacheForTests(): void {
|
||||
TeamConfigReader.configCacheByPath.clear();
|
||||
TeamConfigReader.configReadInFlightByPath.clear();
|
||||
TeamConfigReader.configStatInFlightByPath.clear();
|
||||
TeamConfigReader.configGenerationByPath.clear();
|
||||
TeamConfigReader.listTeamsCacheByBasePath.clear();
|
||||
TeamConfigReader.listTeamsInFlightByBasePath.clear();
|
||||
TeamConfigReader.listTeamsGeneration = 0;
|
||||
}
|
||||
|
||||
static invalidateTeam(teamName: string): void {
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
TeamConfigReader.invalidatePath(configPath);
|
||||
}
|
||||
|
||||
static invalidatePath(configPath: string): void {
|
||||
TeamConfigReader.configCacheByPath.delete(configPath);
|
||||
TeamConfigReader.configReadInFlightByPath.delete(configPath);
|
||||
TeamConfigReader.configStatInFlightByPath.delete(configPath);
|
||||
TeamConfigReader.bumpConfigGeneration(configPath);
|
||||
TeamConfigReader.invalidateListTeamsCache();
|
||||
}
|
||||
|
||||
static invalidateListTeamsCache(): void {
|
||||
TeamConfigReader.listTeamsCacheByBasePath.clear();
|
||||
// Do not clear in-flight scans here. Config writes can arrive while a global
|
||||
// team scan is already running; dropping the in-flight entry starts a second
|
||||
// full scan over all teams and amplifies launch-time filesystem pressure.
|
||||
// The generation check below prevents the stale in-flight result from being
|
||||
// cached after invalidation.
|
||||
TeamConfigReader.listTeamsGeneration += 1;
|
||||
}
|
||||
|
||||
private static invalidatePathForGeneration(
|
||||
configPath: string,
|
||||
expectedGeneration?: number
|
||||
): void {
|
||||
if (
|
||||
typeof expectedGeneration === 'number' &&
|
||||
TeamConfigReader.getConfigGeneration(configPath) !== expectedGeneration
|
||||
) {
|
||||
return;
|
||||
}
|
||||
TeamConfigReader.invalidatePath(configPath);
|
||||
}
|
||||
|
||||
static async primeConfig(
|
||||
teamName: string,
|
||||
config: TeamConfig,
|
||||
fingerprint?: TeamConfigFingerprint | null
|
||||
): Promise<void> {
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
const generation = TeamConfigReader.bumpConfigGeneration(configPath);
|
||||
TeamConfigReader.configReadInFlightByPath.delete(configPath);
|
||||
TeamConfigReader.configStatInFlightByPath.delete(configPath);
|
||||
let internalFingerprint: InternalTeamConfigFingerprint | null = null;
|
||||
if (fingerprint) {
|
||||
internalFingerprint = {
|
||||
...fingerprint,
|
||||
isFile: true,
|
||||
highResolution: Boolean(fingerprint.mtimeNs || fingerprint.ctimeNs),
|
||||
numericSize: Number(fingerprint.size),
|
||||
};
|
||||
} else {
|
||||
internalFingerprint = await TeamConfigReader.readConfigFingerprint(configPath).catch(
|
||||
() => null
|
||||
);
|
||||
}
|
||||
TeamConfigReader.storeConfigCache(configPath, config, internalFingerprint, true, generation);
|
||||
TeamConfigReader.invalidateListTeamsCache();
|
||||
}
|
||||
|
||||
constructor(
|
||||
|
|
@ -190,6 +349,44 @@ export class TeamConfigReader {
|
|||
) {}
|
||||
|
||||
async listTeams(): Promise<TeamSummary[]> {
|
||||
const teamsBasePath = getTeamsBasePath();
|
||||
const cached = TeamConfigReader.listTeamsCacheByBasePath.get(teamsBasePath);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cloneTeamSummaries(cached.value);
|
||||
}
|
||||
|
||||
const existingRequest = TeamConfigReader.listTeamsInFlightByBasePath.get(teamsBasePath);
|
||||
if (
|
||||
existingRequest &&
|
||||
existingRequest.generationAtStart === TeamConfigReader.listTeamsGeneration
|
||||
) {
|
||||
return cloneTeamSummaries(await existingRequest.promise);
|
||||
}
|
||||
|
||||
const request = this.listTeamsUncached(teamsBasePath);
|
||||
const generationAtStart = TeamConfigReader.listTeamsGeneration;
|
||||
TeamConfigReader.listTeamsInFlightByBasePath.set(teamsBasePath, {
|
||||
promise: request,
|
||||
generationAtStart,
|
||||
});
|
||||
|
||||
try {
|
||||
const teams = await request;
|
||||
if (TeamConfigReader.listTeamsGeneration === generationAtStart) {
|
||||
TeamConfigReader.listTeamsCacheByBasePath.set(teamsBasePath, {
|
||||
value: cloneTeamSummaries(teams),
|
||||
expiresAt: Date.now() + LIST_TEAMS_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
return cloneTeamSummaries(teams);
|
||||
} finally {
|
||||
if (TeamConfigReader.listTeamsInFlightByBasePath.get(teamsBasePath)?.promise === request) {
|
||||
TeamConfigReader.listTeamsInFlightByBasePath.delete(teamsBasePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async listTeamsUncached(teamsBasePath: string): Promise<TeamSummary[]> {
|
||||
const worker = getTeamFsWorkerClient();
|
||||
if (worker.isAvailable()) {
|
||||
const startedAt = Date.now();
|
||||
|
|
@ -227,7 +424,7 @@ export class TeamConfigReader {
|
|||
}
|
||||
}
|
||||
|
||||
const teamsDir = getTeamsBasePath();
|
||||
const teamsDir = teamsBasePath;
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
|
|
@ -336,6 +533,21 @@ export class TeamConfigReader {
|
|||
const expectedTeammateNames = new Set<string>();
|
||||
const confirmedArtifactNames = new Set<string>();
|
||||
let metaMembers: TeamMember[] = [];
|
||||
let leadName: string | undefined;
|
||||
let leadColor: string | undefined;
|
||||
|
||||
const captureLeadMember = (m: TeamMember, overwrite = false): void => {
|
||||
if (m.removedAt) return;
|
||||
if (!isLeadMember(m)) return;
|
||||
const name = m.name?.trim();
|
||||
if (name && (overwrite || !leadName)) {
|
||||
leadName = name;
|
||||
}
|
||||
const colorValue = m.color?.trim();
|
||||
if (colorValue && (overwrite || !leadColor)) {
|
||||
leadColor = colorValue;
|
||||
}
|
||||
};
|
||||
|
||||
const mergeMember = (m: TeamMember): void => {
|
||||
const name = m.name?.trim();
|
||||
|
|
@ -360,6 +572,7 @@ export class TeamConfigReader {
|
|||
for (const member of metaMembers) {
|
||||
const name = member.name?.trim();
|
||||
if (!name) continue;
|
||||
captureLeadMember(member);
|
||||
// Summary/memberCount should represent teammates (exclude the lead process).
|
||||
if (name === 'user' || isLeadMember(member)) continue;
|
||||
const key = name.toLowerCase();
|
||||
|
|
@ -385,6 +598,7 @@ export class TeamConfigReader {
|
|||
for (const member of config.members) {
|
||||
if (member && typeof member.name === 'string') {
|
||||
const name = member.name.trim();
|
||||
captureLeadMember(member, true);
|
||||
if (name && name !== 'user' && !isLeadMember(member)) {
|
||||
confirmedArtifactNames.add(name);
|
||||
}
|
||||
|
|
@ -460,6 +674,8 @@ export class TeamConfigReader {
|
|||
taskCount: 0,
|
||||
lastActivity: null,
|
||||
...(members.length > 0 ? { members } : {}),
|
||||
...(leadName ? { leadName } : {}),
|
||||
...(leadColor ? { leadColor } : {}),
|
||||
...(color ? { color } : {}),
|
||||
...(projectPath ? { projectPath } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
|
|
@ -501,11 +717,21 @@ export class TeamConfigReader {
|
|||
: teamName;
|
||||
|
||||
let memberCount = 0;
|
||||
let leadName: string | undefined;
|
||||
let leadColor: string | undefined;
|
||||
try {
|
||||
const metaStore = new TeamMembersMetaStore();
|
||||
const members = await metaStore.getMembers(teamName);
|
||||
const members = await this.membersMetaStore.getMembers(teamName);
|
||||
memberCount = members.filter((member) => {
|
||||
const name = member.name?.trim() ?? '';
|
||||
if (!member.removedAt && isLeadMember(member)) {
|
||||
if (name) {
|
||||
leadName = name;
|
||||
}
|
||||
const color = member.color?.trim();
|
||||
if (color) {
|
||||
leadColor = color;
|
||||
}
|
||||
}
|
||||
if (!name || name === 'user' || isLeadMember(member)) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -524,6 +750,8 @@ export class TeamConfigReader {
|
|||
lastActivity:
|
||||
typeof meta.createdAt === 'number' ? new Date(meta.createdAt).toISOString() : null,
|
||||
color: typeof meta.color === 'string' ? meta.color : undefined,
|
||||
...(leadName ? { leadName } : {}),
|
||||
...(leadColor ? { leadColor } : {}),
|
||||
projectPath: typeof meta.cwd === 'string' ? meta.cwd : undefined,
|
||||
pendingCreate: true,
|
||||
};
|
||||
|
|
@ -533,27 +761,25 @@ export class TeamConfigReader {
|
|||
}
|
||||
|
||||
async getConfig(teamName: string): Promise<TeamConfig | null> {
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
const now = Date.now();
|
||||
const cached = TeamConfigReader.configCacheByPath.get(configPath);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cloneConfig(cached.value);
|
||||
}
|
||||
return this.getConfigVerified(teamName);
|
||||
}
|
||||
|
||||
async getConfigVerified(teamName: string): Promise<TeamConfig | null> {
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
const existingRead = TeamConfigReader.configReadInFlightByPath.get(configPath);
|
||||
if (existingRead) {
|
||||
return this.resolveConfigRead(teamName, configPath, existingRead);
|
||||
}
|
||||
|
||||
const readPromise = this.readConfigFromDisk(teamName, configPath).then((config) => {
|
||||
if (config) {
|
||||
TeamConfigReader.configCacheByPath.set(configPath, {
|
||||
value: cloneConfig(config),
|
||||
expiresAt: Date.now() + GET_CONFIG_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
return config;
|
||||
});
|
||||
const generation = TeamConfigReader.getConfigGeneration(configPath);
|
||||
const readPromise = this.readConfigFromDisk(
|
||||
teamName,
|
||||
configPath,
|
||||
null,
|
||||
true,
|
||||
generation,
|
||||
'verified'
|
||||
);
|
||||
TeamConfigReader.configReadInFlightByPath.set(configPath, readPromise);
|
||||
|
||||
try {
|
||||
|
|
@ -565,6 +791,89 @@ export class TeamConfigReader {
|
|||
}
|
||||
}
|
||||
|
||||
async getConfigSnapshot(teamName: string): Promise<TeamConfig | null> {
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const generationAtStart = TeamConfigReader.getConfigGeneration(configPath);
|
||||
let fingerprint: InternalTeamConfigFingerprint | null;
|
||||
|
||||
try {
|
||||
fingerprint = await TeamConfigReader.getConfigFingerprint(configPath);
|
||||
} catch (error) {
|
||||
if (TeamConfigReader.getConfigGeneration(configPath) !== generationAtStart) {
|
||||
continue;
|
||||
}
|
||||
const cached = TeamConfigReader.configCacheByPath.get(configPath);
|
||||
if (
|
||||
cached &&
|
||||
Date.now() - cached.verifiedAt <= CONFIG_SNAPSHOT_RECENT_STAT_FAILURE_FALLBACK_MS
|
||||
) {
|
||||
logger.warn(
|
||||
`[getConfigSnapshot] config_snapshot_stat_failed_using_recent_cache team=${teamName} error=${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
return cloneConfig(cached.value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (TeamConfigReader.getConfigGeneration(configPath) !== generationAtStart) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fingerprint?.isFile || fingerprint.numericSize > MAX_CONFIG_READ_BYTES) {
|
||||
TeamConfigReader.invalidatePathForGeneration(configPath, generationAtStart);
|
||||
if (fingerprint && fingerprint.numericSize > MAX_CONFIG_READ_BYTES) {
|
||||
logger.warn(
|
||||
`Refusing to load oversized config.json (${fingerprint.numericSize} bytes) for team: ${teamName}`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = TeamConfigReader.configCacheByPath.get(configPath);
|
||||
if (
|
||||
cached?.fingerprint &&
|
||||
TeamConfigReader.fingerprintsEqual(cached.fingerprint, fingerprint)
|
||||
) {
|
||||
const now = Date.now();
|
||||
const mustRevalidateCoarseFingerprint =
|
||||
!fingerprint.highResolution && now - cached.fullVerifiedAt >= COARSE_FS_FULL_VERIFY_MS;
|
||||
if (!mustRevalidateCoarseFingerprint) {
|
||||
cached.verifiedAt = now;
|
||||
return cloneConfig(cached.value);
|
||||
}
|
||||
}
|
||||
|
||||
const existingRead = TeamConfigReader.configReadInFlightByPath.get(configPath);
|
||||
if (existingRead) {
|
||||
return this.resolveConfigRead(teamName, configPath, existingRead);
|
||||
}
|
||||
|
||||
const generation = TeamConfigReader.getConfigGeneration(configPath);
|
||||
const readPromise = this.readConfigFromDisk(
|
||||
teamName,
|
||||
configPath,
|
||||
fingerprint,
|
||||
true,
|
||||
generation,
|
||||
'snapshot'
|
||||
);
|
||||
TeamConfigReader.configReadInFlightByPath.set(configPath, readPromise);
|
||||
try {
|
||||
return await this.resolveConfigRead(teamName, configPath, readPromise);
|
||||
} finally {
|
||||
if (TeamConfigReader.configReadInFlightByPath.get(configPath) === readPromise) {
|
||||
TeamConfigReader.configReadInFlightByPath.delete(configPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async resolveConfigRead(
|
||||
teamName: string,
|
||||
configPath: string,
|
||||
|
|
@ -578,38 +887,164 @@ export class TeamConfigReader {
|
|||
}
|
||||
}
|
||||
|
||||
private static async getConfigFingerprint(
|
||||
configPath: string
|
||||
): Promise<InternalTeamConfigFingerprint | null> {
|
||||
const existing = TeamConfigReader.configStatInFlightByPath.get(configPath);
|
||||
if (existing) return existing;
|
||||
|
||||
const statPromise = TeamConfigReader.readConfigFingerprint(configPath).finally(() => {
|
||||
if (TeamConfigReader.configStatInFlightByPath.get(configPath) === statPromise) {
|
||||
TeamConfigReader.configStatInFlightByPath.delete(configPath);
|
||||
}
|
||||
});
|
||||
TeamConfigReader.configStatInFlightByPath.set(configPath, statPromise);
|
||||
return statPromise;
|
||||
}
|
||||
|
||||
private static async readConfigFingerprint(
|
||||
configPath: string
|
||||
): Promise<InternalTeamConfigFingerprint | null> {
|
||||
let stat: fs.BigIntStats;
|
||||
try {
|
||||
stat = await withReadTimeout(
|
||||
fs.promises.stat(configPath, { bigint: true }),
|
||||
PER_TEAM_READ_TIMEOUT_MS
|
||||
);
|
||||
} catch (error) {
|
||||
const code = typeof error === 'object' && error ? (error as { code?: unknown }).code : null;
|
||||
if (code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const highResStat = stat as fs.BigIntStats & {
|
||||
mtimeNs?: bigint;
|
||||
ctimeNs?: bigint;
|
||||
birthtimeNs?: bigint;
|
||||
};
|
||||
const mtimeNs = highResStat.mtimeNs;
|
||||
const ctimeNs = highResStat.ctimeNs;
|
||||
const birthtimeNs = highResStat.birthtimeNs;
|
||||
|
||||
return {
|
||||
size: stat.size.toString(),
|
||||
mode: stat.mode.toString(),
|
||||
dev: stat.dev.toString(),
|
||||
ino: stat.ino.toString(),
|
||||
mtimeNs: typeof mtimeNs === 'bigint' ? mtimeNs.toString() : undefined,
|
||||
ctimeNs: typeof ctimeNs === 'bigint' ? ctimeNs.toString() : undefined,
|
||||
birthtimeNs: typeof birthtimeNs === 'bigint' ? birthtimeNs.toString() : undefined,
|
||||
mtimeMs: Number(stat.mtimeMs),
|
||||
ctimeMs: Number(stat.ctimeMs),
|
||||
birthtimeMs: Number(stat.birthtimeMs),
|
||||
isFile: stat.isFile(),
|
||||
highResolution: typeof mtimeNs === 'bigint' || typeof ctimeNs === 'bigint',
|
||||
numericSize: Number(stat.size),
|
||||
};
|
||||
}
|
||||
|
||||
private static fingerprintsEqual(
|
||||
a: InternalTeamConfigFingerprint,
|
||||
b: InternalTeamConfigFingerprint
|
||||
): boolean {
|
||||
return (
|
||||
a.size === b.size &&
|
||||
a.mode === b.mode &&
|
||||
a.dev === b.dev &&
|
||||
a.ino === b.ino &&
|
||||
a.mtimeNs === b.mtimeNs &&
|
||||
a.ctimeNs === b.ctimeNs &&
|
||||
a.birthtimeNs === b.birthtimeNs &&
|
||||
a.mtimeMs === b.mtimeMs &&
|
||||
a.ctimeMs === b.ctimeMs &&
|
||||
a.birthtimeMs === b.birthtimeMs
|
||||
);
|
||||
}
|
||||
|
||||
private static storeConfigCache(
|
||||
configPath: string,
|
||||
config: TeamConfig,
|
||||
fingerprint: InternalTeamConfigFingerprint | null,
|
||||
fullVerified: boolean,
|
||||
expectedGeneration?: number
|
||||
): void {
|
||||
if (
|
||||
typeof expectedGeneration === 'number' &&
|
||||
TeamConfigReader.getConfigGeneration(configPath) !== expectedGeneration
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const previous = TeamConfigReader.configCacheByPath.get(configPath);
|
||||
TeamConfigReader.configCacheByPath.set(configPath, {
|
||||
value: cloneConfig(config),
|
||||
fingerprint,
|
||||
verifiedAt: now,
|
||||
fullVerifiedAt: fullVerified ? now : (previous?.fullVerifiedAt ?? now),
|
||||
});
|
||||
}
|
||||
|
||||
private static getConfigGeneration(configPath: string): number {
|
||||
return TeamConfigReader.configGenerationByPath.get(configPath) ?? 0;
|
||||
}
|
||||
|
||||
private static bumpConfigGeneration(configPath: string): number {
|
||||
const next = TeamConfigReader.getConfigGeneration(configPath) + 1;
|
||||
TeamConfigReader.configGenerationByPath.set(configPath, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
private async readConfigFromDisk(
|
||||
teamName: string,
|
||||
configPath: string
|
||||
configPath: string,
|
||||
knownFingerprint: InternalTeamConfigFingerprint | null = null,
|
||||
updateCache = false,
|
||||
cacheGeneration?: number,
|
||||
mode: TeamConfigReadMode = 'verified'
|
||||
): Promise<TeamConfig | null> {
|
||||
const startedAt = performance.now();
|
||||
const caller = captureConfigReadCaller();
|
||||
let size: number | null = null;
|
||||
let statMs: number | null = null;
|
||||
let readMs: number | null = null;
|
||||
let parseMs: number | null = null;
|
||||
let fingerprintHighResolution: boolean | null = knownFingerprint?.highResolution ?? null;
|
||||
|
||||
const buildTiming = (): ConfigReadTiming => ({
|
||||
teamName,
|
||||
mode,
|
||||
configPath,
|
||||
size,
|
||||
statMs,
|
||||
readMs,
|
||||
parseMs,
|
||||
totalMs: Math.round(performance.now() - startedAt),
|
||||
likelyCause: classifyConfigReadTiming({ statMs, readMs, parseMs }),
|
||||
fingerprintHighResolution,
|
||||
cacheGeneration: cacheGeneration ?? null,
|
||||
currentGeneration: TeamConfigReader.getConfigGeneration(configPath),
|
||||
caller,
|
||||
});
|
||||
|
||||
try {
|
||||
const statStartedAt = performance.now();
|
||||
const stat = await fs.promises.stat(configPath);
|
||||
const fingerprint =
|
||||
knownFingerprint ?? (await TeamConfigReader.getConfigFingerprint(configPath));
|
||||
statMs = Math.round(performance.now() - statStartedAt);
|
||||
size = stat.size;
|
||||
size = fingerprint?.numericSize ?? null;
|
||||
fingerprintHighResolution = fingerprint?.highResolution ?? null;
|
||||
|
||||
// Safety: refuse special files and huge/binary configs
|
||||
if (!stat.isFile()) {
|
||||
if (!fingerprint?.isFile) {
|
||||
TeamConfigReader.invalidatePathForGeneration(configPath, cacheGeneration);
|
||||
return null;
|
||||
}
|
||||
if (stat.size > MAX_CONFIG_READ_BYTES) {
|
||||
if (fingerprint.numericSize > MAX_CONFIG_READ_BYTES) {
|
||||
TeamConfigReader.invalidatePathForGeneration(configPath, cacheGeneration);
|
||||
logger.warn(
|
||||
`Refusing to load oversized config.json (${stat.size} bytes) for team: ${teamName}`
|
||||
`Refusing to load oversized config.json (${fingerprint.numericSize} bytes) for team: ${teamName}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
|
@ -622,6 +1057,7 @@ export class TeamConfigReader {
|
|||
const config = JSON.parse(raw) as TeamConfig;
|
||||
parseMs = Math.round(performance.now() - parseStartedAt);
|
||||
if (typeof config.name !== 'string' || config.name.trim() === '') {
|
||||
TeamConfigReader.invalidatePathForGeneration(configPath, cacheGeneration);
|
||||
return null;
|
||||
}
|
||||
const resolvedProjectPath = resolveProjectPathFromConfig(config);
|
||||
|
|
@ -633,10 +1069,24 @@ export class TeamConfigReader {
|
|||
if (totalMs >= GET_CONFIG_SLOW_READ_WARN_MS) {
|
||||
logger.warn(`[getConfig] slow read diag=${JSON.stringify(buildTiming())}`);
|
||||
}
|
||||
if (updateCache) {
|
||||
TeamConfigReader.storeConfigCache(
|
||||
configPath,
|
||||
resolvedConfig,
|
||||
fingerprint,
|
||||
true,
|
||||
cacheGeneration
|
||||
);
|
||||
}
|
||||
return resolvedConfig;
|
||||
} catch (error) {
|
||||
TeamConfigReader.invalidatePathForGeneration(configPath, cacheGeneration);
|
||||
if (error instanceof FileReadTimeoutError) {
|
||||
logger.warn(`[getConfig] ${error.message} diag=${JSON.stringify(buildTiming())}`);
|
||||
} else if (error instanceof Error && error.message === 'Team config read timeout') {
|
||||
logger.warn(
|
||||
`[getConfig] Timed out after ${PER_TEAM_READ_TIMEOUT_MS}ms reading ${configPath} diag=${JSON.stringify(buildTiming())}`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -664,10 +1114,7 @@ export class TeamConfigReader {
|
|||
}
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
|
||||
TeamConfigReader.configCacheByPath.set(configPath, {
|
||||
value: cloneConfig(config),
|
||||
expiresAt: Date.now() + GET_CONFIG_CACHE_TTL_MS,
|
||||
});
|
||||
await TeamConfigReader.primeConfig(teamName, config);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ import type {
|
|||
TeamConfig,
|
||||
TeamCreateConfigRequest,
|
||||
TeamCreateRequest,
|
||||
TeamGetDataOptions,
|
||||
TeamMember,
|
||||
TeamMemberActivityMeta,
|
||||
TeamMemberSnapshot,
|
||||
|
|
@ -112,7 +113,7 @@ const PROCESS_HEALTH_INTERVAL_MS = 2_000;
|
|||
const TASK_MAP_YIELD_EVERY = 250;
|
||||
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
|
||||
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
|
||||
const MEMBER_RUNTIME_ADVISORY_SNAPSHOT_BUDGET_MS = 750;
|
||||
const MEMBER_RUNTIME_ADVISORY_SNAPSHOT_BUDGET_MS = 250;
|
||||
const MIXED_TEAM_LIVE_MUTATION_BLOCK_MESSAGE =
|
||||
'Live roster mutation on a running mixed team is not supported in V1. Stop the team, edit the roster, then relaunch.';
|
||||
|
||||
|
|
@ -267,6 +268,25 @@ function extractPassiveUserPeerSummaryBody(text: string): string | null {
|
|||
return body.length > 0 ? body : null;
|
||||
}
|
||||
|
||||
function readConfigForUiSnapshot(
|
||||
configReader: TeamConfigReader & {
|
||||
getConfigSnapshot?: (teamName: string) => Promise<TeamConfig | null>;
|
||||
},
|
||||
teamName: string
|
||||
): Promise<TeamConfig | null> {
|
||||
return typeof configReader.getConfigSnapshot === 'function'
|
||||
? configReader.getConfigSnapshot(teamName)
|
||||
: configReader.getConfig(teamName);
|
||||
}
|
||||
|
||||
function createUiSnapshotProjectResolver(
|
||||
configReader: TeamConfigReader
|
||||
): TeamTranscriptProjectResolver {
|
||||
return new TeamTranscriptProjectResolver({
|
||||
getConfig: (teamName) => readConfigForUiSnapshot(configReader, teamName),
|
||||
});
|
||||
}
|
||||
|
||||
function isExplicitLeadRole(role: string | undefined): boolean {
|
||||
const normalized = role?.trim().toLowerCase();
|
||||
return normalized === 'lead' || normalized === 'team lead' || normalized === 'team-lead';
|
||||
|
|
@ -385,13 +405,13 @@ export class TeamDataService {
|
|||
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(),
|
||||
private memberRuntimeAdvisoryService: TeamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(),
|
||||
private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache(),
|
||||
private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver(
|
||||
private readonly projectResolver: TeamTranscriptProjectResolver = createUiSnapshotProjectResolver(
|
||||
configReader
|
||||
),
|
||||
private readonly launchStateStore: TeamLaunchStateStore = new TeamLaunchStateStore()
|
||||
) {
|
||||
this.messageFeedService = new TeamMessageFeedService({
|
||||
getConfig: (teamName) => this.configReader.getConfig(teamName),
|
||||
getConfig: (teamName) => this.readSnapshotConfig(teamName),
|
||||
getInboxMessages: (teamName) => this.inboxReader.getMessages(teamName),
|
||||
getLeadSessionMessages: (teamName, config) => this.extractLeadSessionTexts(teamName, config),
|
||||
getSentMessages: (teamName) => this.sentMessagesStore.readMessages(teamName),
|
||||
|
|
@ -399,6 +419,14 @@ export class TeamDataService {
|
|||
this.memberActivityMetaService = new MemberActivityMetaService(this.messageFeedService);
|
||||
}
|
||||
|
||||
private readSnapshotConfig(teamName: string): Promise<TeamConfig | null> {
|
||||
return readConfigForUiSnapshot(this.configReader, teamName);
|
||||
}
|
||||
|
||||
private invalidateGlobalTaskProjectionCache(): void {
|
||||
TeamTaskReader.invalidateAllTasksCache();
|
||||
}
|
||||
|
||||
private getController(teamName: string): AgentTeamsController {
|
||||
return this.controllerFactory(teamName);
|
||||
}
|
||||
|
|
@ -491,7 +519,7 @@ export class TeamDataService {
|
|||
request.catch(() => {
|
||||
/* background advisory refresh is best-effort */
|
||||
});
|
||||
logger.warn(
|
||||
logger.debug(
|
||||
`getTeamData team=${teamName} member runtime advisories exceeded ${MEMBER_RUNTIME_ADVISORY_SNAPSHOT_BUDGET_MS}ms budget; continuing without advisories for this snapshot`
|
||||
);
|
||||
return new Map();
|
||||
|
|
@ -842,7 +870,7 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
async getTaskChangePresence(teamName: string): Promise<Record<string, TaskChangePresenceState>> {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await this.readSnapshotConfig(teamName);
|
||||
if (!config) {
|
||||
throw new Error(`Team not found: ${teamName}`);
|
||||
}
|
||||
|
|
@ -1077,6 +1105,7 @@ export class TeamDataService {
|
|||
config.deletedAt = new Date().toISOString();
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
||||
await TeamConfigReader.primeConfig(teamName, config);
|
||||
}
|
||||
|
||||
async restoreTeam(teamName: string): Promise<void> {
|
||||
|
|
@ -1087,17 +1116,21 @@ export class TeamDataService {
|
|||
delete config.deletedAt;
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
||||
await TeamConfigReader.primeConfig(teamName, config);
|
||||
}
|
||||
|
||||
async permanentlyDeleteTeam(teamName: string): Promise<void> {
|
||||
const teamsDir = path.join(getTeamsBasePath(), teamName);
|
||||
await fs.promises.rm(teamsDir, { recursive: true, force: true });
|
||||
TeamConfigReader.invalidateTeam(teamName);
|
||||
|
||||
const tasksDir = path.join(getTasksBasePath(), teamName);
|
||||
await fs.promises.rm(tasksDir, { recursive: true, force: true });
|
||||
TeamTaskReader.invalidateAllTasksCache();
|
||||
}
|
||||
|
||||
async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
|
||||
async getTeamData(teamName: string, options?: TeamGetDataOptions): Promise<TeamViewSnapshot> {
|
||||
const includeMemberBranches = options?.includeMemberBranches !== false;
|
||||
const startedAt = Date.now();
|
||||
const marks: Record<string, number> = {};
|
||||
const mark = (label: string): void => {
|
||||
|
|
@ -1113,7 +1146,7 @@ export class TeamDataService {
|
|||
return typeof fromTs === 'number' && typeof toTs === 'number' ? toTs - fromTs : -1;
|
||||
};
|
||||
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await this.readSnapshotConfig(teamName);
|
||||
if (!config) {
|
||||
throw new Error(`Team not found: ${teamName}`);
|
||||
}
|
||||
|
|
@ -1345,8 +1378,11 @@ export class TeamDataService {
|
|||
}
|
||||
mark('runtimeAdvisories');
|
||||
|
||||
// Enrich members with git branch when it differs from lead's branch
|
||||
await this.enrichMemberBranches(members, config);
|
||||
// Enrich members with git branch when it differs from lead's branch.
|
||||
// UI-first reads can skip this because the renderer hydrates branches through branch sync.
|
||||
if (includeMemberBranches) {
|
||||
await this.enrichMemberBranches(members, config);
|
||||
}
|
||||
mark('enrichBranches');
|
||||
mark('syncComments');
|
||||
|
||||
|
|
@ -1361,6 +1397,7 @@ export class TeamDataService {
|
|||
const totalMs = Date.now() - startedAt;
|
||||
if (totalMs >= 1500) {
|
||||
const counts = `counts=tasks:${tasks.length},inboxNames:${inboxNames.length},members:${members.length},processes:${processes.length}`;
|
||||
const branchMode = includeMemberBranches ? 'full' : 'skipped';
|
||||
logger.warn(
|
||||
`getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince(
|
||||
'inboxNames'
|
||||
|
|
@ -1381,7 +1418,7 @@ export class TeamDataService {
|
|||
)}/enrichBranches=${msBetween(
|
||||
'runtimeAdvisories',
|
||||
'enrichBranches'
|
||||
)}/processes=${msBetween('syncComments', 'processes')} ${counts}${
|
||||
)}/processes=${msBetween('syncComments', 'processes')} branchMode=${branchMode} ${counts}${
|
||||
warnings.length > 0 ? ` warnings=${warnings.join('|')}` : ''
|
||||
}`
|
||||
);
|
||||
|
|
@ -1867,7 +1904,7 @@ export class TeamDataService {
|
|||
|
||||
let projectPath: string | undefined;
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||
projectPath = config?.projectPath;
|
||||
} catch {
|
||||
/* best-effort */
|
||||
|
|
@ -1889,6 +1926,7 @@ export class TeamDataService {
|
|||
...(request.promptTaskRefs?.length ? { promptTaskRefs: request.promptTaskRefs } : {}),
|
||||
...(shouldStart ? { startImmediately: true } : {}),
|
||||
}) as TeamTask;
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
|
||||
// Controller's maybeNotifyAssignedOwner skips the lead (owner === lead).
|
||||
// For user-created tasks with startImmediately, ensure the lead also gets notified.
|
||||
|
|
@ -1917,6 +1955,7 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
this.getController(teamName).tasks.startTask(taskId, 'user');
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
|
||||
if (task.owner) {
|
||||
try {
|
||||
|
|
@ -1969,6 +2008,7 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
this.getController(teamName).tasks.startTask(taskId, 'user');
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
|
||||
if (task.owner) {
|
||||
await this.sendUserTaskStartNotification(teamName, task);
|
||||
|
|
@ -2024,6 +2064,7 @@ export class TeamDataService {
|
|||
actor?: string
|
||||
): Promise<void> {
|
||||
this.getController(teamName).tasks.setTaskStatus(taskId, status, actor);
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -2083,10 +2124,12 @@ export class TeamDataService {
|
|||
|
||||
async softDeleteTask(teamName: string, taskId: string): Promise<void> {
|
||||
this.getController(teamName).tasks.softDeleteTask(taskId, 'user');
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
}
|
||||
|
||||
async restoreTask(teamName: string, taskId: string): Promise<void> {
|
||||
this.getController(teamName).tasks.restoreTask(taskId, 'user');
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
}
|
||||
|
||||
async getDeletedTasks(teamName: string): Promise<TeamTask[]> {
|
||||
|
|
@ -2095,6 +2138,7 @@ export class TeamDataService {
|
|||
|
||||
async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise<void> {
|
||||
this.getController(teamName).tasks.setTaskOwner(taskId, owner);
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
}
|
||||
|
||||
async updateTaskFields(
|
||||
|
|
@ -2103,6 +2147,7 @@ export class TeamDataService {
|
|||
fields: { subject?: string; description?: string }
|
||||
): Promise<void> {
|
||||
this.getController(teamName).tasks.updateTaskFields(taskId, fields);
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
}
|
||||
|
||||
async addTaskAttachment(
|
||||
|
|
@ -2114,6 +2159,7 @@ export class TeamDataService {
|
|||
taskId,
|
||||
meta as unknown as Record<string, unknown>
|
||||
);
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
}
|
||||
|
||||
async removeTaskAttachment(
|
||||
|
|
@ -2122,6 +2168,7 @@ export class TeamDataService {
|
|||
attachmentId: string
|
||||
): Promise<void> {
|
||||
this.getController(teamName).tasks.removeTaskAttachment(taskId, attachmentId);
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
}
|
||||
|
||||
async setTaskNeedsClarification(
|
||||
|
|
@ -2130,6 +2177,7 @@ export class TeamDataService {
|
|||
value: 'lead' | 'user' | null
|
||||
): Promise<void> {
|
||||
this.getController(teamName).tasks.setNeedsClarification(taskId, value);
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
}
|
||||
|
||||
async addTaskRelationship(
|
||||
|
|
@ -2143,6 +2191,7 @@ export class TeamDataService {
|
|||
targetId,
|
||||
type === 'blockedBy' ? 'blocked-by' : type
|
||||
);
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
}
|
||||
|
||||
async removeTaskRelationship(
|
||||
|
|
@ -2156,6 +2205,7 @@ export class TeamDataService {
|
|||
targetId,
|
||||
type === 'blockedBy' ? 'blocked-by' : type
|
||||
);
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
}
|
||||
|
||||
async addTaskComment(
|
||||
|
|
@ -2172,6 +2222,7 @@ export class TeamDataService {
|
|||
attachments,
|
||||
taskRefs,
|
||||
}) as { task?: TeamTask; comment?: TaskComment };
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
const comment =
|
||||
addResult.comment ??
|
||||
({
|
||||
|
|
@ -2192,7 +2243,7 @@ export class TeamDataService {
|
|||
let enrichedRequest = request;
|
||||
if (!enrichedRequest.leadSessionId) {
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||
if (config?.leadSessionId) {
|
||||
enrichedRequest = { ...enrichedRequest, leadSessionId: config.leadSessionId };
|
||||
}
|
||||
|
|
@ -2265,7 +2316,7 @@ export class TeamDataService {
|
|||
|
||||
private async resolveLeadName(teamName: string): Promise<string> {
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||
return this.resolveLeadNameFromConfig(config);
|
||||
} catch {
|
||||
return 'team-lead';
|
||||
|
|
@ -2276,7 +2327,7 @@ export class TeamDataService {
|
|||
teamName: string
|
||||
): Promise<{ leadName: string; leadSessionId?: string }> {
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||
return {
|
||||
leadName: this.resolveLeadNameFromConfig(config),
|
||||
leadSessionId: config?.leadSessionId,
|
||||
|
|
@ -2593,7 +2644,7 @@ export class TeamDataService {
|
|||
const recoverPending = options?.recoverPending === true;
|
||||
let config: TeamConfig | null = null;
|
||||
try {
|
||||
config = await this.configReader.getConfig(teamName);
|
||||
config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
|
@ -2748,7 +2799,7 @@ export class TeamDataService {
|
|||
): Promise<SendMessageResult> {
|
||||
let leadSessionId: string | undefined;
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||
leadSessionId = config?.leadSessionId;
|
||||
} catch {
|
||||
// non-critical — proceed without sessionId
|
||||
|
|
@ -2781,7 +2832,7 @@ export class TeamDataService {
|
|||
|
||||
async getLeadMemberName(teamName: string): Promise<string | null> {
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await readConfigForUiSnapshot(this.configReader, teamName);
|
||||
|
||||
// Check config.json members first (Claude Code-created teams)
|
||||
if (config?.members?.length) {
|
||||
|
|
@ -2806,7 +2857,7 @@ export class TeamDataService {
|
|||
|
||||
async getTeamDisplayName(teamName: string): Promise<string> {
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await this.readSnapshotConfig(teamName);
|
||||
const displayName = config?.name?.trim();
|
||||
return displayName || teamName;
|
||||
} catch {
|
||||
|
|
@ -2819,7 +2870,7 @@ export class TeamDataService {
|
|||
projectPath?: string;
|
||||
}> {
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const config = await this.readSnapshotConfig(teamName);
|
||||
const displayName = config?.name?.trim() || teamName;
|
||||
const projectPath =
|
||||
typeof config?.projectPath === 'string' && config.projectPath.trim().length > 0
|
||||
|
|
@ -2917,6 +2968,7 @@ export class TeamDataService {
|
|||
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, {
|
||||
providerBackendId: request.providerBackendId,
|
||||
});
|
||||
TeamConfigReader.invalidateListTeamsCache();
|
||||
}
|
||||
|
||||
async reconcileTeamArtifacts(
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue