diff --git a/README.md b/README.md index c3e70e24..709df800 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@

- 100% free, open source. Auto-detects Claude/Codex. Use the provider access you already have - subscriptions or API keys. Not just coding agents. + Free desktop app for AI agent teams. Auto-detects Claude/Codex. Use the provider access you already have - subscriptions or API keys. Not just coding agents.

demo @@ -49,6 +49,7 @@ https://github.com/user-attachments/assets/35e27989-726d-4059-8662-bae610e46b42 ## Installation No prerequisites - the app can detect supported runtimes/providers and guide setup from the UI. +If you want the freshest version, clone the repo and run it from the `dev` branch. @@ -106,7 +107,7 @@ No prerequisites - the app can detect supported runtimes/providers and guide set ## What is this -A local orchestration layer for AI agent teams across Claude and Codex. +An orchestration layer for AI agent teams across Claude and Codex. - **Claude + Codex orchestration** — auto-detect available Claude/Codex runtimes and use the provider access you already have - subscriptions or API keys - **Assemble your team** — create agent teams with different roles that work autonomously in parallel @@ -126,6 +127,8 @@ A local orchestration layer for AI agent teams across Claude and Codex. - **Task creation with attachments** — send a message to the team lead with any attached images. The lead will automatically create a fully described task and attach your files directly to the task for complete context. +- **Auto-resume after rate limits** — when the lead hits a Claude rate limit and the reset time is known, the app can automatically nudge the lead to continue once the cooldown has passed + - **Deep session analysis** — detailed breakdown of what happened in each agent session: bash commands, reasoning, subprocesses - **Smart task-to-log/changes matching** — automatically links session logs/changes to specific tasks @@ -212,7 +215,7 @@ No. The app guides runtime detection/setup and provider authentication from the
Does it read or upload my code?
-No. Everything runs locally. The app reads local runtime/session data to power the UI - your source code is never sent anywhere. +The app is not a cloud code-sync service. It reads local runtime/session data to power the UI, and your project stays on your machine unless you choose a provider/runtime path that sends data to that provider. In `multimodel` mode, startup may also perform runtime access and capability checks before launch.
@@ -224,7 +227,7 @@ Yes. Agents send direct messages, create shared tasks, and leave comments - all
Is it free?
-Yes, completely 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. +Yes, 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.
@@ -251,7 +254,7 @@ Yes. Run multiple teams in one project or across different projects, even simult ## Tech stack -Electron 40, React 19, TypeScript 5, Tailwind CSS 3, Zustand 4. Data from `~/.claude/` (session logs, todos, tasks). No cloud backend — everything runs locally. +Electron 40, React 19, TypeScript 5, Tailwind CSS 3, Zustand 4. Data from `~/.claude/` (session logs, todos, tasks). The desktop app works with local runtime/session state, while some runtime modes may also use provider or startup capability services when required.
Build from source diff --git a/docs/extensions/plugin-kit-ai-integration-plan.md b/docs/extensions/plugin-kit-ai-integration-plan.md new file mode 100644 index 00000000..2023ced8 --- /dev/null +++ b/docs/extensions/plugin-kit-ai-integration-plan.md @@ -0,0 +1,4092 @@ +# plugin-kit-ai Integration Plan for Extensions Plugins + +**Status**: Draft +**Date**: 2026-04-18 +**Owner repos**: + +- `claude_team` +- `plugin-kit-ai` + +## Purpose + +Replace the current Claude-only plugin backend in `claude_team` with a provider-aware backend powered by `plugin-kit-ai`, while keeping the existing `Extensions -> Plugins` UI. + +The integration must support two different truths at the same time: + +- **Universal plugins** managed through `plugin-kit-ai` +- **Native external installed plugins** that already exist in Claude or Codex and are not yet part of universal managed state + +Those are different objects and must remain different in UI, state, and actions. + +## How To Use This Plan + +This document is intentionally long because it combines: + +- product model +- backend contract spec +- rollout spec +- app integration rules + +Use it in this order: + +1. read `One-Page Summary` +2. read `Phase 0 Decision Checkpoints` +3. read `Current backend blockers that shape the plan` +4. for backend work: + - read `Recommended Backend Basis By Surface` + - read `Managed Lifecycle Model` + - read `JSON Contract Style` + - read `Recommended Contract Drafts` +5. for app work: + - read `Entry Derivation and Conflict Resolution` + - read `UI and Entry Model in claude_team` + - read `claude_team Changes Required` +6. before shipping: + - read `Rollout Phases` + - read `PR Exit Criteria` + - read `No-Go Conditions` + +If any implementation decision contradicts the earlier sections, the earlier sections win. + +## One-Page Summary + +### What we are building + +- keep the current `Extensions -> Plugins` UI in `claude_team` +- bundle `plugin-kit-ai` as a backend engine +- use `plugin-kit-ai` for: + - universal catalog + - native discovery + - universal lifecycle actions + +### What we are not building + +- not embedding a second plugin UI +- not parsing prose CLI output +- not scraping repo layout from `claude_team` +- not pretending native installed plugins are the same thing as universal managed plugins +- not promising `local` scope before backend really supports it + +### User-visible outcome + +- installed plugins come first +- universal plugins are the main storefront +- native installed Claude/Codex plugins stay visible and are labeled honestly +- install/update/remove/repair results stay target-granular +- mutation results do not lie about partial progress: + - applied + - rolled back + - degraded state persisted + +### Phase 1 can and cannot promise + +Phase 1 can safely promise: + +- truthful mixed rendering of universal and native external entries +- truthful managed lifecycle status for installed universal entries +- explicit degraded / rolled-back mutation outcomes +- explicit adopted-target update semantics when backend provides them + +Phase 1 must **not** promise: + +- parallel managed installs of the same integration across scopes or workspaces +- fake `local` scope parity +- destructive actions for native external entries +- instant local-only previews for every lifecycle action +- shared universal card metadata that silently reflects one target override +- invisible policy-driven target adoption during update +- target-scoped `update` or `remove` UX until the public backend command surface exposes that capability consistently + +### Quick truth map + +| User question | Backend surface | +|---|---| +| What universal plugins exist? | `catalog` | +| What native plugins already exist outside managed state? | `discover` | +| What universal plugins are managed right now? | `list` | +| Which managed targets need attention or repair? | `doctor` | +| How fresh is the managed lifecycle truth? | lifecycle grouped metadata such as `last_checked_at` and `last_updated_at` | +| Can I install/update/remove/repair this universal plugin? | lifecycle `plan/result` contracts | +| What detail text and README should the storefront show? | `catalog` detail path | +| What detail explains current target drift or activation? | lifecycle target-detail payload | + +### Command quick reference + +| Command surface | Can be networked in preview? | Can mutate native state? | Phase-1 app expectation | +|---|---|---|---| +| `catalog` | no | no | fast storefront truth | +| `discover` | no | no | fast native-installed truth | +| `list` | no | no | fast managed ownership truth | +| `doctor` | no | no | fast health + recovery truth | +| `add` dry-run | yes | no | review can show source-checking state | +| `update` dry-run | yes | no | review can show source-checking state and adopted-target work; phase-1 public command surface is integration-wide, not target-filtered | +| `remove` dry-run | no | no | cheap review, no source resolution required; phase-1 public command surface is integration-wide, not target-filtered | +| `repair` dry-run | no | no | cheap review, no source resolution required | +| apply mutations | yes | yes | explicit progress + explicit outcome class | + +### Lifecycle action families from current code + +Current `integrationctl` already has several different action families. + +They are not interchangeable and the app should not flatten them into one generic “plugin action” concept. + +| Action family | Current action ids | Key behavior from code | Phase-1 app status | +|---|---|---|---| +| add new managed integration | `add` | resolves requested source, plans/installs one or more targets | in scope | +| mutate existing managed integration | `update_version` | resolves current source, may also adopt newly supported targets | in scope | +| remove managed targets | `remove_orphaned_target` | dry-run can stay local, apply may resolve source and mutate state | in scope | +| repair managed drift | `repair_drift` | dry-run can stay local, apply may resolve source and persist degraded state on failure | in scope | +| toggle managed target | `enable_target`, `disable_target` | single-target toggle lane, distinct summary and apply path | out of scope for phase 1 | + +### Phase-1 action subset + +Phase 1 plugin lifecycle in `claude_team` should expose only: + +- `add` +- `update_version` +- `remove_orphaned_target` +- `repair_drift` + +Phase 1 should not expose: + +- `enable_target` +- `disable_target` +- `sync` +- target-scoped `update` or `remove` controls unless backend command contracts add them explicitly + +Why: + +- they are lower-value than core lifecycle +- they have distinct semantics that would widen the UI surface +- they are better added only after mixed-entry rendering and primary lifecycle flows are proven +- current public command surface is not yet symmetric for target-filtered existing mutations + +### Safe delivery order + +1. add stable JSON contracts to `plugin-kit-ai` +2. add universal catalog in `plugin-kit-ai` +3. add native discovery in `plugin-kit-ai` +4. integrate read-only mixed plugin view in `claude_team` +5. add universal lifecycle actions in `claude_team` +6. consider optional native convenience flows only later + +### Current backend blockers that shape the plan + +These come from current code and are the main reason the rollout has to stay phased: + +- managed state is effectively keyed by `integration_id`, not by a richer record key +- project-sensitive service construction still depends on implicit `cwd` +- current `integrations` manage commands still do not accept explicit `--workspace-root` +- current `update_version` planning may also produce adopted-target work based on manifest drift and policy +- current `update_version` dry-run already resolves current source and may therefore clone/fetch remote source before review is shown +- dry-run planning can look clean and still fail later on apply because same-`integration_id` conflict is enforced only under state lock +- current public CLI command surface is asymmetric for existing-target filtering: + - `repair` exposes `--target` + - `enable/disable` can require `--target` + - `update/remove` currently do not expose `--target`, even though the underlying usecase model already has a target field +- current lifecycle `Report.Targets` are too flat for app use and lose integration-level grouping +- current lifecycle `TargetReport` also drops some adapter-level detail that the app will need for truthful detail views +- current lifecycle `TargetReport` currently drops concrete detail such as: + - target warnings + - owned native objects + - observed native objects + - settings files + - config precedence context + - paths touched + - commands +- current lifecycle report also drops managed freshness fields such as `last_checked_at` and `last_updated_at` + +### Execution blueprint + +| Step | Repo | Main packages | What changes | Why this step exists | +|---|---|---|---|---| +| 0 | `plugin-kit-ai` | `integrationctl`, CLI command layer | freeze state identity, conflict timing, app-mode workspace semantics | prevents bad contracts from being versioned | +| 1 | `plugin-kit-ai` | CLI JSON output layer | add versioned lifecycle JSON envelopes | gives app a stable machine-readable seam | +| 2 | `plugin-kit-ai` | lifecycle usecase/domain | add managed grouping and target-detail fidelity | makes lifecycle usable for app cards/detail views | +| 3 | `plugin-kit-ai` | service construction + request context | add explicit `workspace_root` handling | removes hidden `cwd` coupling | +| 4 | `plugin-kit-ai` | authored inspection/catalog projection | add universal catalog JSON | provides storefront truth | +| 5 | `plugin-kit-ai` | new discovery usecase + adapters | add native discovery JSON | provides native installed truth | +| 6 | `claude_team` | main services + renderer normalized model | read-only mixed plugin page | validates catalog + discover + list + doctor interplay | +| 7 | `claude_team` | lifecycle actions + store refresh | universal install/update/remove/repair | completes managed plugin flow | + +### Backend readiness gate before any `claude_team` plugin-kit PR + +`claude_team` should not start real plugin-kit-backed plugin rendering or lifecycle work until the backend can already guarantee all of the following. + +This gate is intentionally stricter than “some JSON exists”. + +| Required backend guarantee | Why the app needs it | Current code reality | +|---|---|---| +| explicit `workspace_root` request context for project-sensitive commands | prevents plan/apply from using hidden `cwd` semantics | missing at CLI manage-command layer | +| stable `managed_entry_key` for grouped lifecycle entries | prevents app cache keys from collapsing to raw `integration_id` | missing as a public grouped contract field | +| grouped lifecycle JSON with `requested_source_ref`, `resolved_source_ref`, `policy.scope`, and `workspace_root` | lets app render ownership truth without row reconstruction heuristics | current report is target-row oriented | +| lifecycle freshness fields such as `last_checked_at` and `last_updated_at` | prevents app from implying live remote verification when it only has stored-state truth | stored in state, not projected publicly | +| structured target-detail fidelity | lets detail panes explain drift, blocking, and owned objects without renderer-side probing | current target report drops key adapter detail | +| structured top-level `doctor` recovery warnings | keeps degraded/interrupted recovery truth first-class | warnings exist in backend but not yet in app-facing JSON | +| explicit target `action_class` and mutation `outcome` in plan/result | keeps `update` vs `adopt_new_target` and `applied` vs `rolled_back` vs `degraded` distinct | backend semantics exist but are not yet pinned in an app contract | +| explicit public policy for target-filtered existing mutations | prevents renderer from inventing target-scoped `update/remove` UX that current CLI surface does not actually support | usecase model has a target field, public command surface is still asymmetric | + +Recommended rule: + +- until this gate is green, `claude_team` may build only isolated types and fixtures behind a feature flag +- it must not ship real plugin-kit-backed mixed rendering or lifecycle actions + +### Top implementation anti-patterns + +These are the fastest ways to create bugs in this migration: + +- using authored target names as installability truth +- using raw `integration_id` as the only managed entry key in the app +- reusing current adapter `Inspect` as discovery backend +- inferring project context from Electron process cwd +- merging native external entries into universal entries by display name +- letting target-specific metadata override shared universal card identity +- treating process exit as the only JSON command outcome signal +- reconstructing managed entry groups from flat target rows in renderer code + +### Non-negotiable no-go items + +- no auto-merge by display name +- no silent `local -> project` downgrade +- no destructive actions on native external entries unless backend explicitly declares them safe +- no app-side inference where backend truth is missing +- no accidental `enable/disable` app surface in phase 1 just because backend already has those actions + +## Glossary + +### Universal plugin + +A plugin from the universal plugin catalog that can be managed through `plugin-kit-ai`. + +### Native external plugin + +A plugin that already exists in a native agent surface such as Claude or Codex, but is not part of `plugin-kit-ai` managed state. + +### Managed universal plugin + +A universal plugin that `plugin-kit-ai` has installed or is tracking in `~/.plugin-kit-ai/state.json`. + +### Catalog + +The backend surface that answers: + +- what universal plugins exist +- what targets and scopes they support +- what storefront metadata should be shown + +### Discover + +The backend surface that answers: + +- what native plugins already exist outside managed universal state +- what target and scopes they belong to +- whether the app may safely manage them + +### List + +The backend surface that answers: + +- what universal plugins are already managed + +### Doctor + +The backend surface that answers: + +- which managed universal plugins need attention because of drift, auth, or activation state + +## Hard Product Decisions + +These are fixed unless a new ADR explicitly changes them. + +### 1. Two plugin classes + +The page shows: + +- `Universal` +- `Native external installed` + +They are never silently merged. + +### 2. Installed-first ranking + +Ranking order: + +1. installed universal +2. installed native external +3. available universal + +### 3. Universal is the main storefront + +Universal plugins are the default source for new installs. +Native external plugins are primarily visibility and compatibility surfaces. + +### 4. `discover` before `adopt` + +Phase 1 needs visibility, not ownership conversion. + +### 5. No fake scope parity + +If the backend target does not support a scope, the UI must not pretend it does. + +## Definition of Done + +This migration is done only when all of the following are true: + +- `plugin-kit-ai` exposes stable machine-readable `catalog`, `discover`, and lifecycle contracts +- `claude_team` renders universal and native external entries as distinct classes +- direct Claude mode works end-to-end for universal install/update/remove/repair +- multimodel Anthropic + Codex mode works end-to-end with target-granular results +- native external plugins remain visible and truthfully labeled +- the page stays useful when one backend view fails or is stale +- rollback is possible through a feature flag without destructive cleanup + +If any of these is false, the migration is still in progress. + +## Phase 0 Decision Checkpoints + +Before app integration starts, these questions must already have explicit answers in backend contracts or documented policy: + +1. **Managed state identity** + - default phase-1 answer: single-record-per-integration +2. **Conflict timing for `add`** + - default phase-1 answer: conflict is surfaced during planning/preflight, not only after confirm +3. **Workspace semantics** + - default phase-1 answer: project-sensitive app mode never depends on implicit `cwd` +4. **Planning context** + - default phase-1 answer: project-sensitive planning uses explicit workspace context, not hidden service-wide defaults +5. **Capability projection** + - default phase-1 answer: installability comes from projected backend capabilities, not from authored target names alone +6. **Catalog truth** + - default phase-1 answer: storefront metadata comes from the richer authored inspection path, not the narrow lifecycle loader +7. **Discovery truth** + - default phase-1 answer: `discover` is read-only, scanner-oriented, and overlap-aware +8. **Target detail fidelity** + - default phase-1 answer: app-facing lifecycle JSON keeps target warnings, object ownership, and blocking status instead of dropping them into prose or internal-only fields +9. **Adopted-target update semantics** + - default phase-1 answer: update plans expose newly adopted targets explicitly instead of burying them as generic update rows or warnings +10. **Doctor warning fidelity** + - default phase-1 answer: `doctor` warnings are treated as structured recovery guidance, not decorative text +11. **Managed freshness semantics** + - default phase-1 answer: grouped lifecycle payloads expose stored-state freshness timestamps and the app does not imply live remote verification unless a source-resolving action actually ran + +If any of these stays fuzzy, the implementation will drift into app-side heuristics. + +## Hard Defaults For Low-Confidence Seams + +These defaults should be used unless a later ADR deliberately changes them. + +| Seam | Default | +|---|---| +| managed state identity | one managed record per `integration_id` in phase 1 | +| install-intent conflict timing | surface during planning/preflight, not only after confirm | +| project context | explicit `workspace_root`, never implicit `cwd` | +| lifecycle grouping key | `managed_entry_key`, not reconstructed from rows | +| discovery backend | separate scanner surface, not current adapter `Inspect` | +| native-to-universal matching | advisory only unless evidence is exact | +| Codex ambiguous state | downgrade to `observed_degraded` | +| target-specific metadata | detail-only enhancement, never shared card identity | +| mutation outcome | keep `applied`, `rolled_back`, and `degraded` distinct | +| adopted targets during update | show as explicit `adopt_new_target` work, not generic update noise | +| doctor warnings | surface as recovery guidance, not ignorable banner copy | +| plan/review latency | assume `update` preview may resolve remote source; do not design UX as instant/local-only | +| action surface breadth | keep `enable/disable` out of phase 1 | +| lifecycle freshness | show stored-state timestamps, do not imply live remote verification unless a source-resolving action just ran | +| unsupported fields | omit or degrade, never synthesize in renderer | +| parallel managed installs for same integration | unsupported until backend state identity is upgraded | + +## What Is Already True in plugin-kit-ai + +This plan should build on real current code, not on an imagined backend. + +### Already present today + +- `integrationctl` already exposes a public lifecycle facade +- target adapters already expose: + - `Capabilities` + - `Inspect` + - `Plan*` + - `Apply*` + - `Repair` +- post-apply verification already re-inspects the target and rejects false-positive installs +- managed lifecycle state already exists in `~/.plugin-kit-ai/state.json` +- read-only managed views already exist conceptually: + - `list` + - `doctor` +- lifecycle update/remove already re-resolve the source and reject identity drift if the resolved manifest no longer matches the stored `integration_id` + +### Important current gaps + +- `integrations` CLI currently prints prose, not versioned JSON +- there is no public `catalog` surface yet +- there is no public `discover` surface yet +- current managed `Report.Targets` do not carry enough integration-level context for the app +- current managed `TargetReport` also drops adapter-level detail such as target warnings, owned-object context, settings files, and precedence context +- current service composition still depends on `os.Getwd()` for workspace semantics +- current CLI manage commands do not yet carry explicit `workspace_root` request context + +### Important current model split + +There are two useful metadata layers today: + +- the richer authored plugin model used by `pluginmodel` / `pluginmanifest` +- the narrower `integrationctl.IntegrationManifest` + +Current `integrationctl` manifest loading preserves: + +- name +- version +- description +- targets +- derived deliveries +- derived capability surface + +But it currently drops richer authored metadata such as: + +- homepage +- repository +- keywords +- author +- license + +Practical consequence: + +- lifecycle can already use the current `IntegrationManifest` +- storefront catalog cannot get all desired detail fields from the current `IntegrationManifest` alone + +### Packaging nuance from current code + +- evidence registry already has an embedded fallback, which lowers packaging risk +- workspace-lock storage is still repo-root oriented + +Practical consequence: + +- `list`, `doctor`, `add`, `update`, `remove`, and `repair` are the right first app surfaces +- `sync` is not a phase-1 or phase-2 app surface +- `enable` and `disable` can stay out of the first app rollout + +## Current Adapter Truth From Code + +These are backend facts the plan must respect. + +### Claude adapter + +- install mode: `native_cli` +- supports native update: yes +- supports native remove: yes +- supports scopes: `user`, `project` +- does not currently advertise `local` +- currently advertises supported source kinds: + - `local_path` + - `github_repo_path` + - `git_url` +- requires reload after install + +### Codex adapter + +- install mode: `marketplace_prepare` +- supports native update: no +- supports native remove: no +- supports scopes: `user`, `project` +- does not currently advertise `local` +- currently advertises supported source kinds: + - `local_path` + - `github_repo_path` + - `git_url` +- requires restart and a new thread +- current inspect logic distinguishes: + - fully installed + - disabled + - prepared but not activated + - degraded + +### Consequence for the app + +The app must treat scope support as backend-owned truth. + +That means: + +- phase 1 and phase 2 should expose only `user` and `project` for plugin-kit-backed universal installs +- if `local` is important later, it must be added as a real backend capability first + +## Architecture Boundary + +### Correct boundary + +- `plugin-kit-ai` = lifecycle engine, universal catalog backend, native discovery backend +- `claude_team` = frontend, state, UX, feature-flagged rollout + +### Wrong boundaries + +- do not embed a second plugin UI +- do not parse human CLI output +- do not scrape universal repo layout directly in `claude_team` +- do not link Go code directly into Electron instead of using the CLI contract + +## Recommended Backend Basis By Surface + +Different backend surfaces should be built on different existing code paths. +Trying to force one internal model to answer every question would make the result worse. + +| Surface | Best current basis in `plugin-kit-ai` | Why | +|---|---|---| +| `catalog` | `pluginmanifest.Inspect` + `publicationmodel` + `targetcontracts` | richer authored metadata and stronger target/output truth | +| `list` | `integrationctl` managed state and current `list` service | managed universal truth already exists here | +| `doctor` | `integrationctl` current `doctor` service | managed drift/auth/activation truth already exists here | +| lifecycle mutate | `integrationctl` facade and adapters | real install/update/remove/repair engine already exists here | +| `discover` | new dedicated native discovery layer built using native surface readers and inspect helpers | external observed truth is different from managed lifecycle truth | + +### Recommended decision + +- build `catalog` from the richer authored-model path +- build `list`, `doctor`, and lifecycle mutations from `integrationctl` +- build `discover` as a new surface that may reuse inspect helpers, but is not just `Inspect` + +This is the cleanest way to avoid overloading one narrow model with too many jobs. + +## Recommended plugin-kit-ai Implementation Ownership + +One of the highest-risk failure modes is building the right contract in the wrong layer. +This section fixes ownership up front. + +### Chosen seam by package + +| Concern | Recommended home in `plugin-kit-ai` | Why | +|---|---|---| +| CLI flags, `--format json`, envelopes, exit semantics | `cli/plugin-kit-ai/cmd/plugin-kit-ai` | command boundary belongs here, not business truth | +| catalog projection for app consumption | new helper near `cli/plugin-kit-ai/internal/pluginmanifest` such as `internal/catalogview` | catalog truth comes from authored inspection and needs a stable projection layer | +| managed lifecycle grouping and integration-level fields | `install/integrationctl/domain` + `install/integrationctl/usecase` | lifecycle truth already lives here and should not be reconstructed in the CLI | +| native discovery orchestration | new discovery usecase under `install/integrationctl/usecase` with types in `install/integrationctl/domain` | discovery is backend truth, not a frontend heuristic | +| target-specific native enumeration and evidence collection | existing adapter packages under `install/integrationctl/adapters/` | adapters already own target-specific path and native-surface knowledge | +| source resolution | `install/integrationctl/adapters/source` | canonical source resolution already lives here | +| workspace-root normalization into target-specific native roots | `install/integrationctl/adapters/pathpolicy` plus adapter path helpers | app must not duplicate `ProjectRoot` vs `EffectiveGitRoot` logic | + +### Raw internal models must not leak as app contracts + +The app should not consume any of these raw internal shapes directly: + +- raw `pluginmanifest.Inspection` +- raw `integrationctl` lifecycle `domain.Report` +- raw adapter `InspectResult` + +Instead: + +- CLI commands should project them into stable app-facing JSON contracts +- projection should happen once in `plugin-kit-ai` +- `claude_team` should consume only those projected contracts + +### Why this chosen seam is safer + +- it keeps target/path/source truth backend-owned +- it avoids Electron reconstructing lifecycle groupings or source provenance +- it allows `plugin-kit-ai` to change internal models without breaking the app contract +- it makes E2E failures easier to localize to one layer + +## Which Backend Surface Answers Which UI Question + +| UI question | Backend surface | Why | +|---|---|---| +| What universal plugins can I install? | `catalog` | storefront availability | +| What universal plugins are already managed? | `list` | managed truth | +| What managed universal plugins need attention? | `doctor` | drift, auth, activation | +| What native plugins already exist outside plugin-kit management? | `discover` | observed external truth | +| Can this plugin be installed for Claude? | `catalog` capability + scope metadata | install intent | +| Can this plugin be installed for Codex? | `catalog` capability + scope metadata | install intent | +| Can I safely uninstall this native external plugin from the app? | `discover.manageability` | destructive authority must come from backend | +| What exactly happened after install/update/remove? | lifecycle result JSON | target-granular mutation truth | + +Rule: + +- if the answer is not available from one of these surfaces, the app should not invent it + +## Backend View Consistency Matrix + +This section makes cross-surface ownership explicit. +It should be possible to answer every “which surface wins?” question from this table alone. + +| Field or question | Winning surface | Allowed fallback | Forbidden fallback | +|---|---|---| +| managed existence | `list` | none | `catalog`, `discover`, app heuristics | +| managed health / degraded / auth-pending | `doctor` | `list` only for neutral installed state | `catalog`, `discover` | +| universal availability | `catalog` | stale cached catalog with explicit stale marker | `discover`, app heuristics | +| native external existence | `discover` | stale cached discovery with explicit stale marker | `catalog`, `list` | +| destructive authority for native external entries | `discover.manageability` | none | app heuristics | +| installability by target/scope | `catalog` plus lifecycle capability projection | conservative disable in app | inferred support from authored target alone | +| managed target result after mutation | lifecycle mutate result | immediate `doctor` refresh | app optimism without payload evidence | +| storefront detail metadata | `catalog` detail projection | explicit missing-detail UI | generated operational docs | + +### Required contradiction handling + +If surfaces disagree: + +- `list` vs `catalog` + - keep the managed entry + - mark catalog/detail support degraded if needed +- `discover` vs `catalog` + - keep both truths + - do not rewrite ownership +- `doctor` vs `list` + - prefer `doctor` for health state + - prefer `list` for managed existence +- lifecycle mutate result vs stale cached `list` + - prefer the fresh mutate payload, then refetch + - do not let stale cache overwrite the mutation outcome + +## Data Flow + +```mermaid +flowchart LR + A["universal-plugins-for-ai-agents"] --> B["plugin-kit-ai catalog"] + C["Claude native surfaces"] --> D["plugin-kit-ai discover"] + E["Codex native surfaces"] --> D + F["plugin-kit-ai state.json"] --> G["plugin-kit-ai list"] + F --> H["plugin-kit-ai doctor"] + I["plugin-kit-ai lifecycle actions"] --> F + B --> J["claude_team Plugins UI"] + D --> J + G --> J + H --> J + J --> I +``` + +## Target Naming and Mapping + +Authored target names and app-facing provider labels are not always the same thing. + +Examples from current code: + +- authored `claude` maps to app/runtime target `claude` +- authored `codex-package` maps to app/runtime target `codex` +- authored `gemini` maps to app/runtime target `gemini` +- authored `cursor` maps to app/runtime target `cursor` +- authored `opencode` maps to app/runtime target `opencode` + +### Surface-specific target id rule + +The same plugin may be described through different target vocabularies depending on the backend surface: + +- authored/catalog truth: + - `claude` + - `codex-package` + - `codex-runtime` + - `gemini` + - `cursor` + - `cursor-workspace` + - `opencode` +- lifecycle-manageable truth: + - `claude` + - `codex` + - `gemini` + - `cursor` + - `opencode` +- app-facing provider labeling: + - `Anthropic` + - `Codex` + - optionally later other providers + +This is already visible in current code: + +- authored plugin metadata and `pluginmanifest` preserve `codex-package` +- `integrationctl` normalizes that into lifecycle target `codex` +- the UI should render a provider lane label such as `Codex`, not leak raw lifecycle ids everywhere + +Recommended rule: + +- never force one single `target` field to carry all three meanings +- preserve target ids separately by surface +- use explicit fields such as: + - `authored_targets` + - `manageable_targets` + - `available_app_targets` + +### Recommended catalog rule + +Catalog entries should preserve both: + +- authored target identifiers +- normalized app/runtime target identifiers + +Why: + +- authored compatibility and generated outputs still care about authored target names +- the app needs stable provider-level labels like `Anthropic` and `Codex` +- this avoids lossy translation + +## App-Facing Target Subset + +`plugin-kit-ai` understands more targets than the `claude_team` plugin page needs to action directly. + +For this integration, the primary app-facing actionable subset should be: + +- `claude` +- `codex-package` + +These map to the current app-facing provider lanes: + +- `Anthropic` +- `Codex` + +### Out of scope for first actionability + +These may still exist in authored metadata, but they should not drive primary install buttons in the first rollout: + +- `codex-runtime` +- `gemini` +- `cursor` +- `cursor-workspace` +- `opencode` + +### Recommended UI rule + +- the backend catalog may preserve full authored target support +- `claude_team` should derive primary provider labels and actions only from the app-relevant subset +- broader target support may appear as secondary detail later, but should not confuse the main install surface + +### Important nuance + +This is not because those other targets are “fake”. +It is because this app rollout has a narrower action surface than the full authored/plugin backend target space. + +For example: + +- `plugin-kit-ai` lifecycle already knows targets like `gemini`, `cursor`, and `opencode` +- `pluginmanifest` and `targetcontracts` know even broader authored/runtime distinctions such as `codex-package` vs `codex-runtime` + +But the first plugin page rollout in `claude_team` should optimize for a clear and reliable main surface, not for exposing the entire backend target universe at once. + +### Why packaged support still does not mean first-class app actionability + +Current `platformmeta` already exposes packaged profiles for: + +- `claude` +- `codex-package` +- `codex-runtime` + +That still does **not** mean all three should become first-class action lanes in `claude_team`. + +Recommended rule: + +- packaged/backend support answers “can the backend understand this target family?” +- app-primary actionability answers “should this app expose install/manage actions for this target in the first rollout?” +- those questions are related but not identical + +This is one of the most important places where the plan must stay conservative. + +## Catalog Support Projection Rules + +Catalog generation should preserve three different truths without collapsing them: + +### 1. Authored targets + +What the plugin repo declares in `plugin.yaml`. + +Examples: + +- `claude` +- `codex-package` +- `codex-runtime` +- `gemini` +- `cursor` +- `cursor-workspace` +- `opencode` + +### 2. Backend-manageable lifecycle targets + +What the current `integrationctl` lifecycle can actually manage today. + +From current code, that target set is: + +- `claude` +- `codex-package` +- `gemini` +- `cursor` +- `opencode` + +Notably, it does **not** include: + +- `codex-runtime` +- `cursor-workspace` + +Important nuance from current code: + +- target adapters expose `Capabilities()` +- but current add planning does not use adapter capabilities as one central authoritative gate before all plan work +- current planning first resolves manifest deliveries and then inspects/plans target-specific installs + +Practical consequence: + +- the app must not infer real installability from authored targets alone +- the backend contract should project lifecycle-manageable truth explicitly +- if capability-based limits such as supported source kinds or scopes matter, they should come from backend projection, not renderer heuristics + +### 3. App-primary action targets + +What `claude_team` should expose as first-class install lanes in this rollout. + +Recommended set: + +- `claude` +- `codex-package` + +### Current public target universe in `plugin-kit-ai` + +From current `platformmeta` code, the public target universe is already split into: + +- packaged profiles: + - `claude` + - `codex-package` + - `codex-runtime` +- tooling profiles: + - `gemini` + - `cursor` + - `cursor-workspace` + - `opencode` + +This is useful context because it shows that the backend target universe is intentionally broader than the first plugin-page rollout. + +### Required contract rule + +A catalog entry should be able to preserve all three layers separately, for example: + +- `authored_targets` +- `manageable_targets` +- `primary_action_targets` + +This keeps the system honest: + +- authored truth stays intact +- backend actionability stays explicit +- app UI stays focused + +### Capability projection rule + +For app-facing plugin installability, the contract should project at least: + +- manageable targets +- supported scopes by target +- supported source kinds by target when relevant +- target-level lifecycle capabilities that materially affect UX such as: + - update support + - remove support + - repair support + - restart / reload / new-thread requirements + +Recommended rule: + +- the app should render from this projected capability layer +- not from authored target names +- and not by trying to call low-level capability methods itself + +## Product Model + +### Universal plugins + +Source: + +- [universal-plugins-for-ai-agents/plugins](https://github.com/777genius/universal-plugins-for-ai-agents/tree/main/plugins) + +Properties: + +- installable through `plugin-kit-ai` +- available for one or more targets +- primary browse/search source +- explicit target support labels + +### Native external installed plugins + +Properties: + +- already installed in a native agent surface +- not necessarily managed by `plugin-kit-ai` +- still visible in UI +- clearly labeled by target ownership + +Example labels: + +- `Installed - Anthropic only` +- `Installed - Codex only` + +## Source of Truth Model + +| Surface | Owner | Meaning | +|---|---|---| +| `catalog` | `plugin-kit-ai` | what universal plugins are available | +| `discover` | `plugin-kit-ai` | what native plugins are already observed | +| `list` | `plugin-kit-ai` | what universal plugins are managed | +| `doctor` | `plugin-kit-ai` | health and drift for managed universal plugins | +| renderer cache | `claude_team` | temporary UI cache only | + +### Consistency rules + +- every rendered `universal_installed` entry must be explainable by `list` +- every degraded managed universal entry must be explainable by `doctor` +- `discover` may overlap conceptually with universal entries, but never redefines managed ownership +- `catalog` must not advertise target/scope support that lifecycle will reject under normal supported conditions + +### Freshness and partial-view rules + +These rules matter because `catalog`, `discover`, `list`, and `doctor` do not have the same source or refresh cost. + +- `list` is authoritative for managed existence, even if `catalog` is stale or temporarily missing an entry +- `doctor` is authoritative for managed health, even if `catalog` is stale +- `discover` is authoritative for observed native external existence, unless suppressed by stronger managed-overlap evidence +- failed or stale `catalog` must not make a managed entry disappear from the page +- failed or stale `discover` must not invent that native external entries were removed +- the app may mark data stale, but it must not rewrite ownership because one backend view is temporarily unavailable + +### Consistency invariants the backend contract should preserve + +1. every managed entry returned by `doctor` must also be representable in `list` by the same managed grouping key +2. `discover` must either suppress managed overlap or mark it explicitly, but must not silently contradict `list` +3. `catalog` may omit optional storefront metadata, but must not change canonical `integration_id` +4. `claude_team` must never resolve a contradiction by guessing - it should preserve both truths and degrade the UI honestly + +## Identity, Matching, and Dedupe + +### Universal identity + +Canonical key: + +- `integration_id` + +### Native external identity + +Canonical key: + +- `native_target + native_plugin_id + scope-set` + +### Matching rule + +`matched_integration_id` is advisory only. +It does not convert a native external entry into a universal entry. + +### Match confidence + +Recommended values: + +- `match_confidence`: `exact | heuristic | none` +- `match_basis`: `same_repo_same_plugin_id | same_marketplace_identity | manual_mapping | name_heuristic | unknown` + +### Target-specific matching ladder + +The backend should classify matches conservatively. + +Recommended ladder: + +#### Claude native external -> universal + +`exact` only when the backend can prove the same marketplace identity, for example: + +- same native plugin ref +- or same marketplace identity pair such as: + - plugin id + - marketplace name + +`heuristic` only when: + +- display name matches strongly +- and target is the same +- and there is no stronger conflicting candidate + +`none` when: + +- only loose name similarity exists +- or more than one universal plugin could plausibly match + +Important note: + +- current managed Claude installs use synthetic refs such as `integration_id@integrationctl-` +- native external Claude installs from official or third-party marketplaces may use different marketplace identities +- therefore name equality alone is not enough for `exact` + +#### Codex native external -> universal + +`exact` only when the backend can prove the same native plugin identity, for example: + +- marketplace entry name equals integration id +- and the same plugin reference is observed consistently in: + - marketplace catalog + - config toggle ref + - or managed plugin root path + +`heuristic` only when: + +- marketplace entry name strongly matches integration id +- but not every supporting surface is available + +`none` when: + +- only title-level similarity exists +- or multiple universal integrations could map to the same observed native name + +Important note: + +- current Codex-managed installs use `integration_id` as the marketplace entry name +- they also create related evidence in: + - `.agents/plugins/marketplace.json` + - plugin root path under `plugins/` + - config plugin ref `@` +- that is good raw evidence, but the backend should still keep external discovery conservative + +### Preferred matching evidence by target + +| Target | Strongest evidence | Weaker evidence | Unsafe alone | +|---|---|---|---| +| Claude | plugin ref, marketplace name + plugin id pair | stable display name + target | display name alone | +| Codex | marketplace entry name + config plugin ref + plugin root agreement | marketplace entry name only | title similarity alone | + +Recommended rule: + +- only the strongest evidence column may justify `exact` +- weaker evidence may justify `heuristic` +- the unsafe column must map to `none` + +### Matching invariants + +- `exact` must be explainable from stable identity-bearing fields, not from display text +- `heuristic` must never unlock destructive actions +- `heuristic` must never collapse two entries into one +- if confidence is below `exact`, the UI should treat the relation as advisory only +- the app must not recalculate confidence differently from the backend +- overlap suppression and matching are different decisions +- an entry may be suppressed as managed overlap without ever being exposed as a native external match candidate + +Renderer rule: + +- only strong bases may drive stronger UI hints +- heuristic matches must never auto-merge entries or unlock stronger actions + +## Catalog Production Model + +This must be explicit because the universal repo is the source of truth for available universal plugins, but it must not become the app contract directly. + +### Source of universal entries + +Universal catalog entries should be generated from: + +- `777genius/universal-plugins-for-ai-agents/plugins/*` + +using `plugin-kit-ai`, not using app-side parsing. + +### Recommended pipeline + +1. select a pinned revision of `universal-plugins-for-ai-agents` +2. enumerate plugin directories under `plugins/*` +3. load each plugin through the richer authored plugin model, ideally the same `pluginmanifest.Inspect` path that already exposes: + - manifest metadata + - publication model + - target contract details +4. derive normalized catalog entries +5. write a bundled snapshot for app packaging +6. optionally refresh from the same source later + +### Why `publicationmodel` is helpful but not enough on its own + +Current `publicationmodel.Model` is useful for catalog generation because it already normalizes: + +- package targets +- package families +- channel families +- install model +- authored docs +- managed artifacts + +But by itself it does **not** carry the full storefront metadata set the app wants, such as: + +- homepage +- repository +- keywords +- author +- license + +Those live in the richer authored manifest path exposed through `pluginmanifest.Inspection.Manifest`. + +Recommended rule: + +- catalog generation should use `pluginmanifest.Inspect` as the main authored inspection entry point +- then combine: + - `Inspection.Manifest` for storefront metadata + - `Inspection.Publication` for publication/channel/package projection + - `Inspection.Targets` plus target contracts for support and surface details + +The app must not try to reconstruct that combination on its own. + +### Why this should not use the current narrow lifecycle loader only + +The current `integrationctl` manifest loader is enough for lifecycle planning, but it preserves only: + +- name +- version +- description +- targets +- derived deliveries + +That is not enough on its own for the desired storefront contract. + +Recommended resolution: + +- treat catalog generation as its own backend translation layer +- allow that layer to read richer authored metadata +- still keep the final catalog output normalized and versioned + +### Why `pluginmanifest.Inspect` is a better catalog basis than the current lifecycle loader + +From current code, `pluginmanifest.Inspect` already carries much richer input than `integrationctl.Loader`, including: + +- authored manifest metadata +- publication model +- target contract fields such as install model, activation model, native root, portable kinds, native surfaces, and managed artifacts + +That makes it a much better source for storefront and support badges. + +### Performance boundary + +Current source resolution for lifecycle work may clone GitHub or git URL sources. +That is acceptable for install/update style mutations. + +It is not acceptable as the way to build the storefront catalog. + +Catalog generation should work from: + +- a pinned local checkout +- a prepared snapshot +- or another batch-friendly backend path + +It should not resolve each storefront entry by cloning sources independently at runtime. + +### Alias rule + +The first-party alias map is useful for CLI shortcuts like `plugin-kit-ai add notion`. +It is not the storefront contract. + +## Source Reference Semantics + +Source semantics must stay explicit. +This is another place where the app should not invent meaning. + +### Current lifecycle source truth + +From current `integrationctl` code, lifecycle source resolution already distinguishes: + +- requested source ref +- resolved source ref +- local materialized path +- source digest + +Examples: + +- local path request: + - requested kind `local_path` + - resolved kind `local_path` +- GitHub repo-path request: + - requested kind `github_repo_path` + - resolved kind `git_commit` +- git URL request: + - requested kind `git_url` + - resolved kind `git_commit` + +This is good and should be preserved in the app-facing lifecycle contract. + +### Alias semantics + +Current first-party aliases are only convenience input forms such as: + +- `context7` +- `stripe` +- `notion` + +They resolve to concrete GitHub repo-path refs under the universal plugin repository. + +Recommended rule: + +- aliases are accepted CLI input +- aliases are not canonical identity +- aliases should not become the only stored source value in app state + +### Catalog source semantics + +Catalog source semantics are different from lifecycle source semantics. + +For the first app rollout, catalog entries should be treated as coming from a curated catalog snapshot with its own provenance, for example: + +- snapshot source kind +- catalog revision +- generated-by backend version + +The catalog should not pretend that every card was individually resolved through runtime lifecycle source resolution. + +### Recommended contract rule + +Keep these source layers separate: + +- `requested_source_ref` + - lifecycle input truth +- `resolved_source_ref` + - lifecycle resolved truth +- `catalog_source` + - storefront snapshot provenance + +The app should never collapse those into one ambiguous `source` string. + +### Recommended UI rule + +- install detail may show requested and resolved source refs for managed universal installs +- storefront cards should usually show catalog provenance only when needed for debugging or advanced detail +- aliases may be accepted in user-facing install flows, but the stored and rendered lifecycle truth should remain normalized requested/resolved refs + +## Workspace Root and Project Scope Semantics + +Project-scoped installs need one more rule-set because current target adapters do not all interpret project roots the same way. + +### Current code reality + +`plugin-kit-ai` already distinguishes: + +- user scope +- project scope +- stored `workspace_root` on managed installation records + +But current adapters derive effective native roots differently: + +- `Claude` + - project settings path uses `ProjectRoot(workspace_root, project_root)` +- `Codex` + - project marketplace root uses `EffectiveGitRoot(workspace_root, project_root)` +- `OpenCode` + - project assets/config roots also use `EffectiveGitRoot(workspace_root, project_root)` +- `Cursor` + - project config path currently uses `ProjectRoot(workspace_root, project_root)` + +This means the same raw workspace path can lead to different effective native roots depending on target semantics. + +There is one more critical current reality: + +- current `integrationctl.newService()` still derives: + - current workspace root from `os.Getwd()` + - repo-root-oriented files such as workspace lock and evidence paths from discovered repo root + - default adapter project roots from that same cwd + +That is acceptable for a human CLI launched from the intended repo. +It is not a safe default for a bundled desktop app. + +There is also a more subtle planning seam: + +- current add planning calls adapter `Inspect(...)` with: + - `IntegrationID` + - `Scope` +- but no explicit `workspace_root` field in `InspectInput` +- project context therefore reaches adapters indirectly through service construction and adapter defaults, not through an explicit per-request planning field + +That may be acceptable for the current CLI composition, but it is too implicit for app integration. + +### Why this matters + +If the app assumes one global meaning for `workspace_root`, it can easily: + +- install into the wrong repo root +- render the wrong project target path in detail +- refresh the wrong context after mutation +- mis-explain project scope differences between providers + +### Recommended rule + +- the app passes the raw user-selected `workspace_root` +- the backend owns target-specific effective-root normalization +- the app must not try to emulate `EffectiveGitRoot` or `ProjectRoot` logic itself + +### Recommended contract additions + +Where useful, lifecycle and discovery payloads may expose explicit derived fields such as: + +- `workspace_root` + - raw project context that was requested or persisted +- `effective_native_root` + - target-specific derived root actually used for native files +- `native_scope_root` + - optional friendlier alias if that reads better in the contract + +Phase-1 minimum: + +- `workspace_root` is required for project-scoped managed installs +- missing project `workspace_root` must remain a hard backend error +- target-specific effective root may be additive if not ready immediately + +### Required service-construction rule for app mode + +For app integration, `plugin-kit-ai` should not rely on implicit process cwd semantics. + +Recommended rule: + +- add an explicit app/CLI service-construction path that accepts: + - `workspace_root` + - optional repo-root-oriented paths only where they are still needed +- project-sensitive commands should use that explicit workspace input +- packaged app execution must not depend on what directory the Electron process happened to launch from + +Recommended phase-1 default: + +- keep repo-root-oriented flows such as workspace lock and `sync` out of app mode +- require explicit `workspace_root` for project-scoped lifecycle and discovery commands +- treat missing explicit workspace context as usage error, not as permission to fall back to `os.Getwd()` + +### Planning-context rule + +For app integration, project-sensitive planning must also receive explicit context. + +Recommended rule: + +- either service construction per request must bind explicit workspace context before planning +- or planning interfaces must grow explicit workspace-root context + +What must not happen: + +- “plan” uses one implicit project context +- “apply” uses a different explicit project context +- and the app presents them as if they were the same decision + +### Recommended app rule + +- UI selection should talk in terms of the chosen project/workspace +- backend detail and diagnostics may show the effective native root when it differs +- app logic for mutation, refresh, and cache keys should continue to use the raw workspace context plus target, not a home-grown rewritten root + +### Why this should stay backend-owned + +Current target adapters already encode platform-specific expectations. + +Examples: + +- Codex project installs intentionally anchor to effective git root +- Claude project installs intentionally target project-local settings path + +Trying to centralize those rules in Electron would duplicate platform policy and create drift. + +## Metadata Truth Table + +One of the biggest ways this migration can go wrong is promising metadata that the backend does not actually know reliably. + +### Metadata that already exists today in authored source + +From current authored plugin source, `plugin-kit-ai` can already obtain: + +- `integration_id` +- `version` +- `description` +- `homepage` +- `repository` +- `license` +- `keywords` +- declared `targets` + +### Important caveat + +The current `integrationctl` manifest loader does not carry all of those fields forward today. + +So there are two safe paths: + +1. extend `integrationctl` manifest loading to preserve richer metadata +2. build `catalog` from the richer authored-model path and translate it into the catalog contract + +What must not happen: + +- the app inventing those fields +- the app scraping raw repo files directly +- two different backend paths returning contradictory storefront metadata + +### Metadata the backend can derive safely + +The backend can also derive: + +- generated `delivery_kinds` +- `available_targets` +- `supported_scopes_by_target` from target adapter capabilities +- `readme` location using a stable default rule +- provenance fields such as source ref, revision, manifest digest, generated-by version + +### Metadata that is not a safe phase-1 assumption + +These fields are optional curation data, not phase-1 requirements: + +- `category` +- `icon_url` +- `install_count` +- `popularity` +- `featured_rank` + +### Recommended default + +Phase 1 should require only: + +- name +- description +- version +- homepage / repository +- keywords +- target support +- scope support +- README/detail + +If `category`, `icon`, or popularity are absent, the UI should hide or degrade those features honestly. + +## Storefront Detail and README Semantics + +The plugin detail surface must use the authored human guide, not arbitrary generated root docs. + +### Current code reality + +`plugin-kit-ai` already generates root-facing docs such as: + +- `README.md` +- `GENERATED.md` +- boundary guidance docs + +But those are operational/generated root entrypoints. +They are not the best source of storefront detail content. + +Current code also makes the authored README explicit: + +- the managed root `README.md` points readers back to `plugin/README.md` +- generated docs inventory also treats root docs differently from managed outputs + +That is a strong signal that the authored README remains the source of truth for human-facing plugin detail. + +### Recommended detail rule + +For universal catalog entries, the default detail source should be: + +- authored `plugin/README.md` + +Not: + +- generated root `README.md` +- `GENERATED.md` +- boundary docs like `AGENTS.md` + +### Why this matters + +If the app accidentally uses generated root docs as storefront detail: + +- the detail view becomes noisy and operational +- it may emphasize generate/normalize workflows instead of plugin value +- the same plugin can appear to have unstable detail content depending on packaging mode + +### Recommended contract shape + +The catalog contract should prefer an explicit detail reference such as: + +- `detail_kind: "authored_readme"` +- `detail_ref` + +Recommended phase-1 default: + +- `detail_kind = "authored_readme"` +- `detail_ref` points to the authored README location within the catalog source snapshot + +### Recommended app rule + +- plugin cards use catalog summary fields +- plugin detail loads the authored detail reference when available +- if detail content is missing, the app should degrade honestly instead of substituting generated operational docs + +## Catalog Field Ownership Matrix + +This makes the contract much easier to implement because it is explicit about where each field should come from. + +| Catalog field | Recommended source in `plugin-kit-ai` | Notes | +|---|---|---| +| `integration_id` | authored manifest `name` | stable universal identity | +| `display_name` | authored manifest `name` initially | future curation can improve presentation | +| `description` | authored manifest `description` | required | +| `version` | authored manifest `version` | required | +| `homepage_url` | richer authored model | not present in current narrow lifecycle manifest | +| `repository_url` | richer authored model | not present in current narrow lifecycle manifest | +| `keywords` | richer authored model | phase-1 safe metadata | +| `authored_targets` | authored manifest `targets` | preserve exact authored truth | +| `manageable_targets` | lifecycle target mapping / registered adapters | what backend can actually act on | +| `primary_action_targets` | app rollout policy | narrower than full backend target universe | +| `available_app_targets` | backend target projection | provider-facing labels for `claude_team` | +| `supported_scopes_by_target` | target adapter capabilities | backend-owned truth | +| `capabilities` | delivery mapping and/or target contract data | do not invent in app | +| `readme_url` | catalog translation layer | use a stable default rule | +| `category` | optional curation metadata | do not block phase 1 | +| `icon_url` | optional curation metadata | do not block phase 1 | +| `catalog_revision` | catalog generation pipeline | provenance | +| `generated_by_plugin_kit_version` | CLI/backend build info | provenance | + +### Important rule + +If a field does not have a trustworthy backend source yet, phase 1 should omit or degrade it instead of synthesizing it in the app. + +## Effective Metadata Projection + +Catalog generation must distinguish between: + +- shared plugin metadata +- target-specific effective metadata + +This matters because current `plugin-kit-ai` code already allows target-specific metadata overlays, especially for package-style targets such as `codex-package`. + +### Shared metadata + +Shared metadata should come from the authored manifest layer: + +- `name` +- `version` +- `description` +- base `homepage` +- base `repository` +- base `license` +- base `keywords` +- base `author` + +This is the safest metadata for: + +- mixed-target storefront cards +- cross-target search +- universal identity + +### Target-specific effective metadata + +For some targets, especially `codex-package`, the effective generated package metadata is: + +- base manifest metadata +- plus allowed target-specific overrides from `targets//package.yaml` + +Current code already proves this path exists: + +- `codex-package` generation merges base manifest metadata with optional `targets/codex-package/package.yaml` +- validation checks the generated Codex package metadata against that merged expectation + +Current code also proves the override boundary is intentionally narrow. + +For `codex-package`, the effective metadata overlay is currently designed for: + +- `author` +- `homepage` +- `repository` +- `license` +- `keywords` + +It is **not** the same thing as “target can override any storefront field”. + +Recommended rule: + +- phase 1 should treat only these currently-proven metadata fields as safe `codex-package` effective overrides +- the app should not assume per-target overrides for: + - `name` + - `version` + - `description` + - entry identity + - universal card title + - universal card summary + +### Recommended catalog rule + +The catalog contract should preserve both layers explicitly: + +- shared metadata for the universal entry itself +- optional `effective_target_metadata` for targets that project different package metadata + +Example shape: + +- `shared_metadata` +- `effective_target_metadata.codex` + +At minimum, per-target effective metadata may include: + +- `homepage` +- `repository` +- `license` +- `keywords` +- `author` + +### Recommended UI rule + +- list cards should use shared metadata +- provider-specific effective metadata should appear only in target detail sections or provider-specific support details +- the app must not silently replace universal card metadata with one target's override +- the app must not let `codex-package` override: + - universal `display_name` + - universal `description` + - universal search identity + +Why: + +- otherwise a `codex-package` override could accidentally become the visible truth for a plugin that is still conceptually universal +- that would make shared cards unstable and misleading across providers + +### Recommended phase-1 default + +If effective target metadata is not yet emitted in the contract: + +- use shared metadata only +- do not guess target-specific homepage/repository/license in the app +- add effective target metadata later as an additive contract field + +### Conservative phase-1 metadata rule + +For phase 1, treat target-specific effective metadata as enhancement, not as a dependency. + +That means: + +- search, ranking, and primary cards use shared metadata only +- target-specific metadata appears only when backend emits it explicitly +- absence of effective target metadata must never block installability rendering +- the app must not read target-specific docs or package manifests directly to reconstruct this layer + +## Native Discovery Model + +`discover` is a genuinely new backend surface. +It must not be implemented as a thin wrapper over current `List` or current per-target `Inspect`. + +### Why current per-target `Inspect` is not a safe discovery backend + +This needs to be explicit because reusing current adapter `Inspect` can look tempting, but it is the wrong abstraction for external discovery. + +#### Claude + +Current Claude inspect logic is still strongly lifecycle-oriented: + +- it resolves inspect identity from: + - `in.IntegrationID` + - or `in.Record.IntegrationID` +- native plugin-list confirmation then looks for a specific plugin ref: + - defaulting to `integration_id@integrationctl-` + - or a managed plugin ref from record metadata +- if that specific confirmation path does not resolve, current inspect can still fall back to `installed` when native files or CLI availability make the managed candidate look plausible + +That is appropriate for managed lifecycle verification. +It is not appropriate for general native external discovery, because external installs may use: + +- a different marketplace name +- a different plugin ref +- or no managed lifecycle record at all + +#### Codex + +Current Codex inspect logic is also lifecycle-oriented: + +- inspect inputs derive `integration_id` from the managed record +- scope/path construction depends on that managed integration identity +- state classification assumes it is inspecting a known candidate plugin root +- current observed-surface logic is built around a specific expected catalog path, plugin root, and config path +- current lifecycle classification then reasons from managed cache presence plus that expected surface bundle + +That is useful for verifying a managed installation. +It is not enough for general native external enumeration, which first needs to discover candidates before it can classify them. + +### Recommended backend rule + +- `discover` must be its own scanner-oriented surface +- it may reuse helper functions from adapters where useful +- but it must not simply loop over current adapter `Inspect` without an independent candidate-enumeration layer + +### Critical discovery anti-patterns + +These are explicitly forbidden: + +1. calling current adapter `Inspect` on arbitrary filesystem hits and treating the result as native discovery truth +2. suppressing a discovered entry before comparing it against managed lifecycle evidence +3. upgrading a heuristic name match into an exact overlap suppression signal +4. deriving native manageability from UI assumptions instead of backend evidence +5. hiding a discovered entry only because catalog lookup failed or was stale + +If implementation pressure pushes toward any of these shortcuts, the correct fix is to extend backend discovery evidence, not to make the app smarter. + +### Practical implementation shape + +Recommended backend structure: + +1. enumerate native candidates from target-specific sources +2. derive native identity and evidence for each candidate +3. suppress candidates already explained by managed lifecycle state +4. classify observed state +5. compute advisory relation to universal catalog +6. emit normalized discovery entries + +This keeps `discover` honest: + +- enumeration first +- classification second +- matching last +- read-only throughout + +### Claude discovery sources + +- `~/.claude/plugins/installed_plugins.json` +- `~/.claude/settings.json` +- `/.claude/settings.json` +- `/.claude/settings.local.json` + +### Codex discovery sources + +- `~/.agents/plugins/marketplace.json` +- `/.agents/plugins/marketplace.json` +- `~/.agents/plugins/plugins/` +- `/.agents/plugins/plugins/` +- `~/.codex/plugins/cache///local` +- `~/.codex/config.toml` + +### Required observed states + +- `observed_active` +- `observed_disabled` +- `observed_prepared` +- `observed_degraded` + +### Recommended observed-state derivation rules + +Observed-state classification should be target-specific and evidence-driven. + +#### Codex + +Current adapter code already implies a practical state ladder: + +- `observed_active` + - cache bundle exists + - and marketplace catalog + plugin root are present + - and config does not mark the plugin disabled +- `observed_disabled` + - cache bundle exists + - and config toggle is present and disabled +- `observed_prepared` + - marketplace entry exists and plugin root exists + - but activation evidence such as cache bundle is not present yet +- `observed_degraded` + - only part of the expected prepared/install surface exists + - or cache exists while managed marketplace source is missing or drifted + +Important rule: + +- `discover` should keep this richer observed-state truth +- the app should not collapse everything into plain `installed/not installed` +- the app should not promote `observed_active` from cache evidence alone +- when evidence is missing or contradictory, downgrade to `observed_degraded` + +### Codex evidence mapping table + +The first implementation should stay conservative and evidence-driven. + +| Evidence seen by discovery | Recommended observed state | Why | +|---|---|---| +| marketplace entry + plugin root + installed cache, config not disabled | `observed_active` | strongest “prepared and activated” signal available today | +| installed cache + config toggle present and disabled | `observed_disabled` | disable state is explicit | +| marketplace entry + plugin root, but no installed cache yet | `observed_prepared` | package is staged but native activation is not complete | +| only one of marketplace entry or plugin root exists | `observed_degraded` | partial native surface | +| installed cache exists but marketplace entry or plugin root is missing | `observed_degraded` | drifted or partially removed managed/native surface | +| config references plugin, but marketplace entry and plugin root are both absent | `observed_degraded` | stale toggle or orphaned config | + +### Conservative phase-1 defaults for Codex discovery + +Until discovery evidence is richer, prefer these defaults: + +- if evidence is ambiguous, downgrade to `observed_degraded` +- do not claim `observed_active` from config evidence alone +- do not claim `observed_active` from cache evidence alone when marketplace entry or plugin root is missing +- do not infer exact universal matching from marketplace entry name alone +- do not infer safe removal from discovered Codex paths alone +- do not suppress a discovered Codex entry unless managed-overlap evidence includes owned objects or stable lifecycle evidence + +#### Claude + +For phase 1, Claude discovery may stay simpler: + +- `observed_active` + - plugin appears in native plugin list and is enabled +- `observed_disabled` + - plugin appears in native plugin list and is disabled +- `observed_degraded` + - settings or install evidence exists but plugin list cannot confirm a clean state +- `observed_prepared` + - optional future state only if backend gains a meaningful pre-install or staged marketplace concept for external Claude installs + +Recommended rule: + +- do not force artificial state parity between Claude and Codex +- preserve richer Codex states where the backend can actually justify them + +### Required extra fields + +- `native_target` +- `native_plugin_id` +- `installed_scopes` +- `detected_source` +- `manageability` +- `matched_integration_id` +- `match_confidence` +- `match_basis` +- `identity_evidence` +- `activation_hint` + +### Discovery manageability rule + +The backend must declare whether a native external entry is: + +- `display_only` +- `safe_remove` +- `safe_adopt` +- or another explicit future mode + +The app must not infer destructive authority. + +### Discovery overlap suppression rule + +`discover` must not blindly report every observed native install as `native_external_installed`. + +Why this is necessary: + +- managed universal installs also materialize into native agent surfaces +- a naive scanner would rediscover those same installs and duplicate them as native external entries + +Recommended backend rule: + +- `discover` should load managed lifecycle state, or equivalent managed evidence, before finalizing external entries +- if an observed native install is already explained by managed lifecycle state with high confidence, it should either: + - be suppressed from discovery output, or + - be explicitly marked as managed overlap for app-side filtering + +Recommended default: + +- suppress managed-overlap entries in the discovery payload +- discovery should describe only installs that are not already explained by managed lifecycle state + +### Managed-overlap evidence examples + +Claude examples from current code: + +- synthetic marketplace name pattern `integrationctl-` +- managed plugin ref recorded in lifecycle metadata +- managed materialized marketplace root under `~/.plugin-kit-ai/materialized/claude/` + +Codex examples from current code: + +- managed plugin root under `.agents/plugins/plugins/` +- managed catalog entry name equal to `integration_id` together with lifecycle-owned native objects +- managed config ref `@` when that catalog name is already tied to a managed installation + +Important rule: + +- suppression should prefer owned native object evidence and managed lifecycle state over name heuristics +- name equality alone is not enough to classify something as managed overlap + +## Managed Lifecycle Model + +### Existing lifecycle surfaces + +- `list` for managed installations +- `doctor` for managed drift / activation / auth attention +- `add`, `update`, `remove`, `repair` for mutations + +### Important current gap + +Current `Report.Targets` do not identify which integration a target belongs to. + +The app needs lifecycle JSON to include integration-level context such as: + +- `integration_id` +- `managed_entry_key` +- source refs +- policy scope +- workspace root + +### Important current state-identity constraint + +Current managed state logic is narrower than it may look at first glance. + +From current `integrationctl` code: + +- `StateFile.Installations` is an array of `InstallationRecord` +- but `findInstallation`, `upsertInstallation`, and `removeInstallation` all key records only by `IntegrationID` +- existing plan and mutation flows also load records by integration name only + +Practical consequence: + +- current backend behavior effectively supports only one managed installation record per `integration_id` +- it does **not** yet describe a first-class model where the same integration can safely exist as parallel managed records for different scopes or workspaces +- current add flow also checks this conflict only at apply time after loading locked state, not during the earlier dry-run planning path + +This is a critical contract seam, not an implementation detail. + +### Install-intent conflict timing + +Current behavior is stricter than it first appears, but also later than the app would ideally want: + +- `add --dry-run` can still produce a plan without surfacing “integration already exists in state” +- `applyAdd(...)` then acquires the state lock, loads state, and fails with: + - `ErrStateConflict` + - `integration already exists in state: ` + +Why this matters: + +- the app can otherwise show a plausible install plan and only fail after the user confirms the mutation +- that is acceptable for a CLI, but it is weak UX for a structured desktop integration + +Recommended phase-0 rule: + +- either backend planning surfaces existing-state conflicts explicitly +- or the app must perform an authoritative managed-state preflight before presenting install as cleanly applyable + +Recommended default: + +- prefer surfacing the conflict in backend planning/contract, not only at apply time +- if that is not ready yet, app UI must at least treat same-`integration_id` managed presence as a preflight blocker + +### Why the current raw lifecycle report is not enough for app integration + +Today the raw `integrationctl` lifecycle query shape is still too flat for the plugin page. + +In current code: + +- `domain.Report` contains only: + - `summary` + - `targets` + - `warnings` +- `domain.TargetReport` contains per-target state such as: + - `target` + - `delivery_kind` + - `state` + - `activation_state` + - `environment_restrictions` + - `manual_steps` + +What it does **not** preserve at the same level: + +- `integration_id` +- `requested_source_ref` +- `resolved_source_ref` +- `resolved_version` +- `policy.scope` +- `workspace_root` +- a stable grouping boundary between one integration and another + +And for planning/apply UX it also drops important target-plan semantics that do exist one layer below in `ports.AdapterPlan`, such as: + +- explicit `blocking` +- plan `summary` +- `paths_touched` +- `commands` + +That matters because the app needs to render cards and detail views at the integration-entry level, not as an ungrouped stream of target facts. + +Recommended rule: + +- `plugin-kit-ai` should keep its current internal normalized lifecycle model +- but the app-facing JSON contract must expose managed entries grouped by integration +- `claude_team` must not try to reconstruct integration grouping from flat target rows by heuristics +- app-facing plan/result contracts must also preserve enough per-target semantics to tell: + - whether the action is actually applyable + - why it is blocked + - what manual steps are advisory vs blocking + +### Current report also drops target-detail fidelity the app will care about + +Today there is another mismatch between the internal adapter layer and the public lifecycle report. + +Current adapter-level structs already carry richer target detail: + +- `InspectResult` + - `Warnings` + - `OwnedNativeObjects` + - `ObservedNativeObjects` + - `SettingsFiles` + - `ConfigPrecedenceContext` +- `ApplyResult` + - `Warnings` + - `OwnedNativeObjects` + - `AdapterMetadata` +- `AdapterPlan` + - `Blocking` + - `Summary` + - `PathsTouched` + - `Commands` + +Current `TargetReport` keeps only a subset of that. + +Practical consequence: + +- the current lifecycle report is enough for a terminal summary +- it is not yet a strong enough truth surface for a desktop detail view +- if phase 1 exposes only the current flat report shape, `claude_team` will eventually be forced to guess or hide important state + +Recommended rule: + +- app-facing lifecycle JSON should include a structured target-detail block +- it does not need to expose every low-level adapter internal +- but it must preserve at minimum: + - target warnings + - owned native objects + - blocking vs advisory status + - settings or config files when they are part of the activation story + - enough context to explain precedence or override issues truthfully + +### Required grouped lifecycle identifiers + +For app-facing lifecycle JSON, each managed entry should include at minimum: + +- `managed_entry_key` +- `integration_id` +- `requested_source_ref` +- `resolved_source_ref` +- `resolved_version` +- `policy.scope` +- `workspace_root` +- `last_checked_at` +- `last_updated_at` + +Recommended rule: + +- `managed_entry_key` should be stable for one stored installation record +- it should not depend on target row order +- it should be safe for the app to use as the primary cache and merge key for managed lifecycle entries + +Without this, the frontend will eventually drift into reconstructing groups from target arrays, which is fragile and unnecessary. + +### Freshness semantics from current code + +Current managed lifecycle state is persisted with freshness timestamps. + +From current code: + +- successful `add` persists: + - `last_checked_at` + - `last_updated_at` +- successful `update/remove/repair/toggle` also update those timestamps +- degraded persistence paths also update stored record timestamps +- current `list` and `doctor` reports read state and journal, but they do not currently project these timestamps into public report rows + +Practical consequence: + +- current lifecycle truth is primarily stored-state truth +- it is not the same thing as “this source was remotely revalidated just now” +- without explicit timestamps, the app can easily overstate freshness + +Recommended rule: + +- grouped lifecycle payloads should expose at least: + - `last_checked_at` + - `last_updated_at` +- the app should use those fields for freshness copy and stale-state heuristics +- the app should not imply remote source freshness unless a source-resolving lifecycle action actually ran + +### Conservative phase-1 grouped lifecycle rule + +Until the backend exposes a more formal record identifier, phase 1 should still require: + +- one grouped managed entry per stored installation record +- stable ordering of `managed_entries` +- stable ordering of nested `targets` +- explicit grouping keys in payload, not implied grouping by adjacent rows + +The app must treat missing grouping keys as a compatibility problem, not as an invitation to reconstruct them heuristically. + +### Managed multiplicity rule + +Phase 1 must choose one of these backend truths explicitly and reflect it in the contract: + +1. **single-record-per-integration** + - one `integration_id` can have only one managed record at a time + - conflicting install intents must fail explicitly +2. **multi-record-per-integration** + - the backend introduces a real record key beyond `integration_id` + - lifecycle and state mutations become record-key aware + +Recommended phase-1 default: + +- keep the current single-record-per-integration model explicit +- do **not** let the app imply parallel managed `user` and `project` installs of the same universal plugin unless backend state identity is upgraded first +- if the user attempts a conflicting install intent, backend should return a structured conflict instead of silently replacing the existing record + +### Mutation outcome fidelity from current code + +Current mutation paths already distinguish more than plain success vs failure. + +From current code: + +- `add` + - may finish `committed` + - may finish `rolled_back` + - may finish `degraded` if rollback was incomplete and degraded state was persisted +- `update` + - may finish `committed` + - may finish `degraded` +- `remove` + - may finish `committed` + - may finish `rolled_back` + - may finish `degraded` +- `repair` + - may finish `committed` + - may finish `degraded` + +Practical consequence: + +- app-facing mutation contracts should not flatten all non-success paths into one generic `failed` +- the app needs to know whether native changes were rolled back cleanly or whether degraded managed state was persisted + +Recommended rule: + +- payload-level mutation outcome should distinguish at least: + - `applied` + - `rolled_back` + - `degraded` + - `failed` +- target-level results should remain visible inside that higher-level operation outcome + +### Update-time adopted target semantics from current code + +Current `update_version` planning already has a second behavior beyond “update existing targets”. + +From current code: + +- `update_version` resolves the next manifest/source +- if the next manifest exposes deliveries for targets not currently present in the managed record +- planning may also produce adopted-target work +- that adopted work is policy-sensitive: + - if `adopt_new_targets=manual`, planning emits warnings instead of auto-adopt work + - if `adopt_new_targets=auto`, planning may produce target plans with action class `adopt_new_target` + +Practical consequence: + +- an update plan is not always only “update current targets” +- it may also include “new target becomes managed as part of update policy” + +Recommended rule: + +- app-facing lifecycle plan/result payloads should preserve whether a target is: + - ordinary update work + - adopted new target work +- the app should render adopted targets explicitly in review/results instead of burying them inside generic update output + +Conservative phase-1 default: + +- if adopted-target semantics are not explicitly present in payloads, the app should not silently assume there are none +- update UX should stay conservative until backend carries that signal clearly + +### Adopted-target apply path from current code + +Current apply logic makes this even more important. + +From current code: + +- ordinary `update_version` target work uses `ApplyUpdate` +- adopted target work uses `ApplyInstall` + +Practical consequence: + +- adopted target work is not just cosmetic plan labeling +- it is a materially different mutation path + +Recommended rule: + +- plan and result payloads must preserve target `action_class` +- `action_class` must survive from preview to final result +- the app must not treat every target in an update result as if it came from the same mutation path + +### Critical CLI semantic to freeze + +Current `integrations` mutating commands default to `--dry-run=true`. + +That is good for humans in a terminal, but dangerous for app integration. + +Recommended rule: + +- machine-readable mutating calls from `claude_team` must always pass explicit execution mode +- either: + - `--dry-run=false`, or + - a future clearer flag such as `--apply` + +The app must never rely on CLI defaults for mutating behavior. + +## JSON Contract Style + +`plugin-kit-ai` already has a public JSON contract style in surfaces like `validate` and `publication`. +The new integrations contracts should follow that style instead of inventing a second JSON dialect. + +### Required envelope rules + +- top-level `format` +- top-level `schema_version` +- explicit request context fields where relevant +- top-level `warning_count` +- top-level `warnings` +- one canonical payload field rather than many competing summary shapes + +### Requested context rule + +Current public JSON reports in `plugin-kit-ai` already use request-context fields such as: + +- `requested_target` +- `requested_platform` + +Recommended rule for the integrations surfaces: + +- include explicit request-context fields when the command accepts them +- examples: + - `requested_targets` + - `requested_scope` + - `requested_workspace_root` + - `requested_integration_id` + +This makes automation and debugging much safer than inferring invocation context from payload shape. + +### Required array guarantees + +In schema version `1`, the following fields should be arrays, never `null`: + +- `warnings` +- `entries` +- `managed_entries` +- `targets` + +### Compatibility rules + +- additive fields are allowed within the same `schema_version` +- semantic changes to existing fields require a new `schema_version` +- removing a field the app depends on requires a new `schema_version` +- enum meaning changes require a new `schema_version` + +### App behavior on unsupported versions + +If the backend returns a newer unsupported schema: + +- read-only views may continue only if safe +- lifecycle actions must be disabled +- the UI must explain the compatibility mismatch clearly + +### Process exit and payload semantics + +This must be explicit because current `plugin-kit-ai` already has public JSON commands that can: + +- print a valid JSON payload to stdout +- then still exit non-zero because the payload describes a failing or issue-bearing report + +Current examples in code: + +- `validate --format json` +- `publication doctor --format json` + +Recommended rule for the integrations surfaces: + +- stdout JSON is the canonical machine-readable payload +- process exit code is still meaningful, but it must not be the only signal the app uses +- `claude_team` should: + - first attempt to parse a valid JSON payload from stdout + - then interpret payload-level fields such as `outcome`, `ok`, `warning_count`, `failure_count`, `issue_count` + - only fall back to process-exit-only handling when no valid contract payload exists + +Without this rule, the app will misclassify structured partial failures as transport failures. + +### Recommended outcome semantics + +For machine-readable integrations surfaces, outcome should be explicit in the payload instead of inferred only from process exit: + +- read-only reports: + - may expose `ok`, `warning_count`, and optional `issue_count` +- mutating results: + - should expose explicit `outcome` + - recommended values: + - `planned` + - `applied` + - `rolled_back` + - `degraded` + - `partial_success` + - `failed` + +The exact enum may still evolve, but the contract must keep payload-level outcome explicit. + +### Recommended identifiers + +- `plugin-kit-ai/integrations-report` +- `plugin-kit-ai/integrations-result` +- `plugin-kit-ai/integrations-catalog` +- `plugin-kit-ai/integrations-discovery` + +### Summary-string rule + +Current backend summary strings are useful for humans, but they are not strong enough to be canonical machine truth. + +Examples from current code: + +- `Updated integration "demo".` +- `Removed managed targets from integration "demo".` +- `Repaired managed targets for integration "demo".` + +Those are helpful, but they do not by themselves preserve: + +- adopted-target work +- degraded vs rolled-back outcome +- target-level action classes +- target-level manual steps or restrictions + +Recommended rule: + +- app contracts may keep human-readable `summary` +- but the app must never infer machine semantics from `summary` alone +- target rows and explicit payload fields always win + +### Minimum contract by command + +| Command | Minimum request context | Minimum payload truth | +|---|---|---| +| `catalog` | optional requested targets | universal entries, target projection, freshness | +| `discover` | requested targets, requested workspace root when relevant | native entries, observed state, manageability, match metadata | +| `list` | optional requested targets | grouped managed entries, `managed_entry_key`, source refs, scope, workspace, freshness timestamps | +| `doctor` | optional requested targets | everything from `list` plus top-level recovery warnings and target manual steps | +| `add` plan/apply | source ref, targets, scope, workspace root when relevant | grouped target plans/results, blocking, action class, mutation outcome | +| `update` plan/apply | integration id, workspace root when relevant; optional target filter only after the public command contract adds it | grouped target plans/results, adopted-target semantics, mutation outcome | +| `remove` plan/apply | integration id, workspace root when relevant; optional target filter only after the public command contract adds it | grouped target plans/results, mutation outcome | +| `repair` plan/apply | integration id, optional target filter, workspace root when relevant | grouped target plans/results, repair guidance, mutation outcome | + +### Plan fidelity rule + +For app integration, a machine-readable dry-run plan is not useful unless it preserves the distinction between: + +- applyable plan with advisory manual steps +- blocked plan with required manual intervention + +Recommended rule: + +- app-facing plan payloads should expose target-level fields such as: + - `blocking` + - `summary` + - `manual_steps` + - optional `paths_touched` + - optional `commands` +- if backend wants to omit some operational detail from the public app contract, it may omit `commands` +- but it must not omit whether the plan is blocked + +Without this rule, the app can only discover some blocking cases after the user already tries to apply the mutation. + +### Target detail fidelity rule + +For app integration, lifecycle JSON is not good enough if it flattens all interesting target detail into: + +- one summary string +- a few booleans +- or process-exit semantics + +Recommended rule: + +- app-facing lifecycle entry payloads should expose a structured target-detail section, either inline or under a nested field such as `target_detail` +- that section should be allowed to include fields such as: + - `warnings` + - `owned_native_objects` + - `observed_native_objects` + - `settings_files` + - `config_precedence_context` + - `adapter_metadata` only when the value is intentionally public and stable + +Conservative phase-1 default: + +- `warnings` +- `owned_native_objects` +- `settings_files` +- `blocking` +- `manual_steps` + +must be preserved + +Current-code note: + +- raw `domain.TargetReport` already keeps useful basics such as: + - `action_class` + - `manual_steps` + - lifecycle booleans and restrictions +- but it still drops higher-fidelity adapter truth the app will need for honest detail panes, including: + - target warnings + - owned-object and observed-object context + - settings-file context + - config precedence context + - paths touched + - command detail + +Why: + +- this is enough for truthful card/detail UX +- it avoids forcing the app to inspect native filesystem state itself +- it keeps app-side remediation copy grounded in backend truth + +### Action-class persistence rule + +For app integration, target-level `action_class` is part of the contract, not decorative text. + +Why: + +- current backend already distinguishes different mutation kinds at target level +- during `update`, some targets may be ordinary updates while others are `adopt_new_target` +- those may even go through different apply paths + +Recommended rule: + +- app-facing plan payloads must expose target `action_class` +- app-facing result payloads must also expose target `action_class` +- the app must not reconstruct it from summary prose + +Conservative phase-1 default: + +- if a mutating result payload loses target `action_class`, treat that payload as reduced-fidelity and avoid pretending the result was semantically complete + +### Doctor warning fidelity rule + +Current `doctor` output is not only a list of target states. + +From current code: + +- `doctor` top-level warnings already include open journal / operation recovery guidance +- examples: + - previously degraded operation guidance + - interrupted `in_progress` operation guidance + - failed-before-commit guidance +- target-level manual steps are also derived from: + - degraded state + - auth pending state + - activation restrictions + +Recommended rule: + +- app-facing `doctor` JSON must preserve: + - top-level recovery warnings + - target-level manual steps + - activation and restriction-derived guidance +- the app must not treat top-level `doctor` warnings as incidental text that can be hidden without replacement + +Why: + +- these warnings already encode recovery truth from journal state +- hiding them would make desktop UX less informative than the existing backend + +## Recommended Contract Drafts + +These drafts are intentionally close to the current `integrationctl` domain model. +They should extend the existing normalized result shape, not invent a second unrelated response model. + +### Managed lifecycle list + +```json +{ + "format": "plugin-kit-ai/integrations-report", + "schema_version": 1, + "report_kind": "managed_list", + "requested_targets": [], + "warning_count": 0, + "warnings": [], + "summary": "1 managed integration(s) in state.", + "managed_entries": [ + { + "managed_entry_key": "project:/repo:context7", + "integration_id": "context7", + "requested_source_ref": { + "kind": "github_repo_path", + "value": "github:777genius/universal-plugins-for-ai-agents//plugins/context7" + }, + "resolved_source_ref": { + "kind": "git_commit", + "value": "https://github.com/777genius/universal-plugins-for-ai-agents@abc123" + }, + "resolved_version": "0.1.0", + "workspace_root": "/repo", + "last_checked_at": "2026-04-18T12:00:00Z", + "last_updated_at": "2026-04-18T12:00:00Z", + "policy": { + "scope": "project", + "auto_update": true, + "adopt_new_targets": "manual" + }, + "targets": [ + { + "target_id": "claude", + "delivery_kind": "claude-marketplace-plugin", + "capability_surface": ["mcp"], + "state": "installed", + "activation_state": "reload_pending", + "source_access_state": "ok", + "target_detail": { + "warnings": [ + "reload required before the plugin becomes active in existing Claude sessions" + ], + "owned_native_objects": [ + { + "kind": "config_file", + "path": "/repo/.claude/settings.json" + } + ], + "settings_files": [ + "/repo/.claude/settings.json" + ] + } + } + ] + } + ] +} +``` + +### Doctor report with recovery warnings + +```json +{ + "format": "plugin-kit-ai/integrations-report", + "schema_version": 1, + "report_kind": "doctor", + "requested_targets": [], + "warning_count": 2, + "warnings": [ + "Operation op-degraded for context7 ended degraded - run plugin-kit-ai integrations repair context7.", + "Operation op-in-progress for context7 is still marked in_progress - inspect the journal and rerun repair if the process was interrupted." + ], + "summary": "Doctor: 1 installation(s), 2 open operation journal(s), 1 degraded target(s), 0 activation-pending target(s), 0 auth-pending target(s).", + "managed_entries": [ + { + "managed_entry_key": "project:/repo:context7", + "integration_id": "context7", + "policy": { + "scope": "project" + }, + "targets": [ + { + "target_id": "claude", + "state": "degraded", + "manual_steps": [ + "run plugin-kit-ai integrations repair context7" + ] + } + ] + } + ] +} +``` + +### Universal catalog + +```json +{ + "format": "plugin-kit-ai/integrations-catalog", + "schema_version": 1, + "requested_targets": ["claude", "codex"], + "warning_count": 0, + "warnings": [], + "source": { + "kind": "bundled_snapshot", + "fetched_at": "2026-04-18T12:00:00Z", + "revision": "abc123", + "stale": false + }, + "entries": [ + { + "entry_kind": "universal_catalog", + "integration_id": "context7", + "display_name": "Context7", + "description": "Shared MCP plugin for documentation lookup.", + "authored_targets": ["claude", "codex-package"], + "manageable_targets": ["claude", "codex-package"], + "primary_action_targets": ["claude", "codex-package"], + "available_app_targets": ["claude", "codex"], + "keywords": ["mcp", "docs"], + "category": null, + "homepage_url": "https://context7.com", + "repository_url": "https://github.com/upstash/context7", + "readme_url": "https://raw.githubusercontent.com/777genius/universal-plugins-for-ai-agents/main/plugins/context7/plugin/README.md", + "version": "0.1.0", + "effective_target_metadata": { + "codex": { + "homepage_url": "https://context7.com", + "repository_url": "https://github.com/upstash/context7", + "keywords": ["mcp", "docs"] + } + }, + "supported_scopes_by_target": { + "claude": ["user", "project"], + "codex": ["user", "project"] + }, + "capabilities": ["mcp"], + "source_ref": "github:777genius/universal-plugins-for-ai-agents//plugins/context7", + "catalog_revision": "abc123", + "generated_by_plugin_kit_version": "0.0.0" + } + ] +} +``` + +### Native discovery + +```json +{ + "format": "plugin-kit-ai/integrations-discovery", + "schema_version": 1, + "requested_targets": ["claude", "codex"], + "requested_workspace_root": "/repo", + "warning_count": 0, + "warnings": [], + "entries": [ + { + "entry_kind": "native_external_installed", + "native_target": "claude", + "native_plugin_id": "context7@claude-plugins-official", + "display_name": "Context7", + "description": "Installed from Claude marketplace.", + "installed_scopes": ["user"], + "detected_source": "claude_marketplace", + "manageability": "display_only", + "matched_integration_id": "context7", + "match_confidence": "exact", + "match_basis": "same_marketplace_identity", + "identity_evidence": [ + "native_plugin_ref=context7@official-marketplace", + "marketplace_name=official-marketplace" + ], + "observed_state": "observed_active", + "activation_hint": "none" + } + ] +} +``` + +### Dry-run lifecycle plan with blocking target + +```json +{ + "format": "plugin-kit-ai/integrations-report", + "schema_version": 1, + "report_kind": "plan_add", + "requested_integration_id": "context7", + "requested_targets": ["claude"], + "requested_scope": "project", + "requested_workspace_root": "/repo", + "ok": true, + "warning_count": 0, + "warnings": [], + "summary": "Dry-run plan for integration \"context7\" at version 0.1.0.", + "managed_entries": [ + { + "managed_entry_key": "planned:context7", + "integration_id": "context7", + "policy": { + "scope": "project" + }, + "targets": [ + { + "target_id": "claude", + "action_class": "install_target", + "blocking": true, + "summary": "Install Claude plugin through a managed local marketplace", + "manual_steps": [ + "managed policy blocks adding this marketplace", + "ask an administrator to update the allowlist or seed configuration" + ], + "target_detail": { + "warnings": [ + "this target is currently blocked by managed policy" + ], + "settings_files": [ + "/repo/.claude/settings.json" + ] + }, + "paths_touched": [ + "/Users/example/.plugin-kit-ai/materialized/claude/context7", + "/repo/.claude/settings.json" + ] + } + ] + } + ] +} +``` + +### Dry-run update with adopted target + +```json +{ + "format": "plugin-kit-ai/integrations-report", + "schema_version": 1, + "report_kind": "plan_update", + "requested_integration_id": "context7", + "requested_targets": ["claude", "codex"], + "requested_scope": "user", + "ok": true, + "warning_count": 0, + "warnings": [], + "summary": "Dry-run update plan for \"context7\".", + "managed_entries": [ + { + "managed_entry_key": "user::context7", + "integration_id": "context7", + "policy": { + "scope": "user", + "adopt_new_targets": "auto" + }, + "targets": [ + { + "target_id": "claude", + "action_class": "update_version", + "blocking": false, + "summary": "Update managed Claude target" + }, + { + "target_id": "codex", + "action_class": "adopt_new_target", + "blocking": false, + "summary": "Adopt newly supported target codex" + } + ] + } + ] +} +``` + +### Mutating lifecycle result + +```json +{ + "format": "plugin-kit-ai/integrations-result", + "schema_version": 1, + "requested_integration_id": "context7", + "requested_targets": ["claude", "codex"], + "requested_scope": "project", + "requested_workspace_root": "/repo", + "ok": false, + "warning_count": 0, + "warnings": [], + "outcome": "partial_success", + "report": { + "operation_id": "add-context7-...", + "summary": "Managed targets processed for integration \"context7\".", + "integration_id": "context7", + "managed_entry_key": "project:/repo:context7", + "targets": [ + { + "target_id": "claude", + "action_class": "install_target", + "state": "installed", + "activation_state": "reload_pending", + "environment_restrictions": ["reload_required"], + "manual_steps": ["reload Claude plugins"] + }, + { + "target_id": "codex", + "action_class": "install_target", + "state": "activation_pending", + "activation_state": "restart_pending", + "environment_restrictions": ["native_activation_required", "new_thread_required"], + "manual_steps": ["restart Codex", "open a new thread"] + } + ] + } +} +``` + +### Mutating lifecycle result with degraded persistence + +```json +{ + "format": "plugin-kit-ai/integrations-result", + "schema_version": 1, + "requested_integration_id": "context7", + "requested_targets": ["claude"], + "requested_scope": "project", + "requested_workspace_root": "/repo", + "ok": false, + "warning_count": 1, + "warnings": [ + "apply failed after partial progress; degraded state was persisted" + ], + "outcome": "degraded", + "report": { + "operation_id": "add-context7-...", + "summary": "Managed targets processed for integration \"context7\".", + "integration_id": "context7", + "managed_entry_key": "project:/repo:context7", + "targets": [ + { + "target_id": "claude", + "action_class": "install_target", + "state": "degraded", + "activation_state": "reload_pending", + "manual_steps": [ + "run repair before trusting this installation" + ], + "target_detail": { + "warnings": [ + "rollback could not fully restore native state" + ], + "owned_native_objects": [ + { + "kind": "config_file", + "path": "/repo/.claude/settings.json" + } + ] + } + } + ] + } +} +``` + +## Entry Derivation and Conflict Resolution + +This is the most important renderer rule-set in the whole integration. + +The app must derive the plugin list deterministically from four backend surfaces: + +- `catalog` +- `discover` +- `list` +- `doctor` + +It must **not** invent entries or merge classes by guesswork. + +### Backend surface ownership + +Each backend surface owns a different truth: + +- `catalog` + - universal storefront truth + - metadata for universal plugins + - target support projection + - provider-facing installability projection +- `list` + - managed universal installed truth + - policy scope + - workspace root + - resolved source + - installed targets +- `doctor` + - managed universal health augmentation + - degraded/auth/activation attention + - target-level restrictions and manual steps +- `discover` + - native external installed truth + - observed scopes + - detected source + - explicit manageability + - optional relation hints to universal entries + +### Entry derivation algorithm + +Recommended deterministic algorithm: + +1. Load `list` and build a managed map keyed by `managed_entry_key`. +2. Overlay `doctor` onto that managed map by `managed_entry_key + target_id`. +3. Load `catalog` and build a universal catalog map keyed by `integration_id`. +4. For every managed entry: + - create one `universal_installed` entry + - enrich it from matching catalog metadata when available + - keep lifecycle-owned fields from `list/doctor` +5. For every catalog entry without a managed match: + - create one `universal_available` entry +6. For every discovery entry: + - create one `native_external_installed` entry + - keep it separate even if it matches a universal integration +7. Attach relation metadata between `native_external_installed` and universal entries only as advisory linkage, never as a merge. +8. Sort using the installed-first ranking rules already defined in this plan. + +### Non-negotiable derivation invariants + +- `doctor` may augment managed entries, but must never create standalone entries +- `catalog` may create only universal entries +- `discover` may create only native external entries +- `catalog` must never mark an entry as installed +- `discover` must never mark an entry as managed +- advisory matching must never change `entry_kind` +- if two surfaces disagree, the surface that owns that truth wins + +### Field precedence rules + +For `universal_installed` entries: + +- identity: + - from `list` +- installed state: + - `doctor` if present + - otherwise `list` +- lifecycle actions: + - from lifecycle capabilities and current runtime support +- scope and workspace root: + - from `list` +- display metadata: + - `catalog` first + - lifecycle fallback only when catalog is missing + +For `universal_available` entries: + +- identity and metadata: + - from `catalog` +- supported targets and scopes: + - from `catalog` +- installed state: + - none + +For `native_external_installed` entries: + +- identity: + - from `discover` +- observed state and scopes: + - from `discover` +- manageability: + - from `discover` +- relation to universal entries: + - advisory only + +### Conflict resolution rules + +If `catalog` says a universal plugin exists, but `list` has no managed installation: + +- render `universal_available` + +If `list` has a managed installation, but `catalog` entry is missing because the catalog snapshot is stale or incomplete: + +- still render `universal_installed` +- mark catalog metadata as unavailable +- do not hide the installed entry + +If `discover` finds a native external install that strongly matches a universal plugin: + +- show both entries +- add relation hints such as `Also available as universal plugin` +- do not collapse them into one card + +If `doctor` returns a degraded target while `list` shows the same target as installed: + +- `doctor` wins for health/status presentation +- `list` remains the source of integration ownership and policy context + +### Renderer field ownership + +Recommended renderer ownership matrix: + +- `entry_kind` + - derived by the app from surface class, never from heuristics +- `entry_id` + - `managed_entry_key` for `universal_installed` + - stable catalog `integration_id` for `universal_available` + - stable discovery identity for `native_external_installed` +- `integration_id` + - `catalog` or `list` + - never guessed from discovery display name alone +- `display_name` + - `catalog` for universal entries + - `discover` for native external entries +- `description` + - `catalog` for universal entries + - `discover` for native external entries +- `supported_targets` + - `catalog` +- `manageable_targets` + - `catalog` +- `primary_action_targets` + - `catalog` +- `installed_targets` + - `list`, augmented by `doctor` +- `health_state` + - `doctor` +- `observed_scopes` + - `discover` +- `manageability` + - `discover` +- `resolved_source_ref` + - `list` +- `workspace_root` + - `list` + +### Why this section matters + +Without these derivation rules, the app will almost certainly drift into one of the following failure modes: + +- silently merging universal and native external installs +- showing `available` when something is already installed +- losing managed entries when the catalog snapshot is stale +- inventing manageability for discovered native installs +- letting `catalog` or `discover` override lifecycle truth they do not own + +## Worked Examples + +These examples are intentionally concrete. +They should be used as golden fixtures for both backend contracts and app normalization. + +### Example 1 - managed universal install with overlap suppression + +Situation: + +- `catalog` contains universal `context7` +- `list` contains managed installation `context7` +- `doctor` says target is healthy +- native surfaces also visibly contain the installed plugin because managed lifecycle already materialized it there + +Expected result: + +- render one `universal_installed` entry for `context7` +- do **not** render a second `native_external_installed` copy for the same managed install +- health comes from `doctor` +- metadata comes from `catalog` + +Why: + +- discovery overlap suppression should remove the duplicate native observation + +### Example 2 - native Claude marketplace install with no managed lifecycle record + +Situation: + +- `catalog` contains universal `context7` +- `list` does not contain `context7` +- `discover` finds a Claude-native install from a marketplace source +- backend can only establish an advisory relation to universal `context7` + +Expected result: + +- render one `native_external_installed` entry +- optionally render the universal `context7` catalog card separately if not installed through `plugin-kit-ai` +- show relation hint such as `Also available as universal plugin` +- do not collapse them into one entry +- do not show destructive managed actions unless discovery explicitly says they are safe + +### Example 3 - stale or partial catalog snapshot + +Situation: + +- `list` contains managed `demo-plugin` +- `doctor` contains managed `demo-plugin` +- `catalog` snapshot does not contain `demo-plugin` + +Expected result: + +- still render `demo-plugin` as `universal_installed` +- preserve lifecycle actions and managed state +- degrade catalog-derived metadata gracefully +- do not hide the entry just because storefront metadata is missing + +Why: + +- managed installation truth belongs to `list` +- catalog absence is not permission to erase installed truth + +### Example 4 - Codex prepared but not activated yet + +Situation: + +- `discover` finds: + - marketplace entry + - plugin root + - no cache bundle yet +- backend classifies the Codex plugin as prepared but not active + +Expected result: + +- render `native_external_installed` +- show observed state `prepared` +- do not claim it is fully active +- if the backend has an activation hint, show it +- do not pretend this equals a healthy managed universal install + +### Example 5 - degraded Codex native install + +Situation: + +- cache bundle exists +- but expected marketplace source or plugin root is missing or drifted + +Expected result: + +- render `native_external_installed` +- show observed state `degraded` +- do not auto-remove or auto-adopt +- keep relation to universal entries advisory only + +### Example 6 - heuristic match only + +Situation: + +- `discover` finds native plugin display name `Notes` +- `catalog` contains more than one plausible universal candidate with similar wording + +Expected result: + +- either no match or `heuristic` +- no merge +- no stronger action unlock +- no `exact` badge or stronger “same plugin” copy + +### Example 7 - conflicting managed install intent for the same integration + +Situation: + +- backend still uses single-record-per-integration state identity +- `list` already contains managed `context7` in `user` scope +- user now requests managed `context7` in `project` scope for `/repo` + +Expected result: + +- backend returns structured conflict or explicit unsupported-state error +- app explains that the same integration is already present in managed state under a different install context +- app does not pretend both managed installs now coexist +- app does not silently overwrite the existing entry in UI optimism + +### Example 8 - update plans newly adopted target support + +Situation: + +- managed `context7` currently exists only for `claude` +- next manifest now also supports `codex` +- managed policy says `adopt_new_targets=auto` + +Expected result: + +- update review shows ordinary update work for existing targets +- update review also shows explicit `adopt_new_target` work for `codex` +- app does not bury this inside generic update prose +- if policy were `manual`, app would instead surface a warning, not pretend adoption will happen + +## Command Semantics Matrix + +The app integration should treat command classes differently. + +| Surface | Command class | Side effects | App expectation | +|---|---|---|---| +| `catalog` | read-only | none | safe to retry, safe to cache | +| `discover` | read-only | none | safe to retry, safe to cache | +| `list` | read-only | none | safe to retry, source of managed ownership | +| `doctor` | read-only | none | may report issues and still return structured JSON | +| `add` | mutating | yes | must pass explicit apply mode and explicit context | +| `update` | mutating | yes | must pass explicit apply mode | +| `remove` | mutating | yes | must pass explicit apply mode | +| `repair` | mutating | yes | must pass explicit apply mode | + +### Read-only command rules + +- read-only commands must never mutate native state +- read-only commands may return structured issues without that meaning a transport failure +- the app may cache read-only payloads +- the app may retry read-only commands automatically + +### Mutating command rules + +- mutating commands must never rely on CLI defaults for apply behavior +- mutating commands must always receive explicit context: + - target selection where relevant + - scope where relevant + - workspace root where relevant +- post-mutation refresh in the app must use the origin operation context, not global last-view state +- for `add`, state-conflict semantics must be explicit: + - either surfaced in planning/preflight + - or represented as a known late-apply conflict the app blocks before confirm + +### Source-resolution and preview cost rule + +Current backend behavior is not symmetric across lifecycle previews. + +From current code: + +- `add` + - plan resolves requested source + - may clone/fetch remote source +- `update_version` + - dry-run plan resolves current stored source + - may clone/fetch remote source before review is shown +- `remove_orphaned_target` + - dry-run plan does **not** resolve source + - apply does resolve source +- `repair_drift` + - dry-run plan does **not** resolve source + - apply does resolve source + +Recommended rule: + +- app UX should not assume every preview is cheap or local-only +- update review in particular should be allowed a slower “checking source / comparing manifest” state +- timeout policy should distinguish: + - cheap state-only views like `list` and `doctor` + - potentially source-resolving lifecycle previews like `add` and `update` + +### Mutation outcome matrix + +| Payload outcome | Meaning | UI expectation | +|---|---|---| +| `planned` | dry-run only, no mutation applied | show review state, not success | +| `applied` | mutation committed | show success with target details | +| `rolled_back` | mutation failed after progress but native/state effects were rolled back | show failure, but do not imply degraded managed state remains | +| `degraded` | mutation failed and degraded managed state was persisted | show failure with high-visibility repair guidance | +| `partial_success` | some requested targets succeeded while others did not | keep target-granular result rendering | +| `failed` | no structured recovery class beyond failure | show failure with backend reason | + +### Payload vs process-failure rule + +For app integration there are two different failure classes: + +- transport/process failure + - no valid contract payload + - process spawn failure + - timeout + - malformed JSON +- structured domain failure + - valid contract payload exists + - payload says `ok=false`, `outcome=failed`, `issue_count>0`, or equivalent + +The app must distinguish those two classes clearly. + +### Why this section matters + +Without a command semantics matrix, the app will eventually do one or more of these: + +- treat every non-zero exit as an unstructured crash +- lose useful report payloads on domain failures +- accidentally rely on `--dry-run=true` +- retry mutating commands as if they were read-only + +## UI and Entry Model in claude_team + +### Normalized entry kinds + +- `universal_available` +- `universal_installed` +- `native_external_installed` + +### Ranking order + +1. installed universal +2. installed native external +3. available universal + +### Card labels + +Universal: + +- `Universal` +- `Anthropic + Codex` +- `Anthropic only` +- `Codex only` + +Native external: + +- `Installed - Anthropic only` +- `Installed - Codex only` + +### Detail requirements + +Universal detail must show: + +- keywords or tags +- category only when curated metadata exists +- target support +- scope support +- managed freshness when installed: + - last checked + - last updated +- README/detail content +- lifecycle actions where supported +- lifecycle target detail from backend truth: + - warnings + - owned native objects when relevant + - settings/config files when relevant + - blocking or activation guidance without app-side inference + +Native external detail must show: + +- native target +- detected source +- observed scopes +- manageability +- relation to universal plugin when matched + +### Empty and degraded states + +If `catalog` fails but `discover` works: + +- show native external entries +- show a warning for universal catalog unavailability + +If `discover` fails but `catalog` works: + +- show universal entries +- do not claim “no installed plugins” +- show a warning that native discovery is unavailable + +If backend version is unsupported: + +- keep read-only view if safe +- disable lifecycle actions +- show explicit compatibility message + +## App Integration Mode + +`claude_team` should integrate with `plugin-kit-ai` as a bundled CLI binary with versioned JSON I/O. + +Not as: + +- parsed human terminal output +- direct repo scraping +- an embedded second UI +- a Go library linked into Electron + +Why: + +- Electron already knows how to run bundled binaries +- JSON contracts are versionable and testable +- rollout and rollback stay simple +- the same backend surface can be exercised in dev and packaged builds + +## plugin-kit-ai Changes Required + +### Must-have for phase 0 + +1. `integrations --format json` around the current normalized lifecycle model + 🎯 10 🛡️ 10 🧠 4 + Approximate change size: `150-300` lines + +2. integration-level fields in managed lifecycle JSON + Needed because current `Report.Targets` do not identify which integration a target belongs to. + 🎯 10 🛡️ 10 🧠 5 + Approximate change size: `120-240` lines + +3. explicit managed-state identity policy + Needed because current state upsert/find/remove is keyed only by `integration_id`. + 🎯 10 🛡️ 10 🧠 5 + Approximate change size: `80-180` lines + +4. explicit conflict-timing policy for `add` + Needed because current dry-run planning can succeed while apply later fails with `integration already exists in state`. + 🎯 10 🛡️ 9 🧠 4 + Approximate change size: `60-140` lines + +5. plan blocking fidelity in app-facing JSON + Needed because current `AdapterPlan` knows `Blocking`, but current target report projection does not preserve it. + 🎯 10 🛡️ 10 🧠 4 + Approximate change size: `60-140` lines + +6. target-detail fidelity in app-facing lifecycle JSON + Needed because current adapter layer already knows warnings, owned objects, and settings context, but current `TargetReport` drops them. + 🎯 10 🛡️ 10 🧠 4 + Approximate change size: `80-180` lines + +7. `integrations catalog --format json` + 🎯 10 🛡️ 9 🧠 5 + Approximate change size: `180-350` lines + +8. `integrations discover --format json` + 🎯 10 🛡️ 10 🧠 6 + Approximate change size: `220-450` lines + +9. explicit app-mode service construction decoupled from implicit cwd + Needed because current `newService()` still derives workspace/repo context from `os.Getwd()`. + 🎯 10 🛡️ 10 🧠 5 + Approximate change size: `100-220` lines + +10. `--workspace-root` and explicit request-context propagation across plan and apply + 🎯 9 🛡️ 10 🧠 4 + Approximate change size: `80-180` lines + +11. capability, scope, and source-kind projection in catalog/discovery + 🎯 9 🛡️ 9 🧠 4 + Approximate change size: `80-160` lines + +12. stable detail path or detail endpoint + 🎯 8 🛡️ 8 🧠 4 + Approximate change size: `60-140` lines + +13. discovery trust/manageability metadata + 🎯 8 🛡️ 9 🧠 5 + Approximate change size: `80-180` lines + +14. provenance metadata + 🎯 8 🛡️ 9 🧠 4 + Approximate change size: `60-140` lines + +### Explicitly not required for the first app rollout + +- `integrations sync` +- workspace-lock driven desired-state workflows +- `enable` / `disable` UI +- native convenience uninstall +- adopt + +Reason: + +- they are either repo-lock oriented, lower-value than core lifecycle, or too risky before `discover` is proven out + +## claude_team Changes Required + +### Main-process additions + +- `PluginKitBinaryResolver` +- `PluginKitService` +- `PluginKitCatalogService` +- `PluginKitDiscoveryService` +- `PluginKitLifecycleService` + +### Current app touchpoints this migration must replace or bypass + +Current plugin flow in `claude_team` is still Claude-marketplace-shaped: + +- `src/main/services/extensions/catalog/PluginCatalogService.ts` +- `src/main/services/extensions/state/PluginInstallationStateService.ts` +- `src/main/services/extensions/install/PluginInstallService.ts` +- `src/main/services/extensions/ExtensionFacadeService.ts` +- `src/shared/types/extensions/plugin.ts` +- `src/renderer/store/slices/extensionsSlice.ts` +- `src/renderer/components/extensions/plugins/*` + +Recommended rule: + +- do not keep stretching the current `EnrichedPlugin` model until it represents two different product classes badly +- introduce a new normalized plugin-entry layer for the plugin-kit-backed flow +- keep the legacy Claude-only model behind the feature flag until rollout is complete + +### Migration-safe IPC and API boundary + +Current preload and IPC plugin APIs are shaped around the legacy Claude-marketplace model: + +- `getAll(projectPath?, forceRefresh?) -> EnrichedPlugin[]` +- legacy install and uninstall request shapes + +Recommended rule: + +- do not silently change the meaning of the legacy plugin IPC payloads during rollout +- introduce a plugin-kit-backed API boundary separately, or clearly version the payload shape +- keep legacy and plugin-kit flows selectable behind the feature flag until the mixed-entry model is proven + +Why: + +- this reduces rollout blast radius +- it keeps renderer tests and preload contracts easier to reason about +- it avoids “same method name, different product model” ambiguity + +### Required app-side model split + +Current `EnrichedPlugin` is shaped around one catalog plus installed counts: + +- one canonical `pluginId` +- one marketplace-oriented metadata shape +- one merged installed-state view + +That is not a safe long-term shape for mixed: + +- `universal_catalog` +- `universal_installed` +- `native_external_installed` + +Recommended rule: + +- phase 1 should add a new normalized entry model for the plugin page +- old `EnrichedPlugin` can remain only inside the legacy backend path +- the new renderer/store layer should be built around explicit `entry_kind`, not around legacy marketplace assumptions + +### Responsibilities + +#### `PluginKitBinaryResolver` + +- resolve bundled binary +- resolve dev binary +- report version + +#### `PluginKitService` + +- execute commands +- validate `format` and `schema_version` +- apply timeouts +- normalize errors +- redact diagnostics + +#### `PluginKitCatalogService` + +- call `catalog` +- cache normalized results + +#### `PluginKitDiscoveryService` + +- call `discover` +- normalize native external entries + +#### `PluginKitLifecycleService` + +- call `add/update/remove/repair/list/doctor` +- normalize target-level results + +### Store and cache rules + +- only one mutating operation per `entryId + scope + projectPath` +- `catalog` and `discover` may refresh in parallel +- stale responses must never overwrite newer state +- post-mutation refresh must use the origin operation context + +Recommended keying rule: + +- `universal_installed` cache and mutation keys should use `managed_entry_key`, not only `integration_id` +- `integration_id` alone is not a safe future-proof cache key for managed lifecycle entries +- the app should treat payloads that omit `managed_entry_key` as legacy-incompatible for the plugin-kit-backed flow +- mutation UI state should also preserve backend outcome class: + - `applied` + - `rolled_back` + - `degraded` + instead of collapsing every non-success into one generic failure bucket + +### Feature flag + +Recommended app flag: + +- `extensions.plugins.backend = legacy | plugin-kit` + +## Rollout Phases + +### Phase 0 - plugin-kit-ai contracts first + +🎯 10 🛡️ 10 🧠 5 +Approximate change size: `400-900` lines + +Ship: + +- JSON envelopes +- `catalog` +- `discover` +- app-safe service construction +- `--workspace-root` +- target-detail fidelity for warnings / owned objects / blocking state +- schema docs +- source/provenance metadata + +Acceptance: + +- contracts are versioned and testable +- command classes are clearly read-only vs mutating +- managed lifecycle JSON includes integration-level context +- managed-state identity policy is explicit and testable +- blocked vs applyable plans are distinguishable in machine-readable payloads +- views are internally consistent across `catalog`, `discover`, `list`, and `doctor` +- project-sensitive app mode no longer depends on implicit `cwd` + +### Phase 1 - read-only app integration + +🎯 9 🛡️ 9 🧠 5 +Approximate change size: `300-650` lines + +Ship: + +- bundled binary +- catalog rendering +- discovery rendering +- mixed-entry normalization +- ranking and labels +- feature-flagged backend switch + +Acceptance: + +- native external entries render truthfully +- universal catalog renders truthfully +- target detail renders from backend truth, not renderer guesses +- target-specific effective metadata does not leak into shared universal card identity or summary +- no misleading install button on native external entries +- page remains useful when `catalog` or `discover` partially fail +- source-resolving previews such as `update` have truthful loading and timeout behavior +- warm-load performance remains acceptable +- plugin-kit-backed renderer state uses the new normalized entry model instead of overloading legacy `EnrichedPlugin` + +### Phase 2 - universal lifecycle actions + +🎯 9 🛡️ 9 🧠 6 +Approximate change size: `300-700` lines + +Ship: + +- install +- update +- remove +- repair +- target-level result rendering + +Acceptance: + +- direct Claude path green +- multimodel Anthropic + Codex path green +- user/project installs stable +- partial target results rendered truthfully +- safe retries do not corrupt state + +### Phase 3 - optional native convenience flows + +🎯 7 🛡️ 8 🧠 7 +Approximate change size: `150-400` lines + +Optional: + +- native uninstall where backend declares safe +- adopt where backend declares safe + +Acceptance: + +- no ambiguity between universal managed and native external state + +## Recommended First PR Sequence + +### PR 0 - freeze backend semantics that shape every later contract + +Ship: + +- explicit managed-state identity policy +- explicit conflict-timing policy for `add` +- explicit app-mode service-construction policy +- explicit planning-context policy for project-sensitive commands + +Must not do: + +- silently preserve current implicit behavior just because the CLI can tolerate it +- let the app contract depend on hidden `cwd` or late state-lock conflicts + +### PR 1 - JSON envelopes for existing lifecycle commands + +Ship: + +- `integrations {list|doctor|add|update|remove|repair} --format json` +- schema identifiers +- contract docs + +Must not do: + +- change lifecycle semantics +- invent `catalog` or `discover` early + +### PR 2 - managed lifecycle integration context + +Ship: + +- `integration_id` +- `managed_entry_key` +- source refs +- policy scope +- workspace root +- target grouping under managed entries +- target-detail fidelity for warnings / owned objects / settings context + +Must not do: + +- flatten per-target state into one integration status + +### PR 3 - explicit workspace root control + +Ship: + +- `--workspace-root` +- app-safe service construction for project-sensitive flows +- project-sensitive commands stop depending on implicit `cwd` + +Must not do: + +- silently rewrite missing workspace root into current cwd for project commands + +### PR 4 - universal catalog + +Ship: + +- normalized universal catalog JSON +- bundled snapshot story +- freshness metadata +- README/detail default rule + +Must not do: + +- block on category, icon, or popularity + +### PR 5 - native discovery + +Ship: + +- native external discovery JSON +- observed state +- activation hints +- manageability and match metadata + +Must not do: + +- auto-merge native and universal entries +- expose destructive actions without explicit backend manageability + +### PR 6 - claude_team read-only integration + +Ship: + +- bundled binary +- read-only mixed-entry rendering +- ranking +- degraded-state handling + +Must not do: + +- wire mutating actions before backend contracts are pinned + +## PR Exit Criteria + +These checks are intentionally strict. A PR is not “basically done” if these are still fuzzy. + +### Backend contract PRs + +Required: + +- schema id is documented +- schema version is documented +- arrays are never `null` +- one golden fixture exists +- one failure fixture exists +- one compatibility test exists +- one fixture proves target-detail fidelity for warnings or owned-object context +- one fixture proves adopted-target update semantics or explicit adopted-target warning behavior +- one fixture proves grouped lifecycle freshness fields are projected + +### Backend discovery PRs + +Required: + +- at least one Claude discovery fixture +- at least one Codex discovery fixture +- explicit observed-state coverage +- explicit Codex `prepared` and `degraded` fixture coverage +- explicit manageability coverage +- no destructive side effects in read-only commands + +### Backend doctor/report PRs + +Required: + +- one fixture proves top-level `doctor` recovery warnings survive JSON projection +- one fixture proves target-level manual steps survive JSON projection +- interrupted/degraded operation guidance is not flattened away + +### App read-only integration PRs + +Required: + +- mixed-entry rendering works with only `catalog` +- mixed-entry rendering works with only `discover` +- degraded-state copy is truthful +- unsupported-version handling is visible and safe + +### App lifecycle PRs + +Required: + +- explicit dry-run protection +- target-level partial success rendering +- `rolled_back` vs `degraded` outcome rendering is distinct and truthful +- source-resolving preview states are handled explicitly, not as generic loading guesswork +- stale-response protection +- safe retry behavior + +## Risks and Lowest-Confidence Areas + +### 1. Manifest model split between lifecycle and catalog + +🎯 8 🛡️ 8 🧠 6 + +Why this is hard: + +- the richer authored model and the narrower lifecycle model do not preserve the same metadata +- catalog wants richer storefront fields +- lifecycle wants compact normalized install truth + +Best resolution: + +- keep lifecycle and catalog as separate backend surfaces +- allow catalog generation to read the richer authored model +- normalize both into explicit JSON contracts + +### 2. Codex native external discovery fidelity + +🎯 7 🛡️ 8 🧠 7 + +Why this is hard: + +- current Codex inspect logic distinguishes installed, activation pending, disabled, and degraded by combining multiple native surfaces +- that is richer than installed yes/no, but still not a full external discovery surface + +Best resolution: + +- backend-owned discovery +- explicit `observed_state` +- explicit `activation_hint` +- never collapse prepared Codex state into fully active installed state + +### 3. Workspace-root and repo-root coupling + +🎯 8 🛡️ 9 🧠 6 + +Why this is hard: + +- current service construction still derives important paths from `os.Getwd()` +- workspace-lock paths are repo-root oriented +- packaged Electron app must not inherit the wrong working directory semantics + +Best resolution: + +- add explicit `--workspace-root` +- reject missing workspace root for project-sensitive commands +- keep `sync` and workspace-lock flows out of the first app rollout + +### 4. Source resolution cost for lifecycle vs catalog + +🎯 8 🛡️ 9 🧠 5 + +Why this is hard: + +- lifecycle source resolution may legitimately clone remote sources +- storefront catalog must stay fast and bounded + +Best resolution: + +- keep catalog generation batch-oriented +- keep lifecycle resolution source-oriented +- never build the app catalog by doing one source clone per entry at runtime + +### 5. Popularity parity + +🎯 6 🛡️ 9 🧠 5 + +Best resolution: + +- make popularity optional +- add it later only from a first-class universal metric + +### 6. Safe native convenience actions + +🎯 6 🛡️ 8 🧠 7 + +Best resolution: + +- delay to phase 3 +- require backend-declared manageability + +### 7. App-facing target subset confusion + +🎯 8 🛡️ 9 🧠 5 + +Why this is hard: + +- `plugin-kit-ai` knows more targets than the app should expose as first-class plugin lanes +- if the app shows every authored target equally, users will see support the app cannot actually manage yet + +Best resolution: + +- preserve full authored support in backend catalog +- use only `claude` and `codex-package` for primary actionability in `claude_team` +- keep broader support as secondary metadata only + +### 8. Target projection drift between authored, manageable, and app-primary targets + +🎯 8 🛡️ 9 🧠 6 + +Why this is hard: + +- these three target sets are related but not identical +- if they collapse into one field, the UI will eventually lie about support or actionability + +Best resolution: + +- preserve separate target fields in catalog +- test them with golden fixtures +- never let renderer heuristics rebuild one layer from another + +### 9. Managed state identity and multiplicity + +🎯 7 🛡️ 10 🧠 7 + +Why this is hard: + +- current state operations are keyed only by `integration_id` +- that may be fine for the current CLI mental model, but it is not enough if the app later wants parallel managed records for the same integration across scopes or workspaces +- if this stays implicit, the app can easily promise install combinations the backend will silently overwrite or collapse + +Best resolution: + +- make the current single-record-per-integration rule explicit in phase 0, or upgrade backend state identity before the app promises multiplicity +- return structured conflicts instead of silent replacement for incompatible install intents + +### 10. Adopted-target update semantics + +🎯 7 🛡️ 9 🧠 6 + +Why this is hard: + +- current `update_version` planning may also create `adopt_new_target` work +- that behavior is policy-sensitive and depends on next manifest deliveries +- if the app hides this inside generic update review, users can approve broader changes than they realized + +Best resolution: + +- preserve adopted-target work as explicit target-level semantics in plan/result payloads +- show it distinctly in review and result UI +- if backend cannot expose it clearly yet, keep update UX conservative instead of pretending it is plain update-only work + +### 11. Managed freshness truth + +🎯 8 🛡️ 9 🧠 4 + +Why this is hard: + +- current managed state already tracks freshness timestamps +- but current public lifecycle report does not project them +- without them, desktop UI can accidentally present stored state as if it were freshly revalidated remote truth + +Best resolution: + +- expose `last_checked_at` and `last_updated_at` in grouped lifecycle JSON +- use explicit freshness copy in UI +- never imply remote verification unless a source-resolving action actually ran + +## E2E and Contract Testing + +### plugin-kit-ai + +Must add: + +- JSON contract tests for `catalog`, `discover`, `list`, `doctor` +- JSON contract tests for `add/update/remove/repair` +- temp-home tests +- temp-project tests +- target adapter tests for Claude and Codex +- golden fixture tests against at least one real universal plugin + +### claude_team + +Must add: + +- `PluginKitService` parsing tests +- mixed-entry normalization tests +- ranking tests +- badge tests +- manual-step rendering tests +- project-context tests + +### Failure checks + +- bundled binary missing +- bundled binary wrong architecture +- schema mismatch +- project install without workspace root +- unsupported scope for selected target +- partial target success +- stale catalog with failed refresh +- repeated idempotent retries + +## No-Go Conditions + +Do not enable by default if any of these remain true: + +- universal and native external entries still auto-merge by display name +- lifecycle still depends on parsing prose +- `local` scope is silently remapped +- partial target failures are flattened into one misleading status +- native external entries expose destructive actions without backend-declared manageability + +## Open Questions With Recommended Defaults + +### 1. Should the app call GitHub directly for universal plugins? + +Recommended default: **No**. + +Use backend `catalog`. + +### 2. Should native external entries merge into universal entries when matching looks obvious? + +Recommended default: **No**. + +Keep separate and use soft relations only. + +### 3. Should `local` scope be shown for universal installs in phase 1? + +Recommended default: **No**. + +Only show scopes explicitly supported by backend target metadata. + +### 4. Should popularity sorting block the migration? + +Recommended default: **No**. + +Hide or degrade it if there is no stable universal metric. + +### 5. Should `adopt` block phase 1? + +Recommended default: **No**. + +Phase 1 needs `discover`, not `adopt`. + +### 6. Should native uninstall for discovered entries ship immediately? + +Recommended default: **No**. + +Only where backend declares safe manageability. + +### 7. Should category or icon metadata block phase 1? + +Recommended default: **No**. + +Ship phase 1 with honest minimum storefront metadata, then add optional curation metadata later. + +### 8. Should `discover` be implemented by reusing current per-target `Inspect` directly? + +Recommended default: **No**. + +Use `Inspect` as one building block, but build a real native discovery scanner above it because external discovery and managed inspection are not the same problem. + +### 9. Should catalog be generated from the current `integrationctl` manifest loader only? + +Recommended default: **No**. + +Prefer the richer authored-model path such as `pluginmanifest.Inspect/publication/targetcontracts`, or enrich the lifecycle loader first. + +### 10. Should the `claude_team` plugin page expose every target known to `plugin-kit-ai` as a first-class action lane? + +Recommended default: **No**. + +Use the app-relevant target subset for primary actions: + +- `claude` +- `codex-package` + +Keep broader authored support as optional secondary detail, not as primary actionability. + +### 11. Should the catalog expose only one target field? + +Recommended default: **No**. + +Keep separate fields for: + +- authored target support +- backend-manageable lifecycle support +- app-primary action targets + +### 12. Should phase 1 support multiple parallel managed installs of the same integration across scopes or workspaces? + +Recommended default: **No**. + +Keep the current single-record-per-integration model explicit until backend state identity is upgraded. + +That means: + +- same-`integration_id` managed installs should conflict +- the app should not promise parallel managed `user` + `project` presence for one universal plugin yet + +## Phase-1 Conservative Defaults + +These defaults are intentional. +They should not be treated as missing polish or as accidental gaps. + +| Risky seam | Phase-1 default | Why | +|---|---|---| +| `local` scope for universal plugins | hide it | backend does not honestly support it yet | +| native external uninstall | do not expose | destructive authority is not proven yet | +| `adopt` | do not expose | visibility is needed before ownership conversion | +| popularity sorting | optional, not blocking | migration should not depend on a metric the backend does not yet own | +| target-specific metadata | enhance detail only | shared storefront truth should stay stable | +| heuristic universal matching | advisory only | avoids false ownership merge | +| ambiguous Codex observed state | degrade to `observed_degraded` | safer than over-claiming active state | +| stale catalog while managed installs exist | keep managed entry visible | `list` wins for managed existence | +| stale discover while catalog works | keep catalog visible and warn | do not claim “no installed plugins” | +| grouped lifecycle keys missing | treat as incompatible payload | do not reconstruct entry groups in app | +| same integration across multiple managed scopes/workspaces | do not promise it until backend identity model supports it | avoids silent state overwrite | + +## Final Recommendation + +Build this integration in three layers: + +1. make `plugin-kit-ai` expose stable JSON contracts +2. make `plugin-kit-ai` the normalized backend for universal catalog, discovery, and lifecycle +3. make `claude_team` a frontend over those contracts + +This is the most reliable path because it: + +- keeps the current app UX +- reuses the existing lifecycle engine in `plugin-kit-ai` +- avoids false parity and false merging +- keeps rollout reversible diff --git a/docs/research/context-usage-audit.md b/docs/research/context-usage-audit.md new file mode 100644 index 00000000..a6b34267 --- /dev/null +++ b/docs/research/context-usage-audit.md @@ -0,0 +1,496 @@ +# Context Usage Audit + +**Дата**: 2026-04-18 +**Статус**: Research +**Goal**: проверить, как в проекте сейчас считается usage контекста, сверить это с official docs и с реальными логами, и зафиксировать, что нужно менять для понятного и точного UI + +## Executive Summary + +Главный вывод: + +- ✅ Для **Anthropic prompt-side input** текущая базовая формула `input_tokens + cache_creation_input_tokens + cache_read_input_tokens` корректна. +- ❌ Для **"процент занятого контекста"** текущий UI смешивает несколько разных сущностей: + - total prompt input + - visible/debuggable context + - full context used in the turn + - guessed context window +- ❌ Кнопка открытия context panel на team screen сейчас показывает **не процент занятого контекста**, а смесь `visible context / total tokens`, при этом подписывает это как `of input`. +- ❌ Live lead context usage в team runtime **не учитывает `output_tokens`**, хотя Anthropic docs явно пишут, что input и output components count toward the context window. +- ⚠️ Для **Codex** текущие локальные session logs часто вообще не содержат usable input-side token telemetry: в `.jsonl` виден `output_tokens`, а `input_tokens/cache_*` остаются нулями. То есть "точный процент" для Codex из текущего источника правды пока получить нельзя. +- ⚠️ Для **Anthropic context window size** нельзя опираться только на `"[1m]"` suffix. По актуальным docs/релиз-ноутам окно зависит от конкретной модели: native `1M` уже есть у новых raw model ids вроде `claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6`, тогда как часть legacy путей остаётся на `200k` или временном beta-path. + +## 1. Что сейчас считается в коде + +### 1.1 Live lead context в team runtime + +Источник: + +- `src/main/services/team/TeamProvisioningService.ts` + +Текущая формула: + +```ts +currentTokens = input_tokens + cache_creation_input_tokens + cache_read_input_tokens +percent = currentTokens / contextWindow +``` + +Это значение эмитится как `lead-context`. + +Что важно: + +- это **total prompt input** +- это **не full context used for the completed turn** +- `output_tokens` сейчас исключены + +### 1.2 Context button на экране команды + +Источник: + +- `src/renderer/components/team/TeamDetailView.tsx` + +Текущее поведение: + +- собирается `visibleContextTokens = sumContextInjectionTokens(allContextInjections)` +- затем считается `visibleContextPercentLabel = formatPercentOfTotal(visibleContextTokens, lastAiGroupTotalTokens)` +- при этом `lastAiGroupTotalTokens` сейчас = `input + cache_read + cache_creation + output` +- но helper `formatPercentOfTotal()` возвращает строку вида `"X% of input"` + +Итог: + +- знаменатель уже **не input** +- числитель это вообще **visible subset** +- label говорит **of input** +- кнопка выглядит как будто это **общий context usage** + +То есть тут сразу 3 semantic mismatch. + +### 1.3 Session Context Panel / Token popover + +Источники: + +- `src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx` +- `src/renderer/components/common/TokenUsageDisplay.tsx` + +Сейчас в проекте одновременно существуют 3 разных процента: + +1. `visible_estimated / total_input` +2. `visible_estimated / (input + output + cache)` +3. `prompt_input / context_window` + +Но в UI они местами называются почти одинаково. + +## 2. Что говорят official docs + +### 2.1 Anthropic: что такое `input_tokens` при caching + +Official docs: + +- [Anthropic prompt caching](https://docs.anthropic.com/ru/docs/build-with-claude/prompt-caching) + +Ключевые факты: + +- `input_tokens` - это только токены **после последней cache breakpoint** +- total prompt input считается как: + +```text +total_input_tokens = cache_read_input_tokens + cache_creation_input_tokens + input_tokens +``` + +Источник: + +- docs lines 491-500, 493-500, 495: + - `input_tokens` представляет только токены после последней точки разрыва кэша + - `total_input_tokens = cache_read_input_tokens + cache_creation_input_tokens + input_tokens` + +Вывод: + +- текущая базовая формула runtime для **Anthropic prompt input** правильная +- жалоба пользователя на "input percent" логична, потому что **`input_tokens` alone действительно не равен общему prompt input** + +### 2.2 Anthropic: что вообще считается context window + +Official docs: + +- [Anthropic context windows](https://docs.anthropic.com/en/docs/build-with-claude/context-windows) + +Ключевые факты: + +- context window refers to all text model can reference, **including the response itself** +- при tool use docs прямо говорят: + - **all input and output components count toward the context window** + +Источник: + +- lines 194-197 +- lines 215-220 +- lines 255-262 + +Вывод: + +- если UI обещает показать именно **"сколько контекста занято"**, то `output_tokens` игнорировать нельзя +- текущий live team formula under-reports occupied context for completed turn + +### 2.3 Anthropic: thinking blocks + +Official docs: + +- [Anthropic context windows](https://docs.anthropic.com/en/docs/build-with-claude/context-windows) + +Ключевой факт: + +- previous thinking blocks are automatically stripped from future context + +Источник: + +- lines 225-239, especially 228 and 237 + +Вывод: + +- есть важная разница между: + - **full context used during current turn** + - **context that will carry into future prompt** +- usage fields alone не дают perfectly exact "future carried context" без доп. нормализации thinking + +### 2.4 Anthropic: какие модели сейчас имеют 1M context window + +Official docs: + +- [Anthropic models overview](https://platform.claude.com/docs/en/about-claude/models/overview) +- [Anthropic release notes](https://platform.claude.com/docs/en/release-notes/overview) +- [Anthropic context windows](https://platform.claude.com/docs/en/build-with-claude/context-windows) + +Ключевые факты на дату проверки: + +- current models overview показывает: + - `claude-opus-4-7` - `1M` + - `claude-sonnet-4-6` - `1M` + - `claude-haiku-4-5` - `200k` +- release notes отдельно фиксируют: + - с `2026-03-13` `1M` GA для `Claude Opus 4.6` и `Claude Sonnet 4.6` + - `2026-03-30` объявлен retirement beta-path для `Claude Sonnet 4.5` и `Claude Sonnet 4` на `2026-04-30` +- context windows page также указывает, что native long-context matrix уже не сводится к одному beta-header сценарию + +Вывод: + +- inference размера окна для Anthropic надо делать по **model matrix**, а не только по `"[1m]"` suffix +- internal app-alias `"[1m]"` всё ещё полезен как явный сигнал team UX, но для raw session model ids этого уже недостаточно + +## 3. Что показывают реальные локальные логи + +Проверены реальные `~/.claude/projects/*.jsonl`. + +### 3.1 Claude / Anthropic + +Типичный реальный кейс: + +```json +"usage": { + "input_tokens": 3, + "cache_creation_input_tokens": 9284, + "cache_read_input_tokens": 63347, + "output_tokens": 8 +} +``` + +Это значит: + +- `input_tokens = 3` совсем не означает "в prompt было 3 токена" +- реальный total prompt input здесь: + +```text +3 + 9284 + 63347 = 72634 +``` + +То есть UI, который визуально намекает на "input %" без явного объяснения caching breakdown, будет выглядеть багованным даже если арифметика частично правильная. + +### 3.2 Codex / OpenAI path в локальных session logs + +Проверены реальные Codex entries в `~/.claude/projects/-Users-belief-dev-projects-claude-claude-team/**/*.jsonl`. + +Типичный кейс: + +```json +"usage": { + "input_tokens": 0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 650 +} +``` + +Повторяется много раз на `msg_codex_*`. + +Вывод: + +- текущий `.jsonl` source для Codex у нас часто не даёт usable prompt-side usage +- значит из **текущих session logs** нельзя честно строить accurate Codex context percent +- сначала нужен новый telemetry source или нормализация raw usage + +## 4. Codex: что говорят official OpenAI docs + +### 4.1 Context windows + +Official docs: + +- [GPT-5-Codex model](https://developers.openai.com/api/docs/models/gpt-5-codex) +- [codex-mini-latest model](https://developers.openai.com/api/docs/models/codex-mini-latest) + +Ключевые факты на дату проверки: + +- `GPT-5-Codex` - `400,000 context window` +- `codex-mini-latest` - `200,000 context window` + +### 4.2 Cached prompt accounting + +Official docs: + +- [OpenAI prompt caching](https://developers.openai.com/api/docs/guides/prompt-caching) + +Ключевой факт: + +- usage exposes `prompt_tokens_details.cached_tokens` + +Это означает: + +- на уровне OpenAI API нужная prompt-side telemetry в принципе существует +- но наш текущий local session source её, похоже, не сохраняет/не нормализует + +## 5. Конкретные проблемы в текущем проекте + +### 5.1 Semantic mismatch: "visible context" vs "context used" + +Сейчас рядом живут две разные сущности: + +- **Visible Context** - то, что мы можем debug/reduce +- **Context Used** - сколько окна реально занято + +Это не одно и то же. + +Visible Context: + +- это subset prompt-side content +- может сравниваться с total prompt input + +Context Used: + +- это usage against context window +- для Anthropic completed turn это ближе к `total_input + output` + +### 5.2 Неправильный label на context button + +Текущая button label на team screen: + +- выглядит как общий context usage +- но фактически это visible subset percent + +Это и есть один из главных user-facing bugs. + +### 5.3 Inconsistent denominators + +Сейчас по коду используются разные denominators: + +- `totalInputTokens` +- `input + output + cache` +- `contextWindow` + +Без явного переименования метрик UI всегда будет путать. + +### 5.4 Early-run guessed context window + +В `TeamProvisioningService` размер окна сначала может быть guessed: + +- `200K` для `limitContext=true` +- иначе по model-specific matrix: + - internal Anthropic `"[1m]"` alias -> `1M` + - native long-context Anthropic raw ids (`claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6`) -> `1M` + - `GPT-5.4` / `GPT-5.4 pro` -> `1.05M` + - `codex-mini-latest` -> `200K` + - остальные текущие GPT-5/Codex team models -> `400K` + +Потом он обновляется из `modelUsage.contextWindow`, если это поле пришло. + +Значит: + +- ранний live percent может быть временно неточным + +### 5.5 Shared default drift + +В shared utils есть: + +```ts +DEFAULT_CONTEXT_WINDOW = 200_000 +``` + +Но team Anthropic UX по умолчанию исходит из `1M`. + +Это не обязательно immediate arithmetic bug, но это source of drift для разных экранов и helper'ов. + +## 6. Рекомендованная metric model + +Если делать UI понятным и точным, нужно разделить **минимум 3 разные метрики**. + +### 6.1 Prompt Input Used + +Для Anthropic: + +```text +prompt_input_used = + input_tokens + + cache_creation_input_tokens + + cache_read_input_tokens +``` + +Назначение: + +- честный size текущего prompt +- хорошая база для Visible Context % + +### 6.2 Context Window Used + +Для Anthropic completed turn: + +```text +context_window_used_approx = + prompt_input_used + + output_tokens +``` + +Почему `approx`: + +- previous thinking blocks auto-strip from future turns +- exact future carried context нельзя получить из raw usage perfectly + +Но если UI обещает "занятое окно прямо сейчас/на этом ходе", эта формула ближе к docs, чем текущая. + +### 6.3 Visible Context Share + +```text +visible_context_share = visible_context_estimated / prompt_input_used +``` + +Назначение: + +- debug metric +- объясняет, какая часть prompt-а понятна и управляемая пользователю + +Это **не** percent occupied context window. + +## 7. Рекомендованный UI language + +Вместо одного размыто слова `Context` лучше использовать разные подписи: + +- `Context Used` - percent of context window +- `Prompt Input` - current prompt-side tokens +- `Visible Context` - debuggable subset of prompt + +Тогда пользователь сразу видит: + +- сколько занято всего +- сколько из этого prompt +- сколько из prompt мы реально понимаем по breakdown + +## 8. Top 3 implementation options + +### 1. Развести 3 разные метрики и переименовать UI честно + +`🎯 10 🛡️ 9 🧠 7` +Примерно `180-260` строк изменений + +Что сделать: + +- team button показывает только `Context Used` +- panel header отдельно показывает: + - `Visible Context` + - `Prompt Input` + - `Context Window Used` +- `Visible Context` всегда считается только как доля prompt input + +Плюсы: + +- минимальный semantic debt +- почти все пользовательские жалобы закрываются сразу +- легче потом добавить Codex + +Минусы: + +- надо аккуратно переподписать UI в нескольких местах + +### 2. Оставить один главный процент, но считать его по docs как `prompt + output` + +`🎯 8 🛡️ 8 🧠 6` +Примерно `120-180` строк изменений + +Что сделать: + +- live team percent = `(input + cache_read + cache_creation + output) / contextWindow` +- `Visible Context` оставить только внутри sidebar/panel + +Плюсы: + +- очень понятная одна главная цифра +- максимально близко к official Anthropic context-window semantics + +Минусы: + +- future carried context всё равно не perfectly exact из-за thinking blocks +- нужен fallback wording, когда usage incomplete + +### 3. Минимальный fix только label-ов и знаменателей + +`🎯 6 🛡️ 6 🧠 3` +Примерно `40-90` строк изменений + +Что сделать: + +- перестать писать `of input`, если denominator не input +- button переименовать в `Visible` +- panel header явно разделить `Visible` и `Total` + +Плюсы: + +- быстро +- дешево + +Минусы: + +- не решает core semantic debt +- live lead percent всё ещё останется under-reported + +## 9. Recommended next step + +Рекомендую идти по **варианту 1**. + +Почему: + +- он закрывает и math, и naming, и UX confusion +- он не завязан только на Anthropic +- он даёт clean foundation для будущего Codex support + +### Practical plan + +1. Вынести явные type/terms для 3 метрик: + - `promptInputTokens` + - `contextWindowUsedTokens` + - `visibleContextTokens` +2. Исправить live Anthropic runtime formula и wording. +3. Перестать использовать label `of input` там, где denominator не `prompt input`. +4. Для Codex временно показывать: + - window size, если модель известна + - `context usage unavailable` или `output only` + - пока не появится raw prompt telemetry + +## 10. Bottom line + +Главная проблема сейчас не в одной строчке арифметики, а в том, что проект смешал: + +- **prompt input** +- **visible debuggable context** +- **full context window usage** + +В Anthropic path базовая input formula уже в целом нормальная, но UI поверх неё даёт неправильный смысл. + +В Codex path проблема глубже: + +- official API supports cached prompt accounting +- но наш текущий local session telemetry этого не доносит +- поэтому "точный % занятого контекста" для Codex пока нельзя обещать без нового data source diff --git a/docs/research/team-detail-snapshot-messages-activity-plan.md b/docs/research/team-detail-snapshot-messages-activity-plan.md new file mode 100644 index 00000000..67f39d77 --- /dev/null +++ b/docs/research/team-detail-snapshot-messages-activity-plan.md @@ -0,0 +1,3221 @@ +# План: TeamDetail Snapshot / Messages / Member Activity Split + +**Дата**: 2026-04-15 +**Статус**: Detailed execution-ready architecture plan +**Цель**: убрать structural render churn из `TeamDetailView` и отделить message-heavy данные от structural snapshot команды + +## Executive Summary + +Выбранный вариант: + +`Split TeamDetail data flow into structural snapshot + paginated messages + member activity meta` +`🎯 9 🛡️ 9 🧠 8` +Примерно `1600-2600` строк изменений + +Это не "ещё один локальный guard", а нормализация границ данных: + +- `getData(teamName)` перестаёт быть transport для message-heavy UI +- `getMessagesPage(teamName, { limit, cursor })` остаётся единственным сообщенческим feed API +- добавляется новый IPC endpoint `getMemberActivityMeta(teamName)` +- renderer хранит structural snapshot отдельно от message cache +- `refreshTeamData()` получает structural sharing + no-op suppression даже после split + +Самое важное: + +- текущий semantic-equality guard перед `set()` - правильная мысль, но это только часть решения +- если сделать только guard, можно снять текущий crash, но архитектурная сцепка `TeamData <-> messages <-> TeamDetailView` останется +- если сделать split + guard вместе, это уже похоже на правильный долгоживущий вариант + +## Quick Execution Path + +Если исполнитель не хочет читать весь документ линейно, безопасный порядок такой: + +1. Сначала новые shared contracts и worker ops. +2. Потом `TeamMessageFeedService` с stable effective identity и `feedRevision`. +3. Потом structural `getData()` и отдельный `MemberActivityMetaService`. +4. Потом store ownership for messages/meta, single-flight и stale-response guards. +5. Потом migration consumers: `MessagesPanel`, `ActivityTimeline`, `MemberDetailDialog`, `MemberMessagesTab`, `MemberHoverCard`, `StatusBlock`, `TeamDetailView`, graph. +6. Потом event routing split. +7. Потом structural sharing + no-op suppression. +8. Только после этого выпиливать legacy fields и compatibility plumbing. + +Неправильный порядок, которого надо избегать: + +1. Сначала менять UI consumers, пока feed/meta/store contracts ещё не зафиксированы. +2. Сначала удалять `TeamData.messages`, пока graph/dialog/messages consumers ещё на нём сидят. +3. Сначала добавлять polling в store без single-flight/coalescing. + +## Locked Decisions + +Ниже решения, которые в этом плане считаются **закрытыми**, а не оставленными "на потом". + +### 1. Naming and transport + +- IPC route name **не меняем**: остаётся `team:getData` +- public method name **не меняем**: остаётся `getData(teamName)` +- но тип ответа **меняем** на новый structural contract `TeamViewSnapshot` +- repo-wide alias вида `type TeamData = TeamViewSnapshot` **не оставляем** после merge + +Причина: + +- transport rename сейчас только раздует diff +- а вот новый тип нужен, чтобы код и тесты перестали мыслить `getData()` как message transport + +Допустимо локально во время промежуточной сборки держать временный compatibility alias, но в merged коде его быть не должно. + +Дополнительное правило: + +- если temporary compatibility alias или adapter переживает тот commit slice, в котором переводится его последний consumer, это уже smell и план выполняется неверно + +### 2. Snapshot is structural only + +- merged код **не должен** читать `messages` из snapshot +- `messages` больше не часть `TeamViewSnapshot` +- `members` в snapshot больше не считаются от full message history + +### 3. Message ownership + +- единственный message feed API в этом PR - `getMessagesPage()` +- новый отдельный `getMessagesHead()` в этом PR **не добавляем** +- если понадобится оптимизация, делаем её **внутри** `getMessagesPage()` или store caching, без второго transport contract +- existing `MessagesPage` contract расширяем полем `feedRevision` +- store action `refreshTeamMessagesHead()` должен возвращать semantic result c минимум двумя флагами: + - `feedChanged` - изменился ли revision всего normalized feed + - `headChanged` - изменился ли реально текущий canonical head slice в store +- но hot path `getMessagesPage()` при этом **обязательно** должен перестать быть full rescan/full normalize на каждый вызов +- для этого в main добавляется shared normalized message feed cache/index, которым пользуются и `getMessagesPage()`, и `getMemberActivityMeta()` + +Причина: + +- исторический backfill может менять exact member activity semantics без видимого изменения top page +- store не должен гадать про full-feed change только по diff первой страницы + +### 4. Message activity ownership + +- exact full-history message-derived facts идут в `getMemberActivityMeta()` +- renderer **не должен** вычислять exact `messageCount` или `lastActiveAt` только по head page messages +- итоговый member `status` как display field **не храним** как final truth в meta +- meta хранит raw facts, а display status собирается в renderer overlay из: + - `lastAuthoredMessageAt` + - `latestAuthoredMessageSignalsTermination` + - `currentTaskId` + - spawn/runtime state + +### 5. `messageCount` semantics + +- в этом PR semantics **сохраняем** +- `messageCount` остаётся **exact historical count** +- для этого закладываем shared normalized feed cache + meta cache по `feedRevision` +- вариант с `recentMessageCount` в этом PR **не принимаем** + +### 6. Pending replies semantics + +- `pendingRepliesByMember` остаётся renderer-local UI state +- `crossTeamPendingReplies` остаётся renderer-derived состоянием от message cache + local TTL +- `TeamMemberActivityMeta` **не становится** ticking transport для этих таймерных состояний + +Причина: + +- эти состояния частично зависят от local wall clock и текущего UX контекста таба +- перенос их в main/meta создаст лишнюю связанность и сломает текущую интерактивную модель + +### 6.1 Frozen semantics in this PR + +Чтобы performance refactor не превратился в скрытый product-change PR, в этом PR **не меняем**: + +- значение и смысл existing pending-reply waiting windows +- значение и смысл cross-team pending TTL badges +- значение coarse fallback polling intervals, кроме случаев where implementation forces tiny mechanical adjustment +- смысл `active` / `idle` member status thresholds +- exact-vs-recent meaning of `messageCount` +- default head page size / default first-screen message density без отдельного явного решения +- текущий default head request limit остаётся `50`, пока не принято отдельное явное решение его менять + +Если какой-то из этих пунктов всё-таки приходится менять ради correctness: + +- это должно быть отдельно отмечено в PR description +- change должен иметь отдельный тест +- и это уже считается product-semantic change, а не "просто часть split" + +### 7. Fetch ownership after migration + +- после split компоненты UI не вызывают `api.teams.getMessagesPage(...)` напрямую +- message fetching ownership переезжает в store actions +- `MessagesPanel`, `MemberMessagesTab`, graph-consumers становятся passive consumers store state + +### 8. Worker boundary + +- raw feed rebuild и meta build не должны неожиданно вернуться на main event loop +- в этом PR используем существующий `team-data-worker` boundary, а не заводим второй отдельный worker +- `getData()`, expensive `getMessagesPage()` rebuild path и `getMemberActivityMeta()` должны идти через одну и ту же worker strategy + +Важная практическая оговорка: + +- текущий `TeamDataWorkerClient` умеет fallback на main-thread execution, если worker artifact недоступен +- для новых hot paths это допустимо только как test/unpacked-dev escape hatch +- packaged runtime не должен молча остаться без worker и продолжить heavy feed rebuild на main loop + +Значит в плане реализации надо предусмотреть: + +- явную проверку availability для packaged runtime +- диагностический log/metric, если worker path не найден +- тест или smoke check, что message/meta ops реально доходят до worker path в нормальном runtime + +Причина: + +- иначе можно исправить renderer stall, но занести новую main-thread stall точку +- в кодовой базе уже есть готовый паттерн для heavy team I/O + +### 9. Polling ownership + +- fallback polling после миграции остаётся, но переезжает в store +- компоненты не владеют polling lifecycle +- polling нужен только как safety net на случай missed file/runtime events + +### 10. Temporary old-shape guard policy + +Если semantic-equality guard на старом mixed `TeamData` shape уже существует или приземлится раньше полного split, его статус в этом плане фиксированный: + +- это **temporary mitigation**, а не final architecture endpoint +- он не является причиной откладывать snapshot/messages/activity split +- новые consumers не должны начинать зависеть от старой mixed compare semantics +- после перехода на `TeamViewSnapshot` final no-op suppression должен работать уже на новом structural shape +- в merged target не должно остаться comparator logic, которое продолжает сравнивать `messages` внутри legacy snapshot только потому, что "так уже было" + +Иначе легко зацементировать старую неверную data boundary под видом performance fix. + +### 11. Store shape ownership + +- canonical owner structural snapshot state после split - `teamDataCacheByName` +- `selectedTeamData` в этом PR можно оставить как convenience field для текущей команды +- но `selectedTeamData` не должен жить отдельной второй жизнью +- если `selectedTeamData` присутствует, он всегда должен ссылаться на тот же object ref, что и `teamDataCacheByName[selectedTeamName]` + +Дополнительная жёсткая оговорка: + +- предпочтительный merged target - удалить `selectedTeamData` целиком, как только это станет механически просто +- сохранять его допустимо только как literal alias/pointer convenience field без собственной логики пересборки и без второго write path +- если для поддержки `selectedTeamData` нужен отдельный код синхронизации, значит поле уже не оправдано и должно быть удалено + +Причина: + +- иначе можно вроде бы "починить snapshot cache", но оставить hidden churn через second selected-only copy +- для no-op suppression важен именно ref reuse одного canonical объекта, а не две почти одинаковые структуры + +### 12. Out of scope for this PR + +- не делаем новый REST API +- не делаем `PaneContent` unmount refactor +- не делаем virtualization как primary fix +- не делаем graph redesign beyond data-source migration +- не делаем вторую параллельную message model "на время" + +## Source Of Truth Map + +Это обязательная карта владения данными. Если при реализации какая-то часть начнёт читаться не отсюда, это почти наверняка путь к регрессии. + +| Concern | Source of truth | Who derives view state | Must not come from | +| --- | --- | --- | --- | +| Structural team detail | `getData()` -> `TeamViewSnapshot` | store selectors / view-model adapters | message cache, `MessagesPanel` props | +| Normalized message feed | main-side shared feed cache/index | `getMessagesPage()`, `getMemberActivityMeta()` | repeated raw full rescans in each consumer | +| Message feed | `getMessagesPage()` -> `teamMessagesByName` + `selectTeamMessages(teamName)` | `MessagesPanel`, `MemberMessagesTab`, graph | `selectedTeamData`, `TeamViewSnapshot` | +| Full-feed freshness | `MessagesPage.feedRevision` + store cache entry revision | refresh routing / meta invalidation | head-slice diff heuristics only | +| Message identity | main-side effective message identity emitted in feed/page responses | store merge, cursor stability, read state, optimistic confirmation | ad-hoc renderer-only fallback identity | +| Exact member activity facts | `getMemberActivityMeta()` -> `memberActivityMetaByTeam` | member list / headers / hover / status presentation | loaded head messages only | +| Member awaiting-reply state | renderer-local `pendingRepliesByMember` | `TeamDetailView`, `MemberList`, `PendingRepliesBlock` | main/meta snapshot | +| Cross-team pending reply TTL state | renderer-derived from message cache + `Date.now()` | `StatusBlock` | main/meta snapshot | +| Spawn liveness | `memberSpawnStatusesByTeam` | member badges / merged display status | message meta | +| Message dedup semantics | main-side message services | renderer only consumes normalized output | renderer re-dedup logic | + +## Hard Invariants + +Если любой из пунктов ниже нарушается, значит реализация ушла в неправильную сторону. + +1. В merged коде не должно остаться чтения `selectedTeamData.messages`. +2. Exact `messageCount` и `lastActiveAt` не считаются в renderer по `selectTeamMessages(teamName)`. +3. `MessagesPanel` и `MemberMessagesTab` не имеют собственного IPC fetching logic после миграции. +4. Main остаётся единственным местом, где выполняется dedup `lead_session` / `lead_process`. +5. Pending-reply timer logic не переезжает в main process. +6. `lead-message` event не вызывает full `refreshTeamData()` по умолчанию. +7. В merged коде не живут две долгоживущие message models одновременно. +8. Message/meta refresh не крутятся бесконтрольно для hidden inactive teams. +9. `getMessagesPage()` и `getMemberActivityMeta()` не делают независимый полный raw rescan истории на каждый hot refresh. +10. Expensive feed rebuild path не выполняется на Electron main event loop. +11. Store не выводит "full feed changed" только по diff первого page slice; для этого используется `feedRevision`. +12. `TeamListView` и любые multi-team overview screens не гидратят messages/meta для каждой команды по умолчанию. +13. `getMessagesPage()` отдаёт stable effective message identity для каждого message row; store merge/cursor logic не живут на двух разных key semantics. +14. `selectedTeamData`, если сохраняется, reuse'ит ref из `teamDataCacheByName`, а не создаёт вторую independent snapshot copy. +15. `feedRevision` отражает состояние full normalized feed, а не время rebuild или raw invalidation fingerprint. +16. Если older history после revision change нельзя склеить без сомнений, canonical older tail сбрасывается, а не показывается mixed inconsistent state. + +## Forbidden Shortcuts + +Ниже shortcuts, которые выглядят как "быстро и почти правильно", но в контексте этого плана считаются ошибкой реализации. + +1. Оставить `messages` в snapshot "пока временно", а потом забыть убрать. +2. Считать `messageCount` / `lastActiveAt` по head page или по уже загруженным сообщениям в renderer. +3. Перенести fetching в store, но оставить прямые `api.teams.getMessagesPage(...)` в `MessagesPanel` или `MemberMessagesTab`. +4. Сделать `refreshMemberActivityMeta()` зависимым только от head-slice diff без `feedRevision`. +5. Держать два merge paths для messages: один в store, второй в компоненте. +6. Позволить packaged runtime тихо выполнять expensive message rebuild path на main thread при пропавшем worker. +7. Сохранить и `teamDataCacheByName`, и отдельно пересобираемый `selectedTeamData`. +8. Начать греть `getMessagesPage()` / `getMemberActivityMeta()` для multi-team overview "ради удобства". + +## 1. Top 3 Variants + +### 1. Full split: structural snapshot + messages cache + member activity meta + +`🎯 9 🛡️ 9 🧠 8` +Примерно `1600-2600` строк + +Идея: + +- `TeamData` больше не является message transport +- сообщения живут в отдельном cache/store path +- member list/status/meta перестают зависеть от нового `messages` array ref на каждый refresh +- `lead-message` и `inbox` events больше не триггерят full detail refresh + +Плюсы: + +- бьёт в корень renderer saturation +- уменьшает payload, churn и layout/paint rework +- делает поведение предсказуемым для долгих soaks +- готовит нормальную основу для graph/activity/members + +Минусы: + +- широкий blast radius +- надо аккуратно мигрировать graph и dialog consumers + +### 2. Только semantic-equality guard перед `set()` в `refreshTeamData` + +`🎯 7 🛡️ 6 🧠 4` +Примерно `250-450` строк + +Идея: + +- оставить `getData()` как есть +- сравнивать новый snapshot с предыдущим +- не вызывать `set()` если semantic state не изменился + +Плюсы: + +- быстро +- скорее всего снимет именно observed "new ref without visible change" + +Минусы: + +- `TeamData` остаётся перегруженным transport'ом +- любой реальный message change всё ещё трогает большой subtree +- архитектурная связка не исправляется +- остаётся риск новых форм churn вокруг graph/member dialogs/status blocks + +### 3. UI-side memoization / virtualization / more throttling без data split + +`🎯 5 🛡️ 5 🧠 6` +Примерно `500-900` строк + +Идея: + +- сильнее мемоизировать `MessagesPanel`, `ActivityTimeline`, `TeamDetailView` +- агрессивнее throttle / debounce refreshes +- возможно добавить virtualization + +Плюсы: + +- может уменьшить симптомы +- полезно как secondary optimization + +Минусы: + +- не чинит wrong data boundary +- будет лечить последствия вместо причины +- легко получить сложную, хрупкую UI-логику + +### Final Choice + +Берём **вариант 1**. +Но важная поправка: semantic guard из варианта 2 всё равно нужен внутри варианта 1. + +## 2. Краткая суть проблемы + +Проблема уже не в `persistLaunchStateSnapshot` storm. Он был причиной A и, судя по логам, уже прижат. + +Текущая причина B выглядит так: + +- `refreshTeamData()` регулярно создаёт новый `selectedTeamData` ref +- `TeamDetailView` подписан на весь `selectedTeamData` +- даже когда по смыслу ничего не изменилось, вниз уходит новый `messages` ref +- `MessagesPanel`, `ActivityTimeline`, member activity derivations и часть graph-related logic заново гонят filter/group/layout/paint +- React Profiler молчит, потому что commit time сам по себе не гигантский, а дорогой кусок сидит в browser layout+paint на 50+ message DOM nodes +- из-за mounted tabs через CSS toggle скрытые team tabs тоже могут держать живые тяжелые subtree + +Итог: + +- sustained long tasks по 150-500ms +- почти нет idle gaps +- heap распухает как следствие sustained work +- дальше уже возможен Chromium/V8 native fault `132/133` + +Это очень похоже на "renderer saturates itself useful-looking no-op work", а не на обычную JS memory leak. + +## 3. Факты из текущего кода + +### 3.1 Что уже хорошо + +`messages` уже частично вынесены: + +- `src/main/services/team/TeamDataService.ts` уже имеет `getMessagesPage()` +- `src/preload/index.ts` уже прокидывает `team:getMessagesPage` +- `src/shared/types/api.ts` уже описывает `TeamsAPI.getMessagesPage(...)` +- `src/renderer/components/team/messages/MessagesPanel.tsx` уже грузит страницы через `getMessagesPage()` +- `src/renderer/components/team/members/MemberMessagesTab.tsx` тоже умеет грузить страницы через `getMessagesPage()` + +То есть messages feed как отдельная boundary уже существует. Это важный факт. + +### 3.2 Что всё ещё дорого даже после split, если это не исправить + +Текущий `getMessagesPage()` в `TeamDataService` на каждый вызов: + +- заново читает inbox / lead texts / sent messages +- заново делает dedup `lead_session` / `lead_process` +- заново делает enrichment `leadSessionId` +- заново сортирует весь массив +- и только потом режет страницу + +Это значит: + +- если после split мы просто чаще зовём `getMessagesPage()` на `lead-message` / `inbox`, можно перенести часть нагрузки из renderer обратно в main +- transport boundary сама по себе не гарантирует дешёвый hot path + +Поэтому shared main-side normalized message feed cache - не nice-to-have, а часть правильного решения. + +### 3.3 Что всё ещё не разделено + +`getData()` всё ещё остаётся смешанным transport'ом: + +- собирает messages +- режет их до `MAX_RETURN_MESSAGES = 50` +- возвращает `messages` внутри `TeamData` +- передаёт `messages` в `TeamMemberResolver.resolveMembers(...)` + +Это означает: + +- даже "structural" refresh тянет message-derived часть модели +- members в snapshot зависят от message history +- новый `TeamData` ref почти гарантирован даже при пустом visible diff + +### 3.4 Где сейчас сцепка особенно сильная + +- `src/renderer/store/slices/teamSlice.ts` - `refreshTeamData()` всегда пишет новый `selectedTeamData` +- `src/renderer/components/team/TeamDetailView.tsx` - подписка на весь `selectedTeamData` +- `src/renderer/components/team/messages/MessagesPanel.tsx` - `effectiveMessages = merge(fetchedMessages, propMessages)` +- `src/renderer/components/team/activity/ActivityTimeline.tsx` - filter/group/visible timeline расчёты идут от whole messages array +- `src/renderer/components/team/members/MemberDetailDialog.tsx` - диалог получает `messages` из team snapshot +- `src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts` - graph всё ещё читает `TeamData.messages` +- `src/renderer/components/layout/PaneContent.tsx` - табы не размонтируются, а скрываются через `display: none` + +### 3.5 Вывод из этих фактов + +Messages уже выделены как feed API, но snapshot модели и renderer subscriptions ещё живут так, как будто messages по-прежнему часть основной detail модели. + +Значит реально надо разделять не "messages вообще", а вот это: + +- structural team snapshot +- message feed +- message-derived lightweight member/team activity meta + +Именно `getMemberActivityMeta` здесь ключевой новый слой. + +## 4. Почему текущий semantic guard - хороший, но недостаточный + +Фраза "semantic-equality guard перед `set()` звучит как самый правильный следующий шаг" по сути верная. + +Но глубже: + +- как immediate mitigation - да, это правильный следующий шаг +- как final architecture - нет, этого мало + +Почему он всё равно нужен: + +- он гасит no-op churn +- он дешёв относительно эффекта +- в кодовой базе уже есть хороший precedent в `fetchMemberSpawnStatuses()` с semantic equality suppression + +Почему его мало: + +- `TeamData` всё ещё останется слишком широким контрактом +- message churn всё ещё будет инвалидировать большой subtree +- graph/member dialogs/status block всё ещё будут сидеть на том же data blob +- сама форма данных останется неправильно сцепленной + +Правильная формулировка: + +> semantic guard нужен обязательно, но как часть split architecture, а не вместо неё + +## 5. Что именно надо разделить + +Здесь важно не запутаться. + +### 5.1 Нет, messages не надо "разделять с нуля" + +Они уже разделены: + +- есть `getMessagesPage()` +- есть pagination +- renderer уже умеет этим пользоваться + +### 5.2 Да, в основном надо разделить `member activity meta` + +Потому что именно она сейчас скрыто живёт внутри `TeamData` через: + +- `ResolvedTeamMember.status` +- `ResolvedTeamMember.messageCount` +- `ResolvedTeamMember.lastActiveAt` +- status blocks и pending replies, которые сейчас фактически упираются в `messages` + +### 5.3 И да, `getData()` надо сделать более structural + +Не в смысле "разрезать на 20 endpoints", а в смысле: + +- убрать из него message-heavy responsibility +- перестать использовать full message array как часть canonical detail snapshot + +То есть ответ на вопрос "мы что в основном разделяем `getMemberActivityMeta`?" такой: + +**Да.** +Но это работает только вместе с тем, что `getData()` перестаёт быть message-derived snapshot'ом. + +## 6. Endpoint ли это REST + +Нет. + +В этом проекте это должен быть **IPC endpoint**, а не REST API endpoint. + +То есть по форме это будет что-то в таком духе: + +- `TEAM_GET_MEMBER_ACTIVITY_META = 'team:getMemberActivityMeta'` +- wiring в `src/main/ipc/teams.ts` +- preload bridge в `src/preload/index.ts` +- тип в `src/shared/types/api.ts` + +Так что слово "endpoint" здесь надо понимать как app-internal IPC surface. + +## 7. Целевая архитектура + +## 7.1 Data boundaries + +Нормальная финальная схема должна выглядеть так: + +1. `getData(teamName)` возвращает **structural snapshot** +2. `getMessagesPage(teamName, { limit, cursor })` возвращает **сообщения** +3. `getMemberActivityMeta(teamName)` возвращает **лёгкие message-derived aggregate данные** + +В renderer это хранится раздельно: + +- `teamDataCacheByName[teamName]` +- `teamMessagesByName[teamName]` +- `memberActivityMetaByTeam[teamName]` + +### Concrete naming note + +Чтобы не плодить в документе две конкурирующие сущности, structural snapshot cache в renderer дальше следует понимать так: + +- концептуально - snapshot cache per team +- конкретно в текущем плане и store shape - `teamDataCacheByName` + +Отдельный bucket `teamSnapshotByName` в этом плане не вводится. + +А UI собирает view-model как overlay: + +- base structural team snapshot +- overlay member activity meta +- overlay latest loaded messages +- overlay member spawn statuses + +## 7.2 Что остаётся в structural snapshot + +Должно остаться: + +- `teamName` +- `config` +- `tasks` +- `kanbanState` +- `processes` +- `warnings` +- `isAlive` +- structural member description из config/meta + +### Важная корректировка по `members` + +Сейчас `ResolvedTeamMember` смешивает structural и message-derived поля. + +Это надо разрулить. + +Есть два пути: + +1. Либо ввести новый тип `TeamMemberSnapshot` +2. Либо оставить `ResolvedTeamMember`, но вытащить из него message-derived смысл в отдельный overlay + +Для надёжности и понятности лучше путь 1. + +### Предлагаемый structural member type + +```ts +export interface TeamMemberSnapshot { + name: string; + currentTaskId: string | null; + taskCount: number; + color?: string; + agentType?: string; + role?: string; + workflow?: string; + providerId?: TeamProviderId; + model?: string; + effort?: EffortLevel; + cwd?: string; + gitBranch?: string; + runtimeAdvisory?: MemberRuntimeAdvisory; + removedAt?: number; +} +``` + +Обратите внимание: + +- тут нет `messageCount` +- тут нет `lastActiveAt` +- тут нет `status`, если он message-derived + +Если нужен unified UI member status, он должен собираться поверх: + +- spawn status +- member activity meta +- active task presence + +## 7.3 Что уходит в member activity meta + +Туда должны уйти поля, которые меняются от message/inbox/head activity: + +```ts +export interface MemberActivityMetaEntry { + memberName: string; + /** + * Последнее сообщение, написанное самим участником. + * Важно: это не "последнее сообщение, где участник упомянут", + * а именно authored activity, чтобы сохранить текущую семантику `lastActiveAt`. + */ + lastAuthoredMessageAt: string | null; + /** Exact historical count of authored messages for this member. */ + messageCountExact: number; + /** + * True, если последнее authored message было terminal signal + * вроде shutdown approval. Это raw fact, а не итоговый display status. + */ + latestAuthoredMessageSignalsTermination: boolean; +} + +export interface TeamMemberActivityMeta { + teamName: string; + computedAt: string; + members: Record; + /** + * Revision shared normalized message feed, на котором собрана meta. + * Если revision не менялся, meta можно переиспользовать без пересчёта. + */ + feedRevision: string; +} +``` + +### Что важно не тащить в этот контракт + +Не надо класть туда: + +- full messages +- rendered timeline groups +- React-specific computed state +- tab-specific UI toggles +- ticking pending-reply booleans, зависящие от local clock +- `crossTeamPendingReplies` с TTL-логикой + +### Важная смысловая граница + +`TeamMemberActivityMeta` хранит только **стабильные message-derived факты**. + +Туда не должны попадать: + +- локальные optimistic "ждём ответ" +- таймерные TTL-состояния +- всё, что должно тикать раз в секунду от `Date.now()` + +## 7.4 Что остаётся у messages + +Messages должны жить только здесь: + +- `getMessagesPage()` +- renderer message cache +- специализированные consumers: `MessagesPanel`, `MemberMessagesTab`, graph/activity features + +Это снимает главный structural problem: + +- message changes больше не обязаны пересоздавать весь team detail snapshot + +## 8. Предлагаемые контракты + +## 8.1 Shared types + +Рекомендуемый набор типов: + +```ts +export interface TeamViewSnapshot { + teamName: string; + config: TeamConfig; + tasks: TeamTaskWithKanban[]; + members: TeamMemberSnapshot[]; + kanbanState: KanbanState; + processes: TeamProcess[]; + warnings?: string[]; + isAlive?: boolean; +} + +export interface TeamMemberSnapshot { + name: string; + currentTaskId: string | null; + taskCount: number; + color?: string; + agentType?: string; + role?: string; + workflow?: string; + providerId?: TeamProviderId; + model?: string; + effort?: EffortLevel; + cwd?: string; + gitBranch?: string; + runtimeAdvisory?: MemberRuntimeAdvisory; + removedAt?: number; +} + +export interface MemberActivityMetaEntry { + memberName: string; + lastAuthoredMessageAt: string | null; + messageCountExact: number; + latestAuthoredMessageSignalsTermination: boolean; +} + +export interface TeamMemberActivityMeta { + teamName: string; + computedAt: string; + members: Record; + feedRevision: string; +} + +export interface MessagesPage { + messages: InboxMessage[]; + nextCursor: string | null; + hasMore: boolean; + /** Revision всего normalized feed, а не только текущего page slice. */ + feedRevision: string; +} +``` + +## 8.2 API surface + +```ts +export interface TeamsAPI { + getData: (teamName: string) => Promise; + getMessagesPage: ( + teamName: string, + options?: { cursor?: string | null; limit?: number } + ) => Promise; + getMemberActivityMeta: (teamName: string) => Promise; +} +``` + +### Paging contract for `getMessagesPage()` + +Здесь нельзя оставлять двоякость между "timestamp paging" и "cursor paging". + +Locked choice: + +- request принимает `cursor`, а не `beforeTimestamp` +- `cursor === null` или отсутствие cursor означает "дай head page" +- `cursor` - opaque compound token, построенный main-side из boundary message, минимум `timestamp|effectiveMessageId` +- older-page semantics строго **exclusive**: response не должен повторно включать boundary row, из которой был выдан `nextCursor` +- `nextCursor === null` означает, что более старой canonical history больше нет + +Следствие: + +- renderer/store не реконструируют cursor самостоятельно +- timestamp-only paging в merged target отсутствует +- equality/merge semantics не зависят от неустойчивого порядка сообщений с одинаковым timestamp + +### Почему можно оставить имя `getData` + +С практической точки зрения это снизит churn: + +- старый IPC name можно не переименовывать сразу +- меняется shape, но не transport route + +Мой вывод: + +- **каноническое понятие** в плане должно называться `TeamViewSnapshot` +- **IPC method name** в этом PR остаётся `getData` + +## 8.3 Old to new field mapping + +Это важная таблица миграции. По ней проще всего проверять, не оставили ли мы hidden legacy coupling. + +| Old place | Old field / responsibility | New owner after split | +| --- | --- | --- | +| `TeamData.messages` | recent message batch | `selectTeamMessages(teamName)` over `canonicalMessages + optimisticMessages` | +| `ResolvedTeamMember.messageCount` | exact historical authored count | `memberActivityMetaByTeam[teamName].members[name].messageCountExact` | +| `ResolvedTeamMember.lastActiveAt` | last authored message timestamp | `memberActivityMetaByTeam[teamName].members[name].lastAuthoredMessageAt` | +| `ResolvedTeamMember.status` | display-ready member status | renderer overlay helper from snapshot + meta + spawn state | +| `MessagesPanel` local fetch state | page loading / cursors / merge | store-owned `teamMessagesByName[teamName]` | +| `MemberMessagesTab` direct IPC fetch | member message loading | store-owned message feed + selector filtering | +| `StatusBlock` snapshot messages prop | cross-team pending TTL derivation | store-backed messages + local timer | + +## 8.4 Member type migration strategy + +Это место нельзя оставлять неявным, потому что сейчас слишком много renderer кода ожидает `ResolvedTeamMember`. + +Правильная миграция такая: + +1. IPC transport перестаёт возвращать `ResolvedTeamMember[]` +2. IPC transport начинает возвращать `TeamMemberSnapshot[]` +3. renderer собирает поверх этого `ResolvedTeamMemberView[]` +4. UI-компоненты постепенно переводятся на `ResolvedTeamMemberView` + +### Важное правило + +`ResolvedTeamMember` больше не должен означать одновременно: + +- и IPC transport type +- и renderer display model + +Это две разные ответственности. + +### Рекомендуемый тип + +```ts +interface ResolvedTeamMemberView extends TeamMemberSnapshot { + status: MemberStatus; + lastActiveAt: string | null; + messageCount: number; + hasPendingReply?: boolean; +} +``` + +### Locked choice + +Для этого PR лучше: + +- оставить `ResolvedTeamMemberView` renderer-only type +- не возвращать его из main +- не держать старый `ResolvedTeamMember` как transport alias "для удобства" + +## 9. Main-process design + +## 9.1 `TeamDataService.getData()` должен стать structural + +Сейчас внутри `getTeamData()` messages делают две большие вещи: + +- сами попадают в response +- участвуют в `resolveMembers(...)` + +Значит надо: + +1. перестать возвращать `messages` в snapshot +2. перестать рассчитывать members от full message array + +### Новый shape flow + +Примерно так: + +```ts +const members = this.memberResolver.resolveStructuralMembers( + config, + metaMembers, + inboxNames, + tasksWithKanban +); + +return { + teamName, + config, + tasks: tasksWithKanban, + members, + kanbanState, + processes, + warnings, +}; +``` + +## 9.2 `TeamMemberResolver` надо разделить + +Сейчас `TeamMemberResolver` делает слишком много: + +- собирает member roster +- считает task ownership +- выводит status/messageCount/lastActiveAt из full messages + +Это надо разрезать. + +### Правильнее так + +`TeamMemberResolver.resolveStructuralMembers(...)` + +Отвечает только за: + +- список имён +- merge config/meta/inbox-derived members +- task ownership +- structural member props + +`MemberActivityMetaService.getMeta(teamName)` + +Отвечает за: + +- last authored activity +- exact historical counts +- terminal message facts + +Это даст нормальный SRP и снимет скрытую message coupling из snapshot. + +## 9.3 Как реализовать `getMemberActivityMeta()` надёжно + +Здесь тонкое место не только в meta, а в общем hot path сообщений. + +Если после split: + +- `getMessagesPage()` сам продолжит на каждый вызов делать raw full rescan + normalize +- и `getMemberActivityMeta()` отдельно тоже будет делать raw full rescan + +то мы просто перенесём часть нагрузки из renderer обратно в main. + +Поэтому правильный вариант такой: + +### Strategy A - shared normalized message feed cache + meta cache by feed revision + +Нужны два слоя. + +### Layer 1 - `TeamMessageFeedService` + +Отвечает за: + +- чтение raw sources +- присвоение каждому message row stable effective identity +- dedup `lead_session` / `lead_process` +- enrichment `leadSessionId` +- annotate slash responses +- stable newest-first sort +- shared cache normalized message feed по `teamName` +- вычисление `feedRevision` + +Важно: + +- этот слой становится единым backend для `getMessagesPage()` +- и единым backend для `getMemberActivityMeta()` +- нельзя оставлять старый inline normalize flow внутри `getMessagesPage()` параллельно с новым сервисом + +Примерно такой contract: + +```ts +interface TeamNormalizedMessageFeed { + teamName: string; + revision: string; + messages: InboxMessage[]; + newestTimestamp: string | null; + builtAt: number; +} +``` + +### `feedRevision` contract + +Это один из самых критичных контрактов всего плана. + +Правило: + +- `feedRevision` - это opaque, но **content-stable** revision full normalized feed +- если normalized feed семантически тот же, `feedRevision` обязан остаться тем же +- если normalized feed реально изменился, `feedRevision` обязан измениться + +Что запрещено: + +- генерировать `feedRevision` от `builtAt` +- генерировать `feedRevision` от `Date.now()` +- протаскивать наружу raw source fingerprint вида "mtime изменился, значит revision новый", если normalized output по факту не изменился + +Разрешённый компромисс: + +- internal source fingerprint может быть более консервативным и использоваться только для решения "rebuild or reuse cache" +- но наружу в `MessagesPage.feedRevision` и `TeamMemberActivityMeta.feedRevision` должен попадать именно revision нормализованного feed result, а не internal invalidation token + +Иначе: + +- `feedChanged` станет почти всегда `true` +- `refreshMemberActivityMeta()` начнёт зря крутиться +- store снова получит churn без реального изменения данных + +### Message identity contract + +Это место нужно зафиксировать жёстко, иначе pagination и merge легко станут источником скрытых дублей. + +Правило: + +- `TeamMessageFeedService` должен выдавать feed, где у каждого message row уже есть stable effective identity +- для этого reuse existing main-side identity semantics вроде `getEffectiveInboxMessageId(...)`, а не вводить ещё один независимый renderer fallback algorithm +- cursor `timestamp|messageId` должен строиться по **effective** message id, а не по "сырым optional ids" + +Следствие: + +- store merge older pages / head refresh / optimistic confirmation работают по одной и той же identity semantics +- read-state keys и message expansion keys не расходятся с transport identity +- исчезает класс багов "дубль после head refresh", когда у одной и той же canonical message в разных местах разные fallback keys + +Locked implementation choice: + +- целевой merged state этого PR - canonical feed rows всегда приходят с non-empty `messageId`, уже нормализованным main-side effective identity +- renderer helpers вроде `toMessageKey()` после этого должны фактически опираться на `messageId` как на normal path +- fallback key branch остаётся только как defensive guard для старых/optimistic/local edge cases, а не как вторая равноправная identity model + +### Cache invalidation strategy for feed service + +Первая реализация должна быть **консервативной**, а не "слишком умной". + +Разрешённый подход: + +- feed service хранит source fingerprint per team +- если fingerprint совпал, возвращаем cached feed +- если fingerprint изменился или есть любая неуверенность, rebuild whole normalized feed + +Что может входить в fingerprint: + +- inbox source revision / mtime / count +- lead session id / session history related revision +- sent messages store revision + +Что не надо делать в первой реализации: + +- partial in-place patching normalized feed несколькими независимыми эвристиками +- сложный delta merge между raw sources до появления профилирования + +Правило: + +- на первом шаге correctness важнее микрооптимизации +- optimisation boundary здесь - reuse cached feed when unchanged, а не умный partial patch when changed +- exposed `feedRevision` после rebuild должен вычисляться по normalized feed result, а не копировать internal fingerprint один в один + +### Layer 2 - `MemberActivityMetaService` + +Отвечает за: + +- построение `TeamMemberActivityMeta` **из normalized feed** +- кэширование результата по `feedRevision` + +Примерно такой cache entry: + +```ts +interface TeamMemberActivityMetaCacheEntry { + teamName: string; + feedRevision: string; + meta: TeamMemberActivityMeta; + builtAt: number; +} +``` + +### Важная деталь про no-op meta churn + +Даже если `feedRevision` изменился, это **не всегда** значит, что поменялись member-facing activity facts. + +Пример: + +- пользователь отправил новое сообщение участнику +- head feed изменился +- exact authored counters самих участников не изменились +- `lastAuthoredMessageAt` участников тоже не изменился + +Следствие: + +- `MemberActivityMetaService` может вернуть новый wrapper с новым `feedRevision` +- но `members` record внутри должен использовать structural sharing для неизменившихся entry +- UI selectors не должны подписываться на `computedAt` как на render-driving поле + +Иначе можно случайно вернуть churn в member list уже после правильного split. + +### Как должна выглядеть зависимость + +```ts +const feed = await teamMessageFeedService.getFeed(teamName); +const meta = await memberActivityMetaService.getMeta(teamName, feed); +``` + +### Почему это лучший баланс для этого PR + +- дорогой raw normalization живёт в одном месте +- `getMessagesPage()` просто режет page из cached normalized feed +- `getMemberActivityMeta()` не трогает raw storage напрямую +- если revision не изменился, meta возвращается без пересчёта +- O(n) meta rebuild по cached normalized feed при текущих observed объёмах сообщений выглядит безопаснее и проще, чем отдельный delta engine + +### Как сохраняем старую authored semantics + +Meta строится по authored activity: + +- `lastAuthoredMessageAt` считается по сообщениям `from === member.name` +- `messageCountExact` - это exact historical count authored messages +- `latestAuthoredMessageSignalsTermination` смотрит на последнее authored message и повторяет старую termination semantics + +То есть member-specific facts не считаются по любому сообщению, где member просто фигурирует в `to`. + +## 9.4 Почему не нужен отдельный delta engine в этом PR + +Отдельный delta engine можно добавить потом, если появятся реальные цифры, что даже meta rebuild по cached normalized feed стал горячей точкой. + +Но в этом PR он не обязателен, потому что: + +- shared feed cache уже убирает главную проблему repeated raw rescans +- solution с `feedRevision` проще тестировать +- меньше риск сломать edge cases и дедуп-семантику + +## 10. Renderer-side design + +## 10.1 Новые store slices + +Нужны отдельные state buckets: + +```ts +interface TeamMessagesCacheEntry { + canonicalMessages: InboxMessage[]; + optimisticMessages: InboxMessage[]; + feedRevision: string | null; + nextCursor: string | null; + hasMore: boolean; + lastFetchedAt: number | null; + loadingHead: boolean; + loadingOlder: boolean; + headHydrated: boolean; +} + +interface TeamSlice { + selectedTeamData: TeamViewSnapshot | null; + teamDataCacheByName: Record; + + teamMessagesByName: Record; + memberActivityMetaByTeam: Record; + + refreshTeamData: (teamName: string, opts?: RefreshTeamDataOptions) => Promise; + refreshTeamMessagesHead: (teamName: string) => Promise; + loadOlderTeamMessages: (teamName: string) => Promise; + refreshMemberActivityMeta: (teamName: string) => Promise; + applyOptimisticTeamMessage: (teamName: string, message: InboxMessage) => void; +} +``` + +### Snapshot/meta bootstrap semantics + +До первой successful hydration отсутствие cache entry - это нормальное состояние. + +Правила: + +- отсутствие `teamDataCacheByName[teamName]` означает "structural snapshot ещё не загружен", а не ошибку +- отсутствие `memberActivityMetaByTeam[teamName]` означает "activity meta ещё не загружена или ещё ни разу успешно не доезжала" +- store не должен создавать фиктивные placeholder-объекты только ради того, чтобы избежать `null` / `undefined` +- UI selectors и view-model layer должны уметь работать с отсутствием этих записей через стабильные fallback selectors, а не через ad-hoc object fabrication в компонентах + +Причина: + +- placeholder wrappers легко создают лишние ref changes и запутывают разницу между "нет данных пока" и "есть пустые данные" +- canonical source of truth должен оставаться простым: entry либо реально есть, либо его ещё нет + +### Non-reactive orchestration internals + +Не весь orchestration state должен жить в observable store. + +Допустимо и желательно держать вне reactive state: + +- in-flight promise maps per team/action +- dirty flags / follow-up flags +- explicit visibility registry +- internal cooldown / debounce bookkeeping + +Нельзя без необходимости тащить эти вещи в публичный reactive store shape, если UI не должен на них рендериться. + +Причина: + +- иначе сам orchestration layer начинает становиться источником re-render churn +- reactive store должен хранить в первую очередь данные и только те control flags, которые реально нужны UI + +### `TeamMessagesCacheEntry` field semantics + +Чтобы не было двух трактовок, значения полей должны пониматься так: + +- `canonicalMessages` - весь **уже загруженный** canonical message window для команды, newest-first, включая head page и все успешно догруженные older pages +- `optimisticMessages` - только локальные ещё не подтверждённые rows +- `feedRevision` - revision full normalized feed, на котором построен текущий canonical head state +- `nextCursor` - cursor для **следующей** older page после самого старого canonical message, уже находящегося в `canonicalMessages` +- `hasMore` - есть ли ещё canonical history старше текущего `nextCursor`; до первой successful head hydration это bootstrap flag и не интерпретируется как terminal exhaustion +- `lastFetchedAt` - timestamp последнего **успешного** canonical message fetch/merge для этой команды; до первого success равен `null` и не обновляется на failed attempt +- `loadingHead` - в полёте primary head refresh для canonical window +- `loadingOlder` - в полёте older-page extension текущего canonical window +- `headHydrated` - был ли хотя бы один успешный canonical head fetch + +Следствие: + +- head refresh обновляет canonical head portion, но не "забывает" уже загруженные older pages +- older-page loading расширяет `canonicalMessages` вниз по истории, а не создаёт отдельный side bucket + +### Bootstrap empty entry + +До первой successful head hydration canonical message entry должен иметь предсказуемый bootstrap state: + +```ts +{ + canonicalMessages: [], + optimisticMessages: [], + feedRevision: null, + nextCursor: null, + hasMore: false, + lastFetchedAt: null, + loadingHead: false, + loadingOlder: false, + headHydrated: false, +} +``` + +Важно: + +- bootstrap `hasMore: false` до first hydration не означает, что history exhausted +- terminal meaning у `hasMore === false` и `nextCursor === null` появляется только после `headHydrated === true` + +### Successful empty head hydration + +У команды может быть корректный successful head refresh и при этом ноль canonical messages. + +В таком случае canonical state должен стать таким: + +```ts +{ + canonicalMessages: [], + optimisticMessages: /* whatever local optimistic rows currently exist */, + feedRevision: "", + nextCursor: null, + hasMore: false, + lastFetchedAt: , + loadingHead: false, + loadingOlder: false, + headHydrated: true, +} +``` + +Важно: + +- empty successful feed **не** оставляет `headHydrated === false` +- empty successful feed **не** оставляет `feedRevision === null` +- иначе команда без history будет вечно выглядеть как "ещё не гидратирована" + +### Pre-hydration optimistic entry + +Если пользователь отправил optimistic message до первого successful head hydration, это допустимое состояние. + +В таком случае: + +- `canonicalMessages` остаётся пустым +- `optimisticMessages` может быть non-empty +- `headHydrated` остаётся `false` до первого successful canonical head fetch +- `feedRevision`, `nextCursor`, `lastFetchedAt` остаются bootstrap/null до первого success + +То есть optimistic rows могут существовать поверх bootstrap entry, не превращая его в hydrated canonical state. + +### `TeamMessagesCacheEntry` state invariants + +Чтобы store не собрал внутренне противоречивое состояние, ниже зафиксированы инварианты: + +- `headHydrated === false` => `canonicalMessages.length === 0` +- `headHydrated === false` => `loadingOlder === false` +- `headHydrated === false` => `feedRevision === null` +- `headHydrated === false` => `nextCursor === null` +- `headHydrated === false` => `lastFetchedAt === null` +- `loadingHead === true && loadingOlder === true` для одной команды в корректной реализации не допускается +- `hasMore === false` => `nextCursor === null` +- `canonicalMessages.length === 0` не означает ошибку само по себе, если `headHydrated === false` +- failed request не имеет права менять `lastFetchedAt` +- любой settled request обязан снять соответствующий loading flag, даже если response был stale-ignored + +Если implementation хочет хранить дополнительный error/debug state, он хранится отдельно от этого entry. + +### Operational definitions + +Чтобы разные исполнители не вкладывали разный смысл в одни и те же слова, ниже фиксированные определения. + +`visible active team` + +- команда, для которой прямо сейчас существует видимый team-detail или graph consumer в UI +- hidden mounted tabs через `display: none` сюда **не** входят только потому, что компонент всё ещё смонтирован +- store должен опираться на явный visibility signal, а не на факт mount'а subtree +- одного факта `selectedTeamName === teamName` недостаточно, чтобы считать команду `visible active team` + +`visibility signal` + +- renderer держит явный per-team visibility registration, а не выводит visibility косвенно из mount state +- минимум `TeamDetailView` container и graph container обязаны регистрировать и снимать этот сигнал при реальном показе/скрытии +- CSS-hidden subtree не считается visible consumer +- fallback polling и event routing consult именно этот explicit signal +- допустим ref-count или set of visible consumers per team, но merged code не должен зависеть от "компонент всё ещё смонтирован, значит команда активна" + +`active local pending-reply wait state` + +- у команды есть хотя бы один unresolved `pendingRepliesByMember` entry, который ещё находится в локальном waiting window +- это именно renderer-local UX reason держать лёгкий message polling +- это не означает, что команда становится structural-refresh priority + +`headHydrated` + +- хотя бы один successful head fetch уже положил canonical head page в store entry +- `headHydrated === false` означает "canonical message source для этой команды ещё не инициализирован" +- optimistic rows могут существовать и до `headHydrated === true`, но не заменяют canonical hydration + +`compatibility adapter` + +- временный branch-local helper, который помогает перевести consumer на новый shape без изменения transport contract обратно +- допустим только в renderer migration path +- не допускается как новый shared type alias, новый IPC compatibility contract или новый main-side legacy field + +### Contract for `refreshTeamMessagesHead()` + +Обычный `Promise` здесь слишком двусмысленный. + +Надёжнее сразу зафиксировать semantic result: + +```ts +interface RefreshTeamMessagesHeadResult { + feedChanged: boolean; + headChanged: boolean; + feedRevision: string | null; +} +``` + +Где: + +- `feedChanged` - изменился revision всего normalized feed относительно store cache +- `headChanged` - изменился реально canonical head slice, который подписан в UI +- `feedRevision` - revision после refresh + +Инварианты: + +- `headChanged === true` подразумевает `feedChanged === true` +- состояние `feedChanged === false && headChanged === true` в корректной реализации невозможно +- состояние `feedChanged === false && headChanged === false` означает, что canonical message inputs для UI не изменились +- состояние `feedChanged === true && headChanged === false` допустимо и означает historical-only/full-feed change без изменения текущего head slice + +Почему это важно: + +- старые сообщения могут доехать в feed без изменения top page +- `memberActivityMeta` зависит от full feed semantics, а не только от head page +- `MessagesPanel` может не перерисоваться, но member activity overlay всё равно должен знать, что full feed поменялся + +### Single-flight request discipline + +Даже правильный data split можно испортить, если store начнёт одновременно запускать 5 одинаковых refresh-запросов на burst events. + +Правило: + +- для каждого `teamName` store держит single-flight orchestration отдельно для: + - `refreshTeamData()` + - `refreshTeamMessagesHead()` + - `loadOlderTeamMessages()` + - `refreshMemberActivityMeta()` +- если такой же запрос уже в полёте, новые триггеры reuse existing promise или ставят один follow-up dirty flag +- store не запускает unbounded parallel head refreshes на каждое событие watcher burst + +Дополнительно: + +- responses применяются только через team-scoped request guard +- stale response после team switch / newer refresh не должен откатывать store назад + +### Canonical message mutation serialization + +Это отдельное правило поверх single-flight: + +- для одного `teamName` canonical message window не должен одновременно мутироваться из `refreshTeamMessagesHead()` и `loadOlderTeamMessages()` +- head refresh и older-page load для одной команды сериализуются через общий canonical-message mutation lane +- если во время `loadingOlder === true` приходит новый head trigger, store помечает team как dirty и выполняет head refresh сразу после завершения текущего canonical mutation +- если во время `loadingHead === true` приходит `loadOlderTeamMessages()`, older load либо reuse'ит уже идущую hydration sequence, либо ждёт её завершения + +Причина: + +- это сильно упрощает merge correctness +- это убирает лишний класс reorder bugs между head refresh и older-page append +- stale-response guards должны остаться как защита, но не быть основной стратегией нормального control flow + +### Что значит `meta stale` + +Чтобы здесь не было произвольных трактовок, `isMemberActivityMetaStale(teamName)` должен означать одно из: + +- meta entry для команды отсутствует +- `memberActivityMeta.feedRevision !== teamMessagesByName[teamName].feedRevision` +- safety TTL для visible active team истёк после длительного watcher silence + +И не должен означать: + +- "прошло немного времени, давайте на всякий случай ещё раз всё пересчитаем" +- "head refresh выполнился, значит meta точно stale" + +### UI selector discipline for activity meta + +Это место надо зафиксировать жёстко, иначе churn легко вернётся через selector layer. + +Правило: + +- UI consumers, которым нужны member facts, не подписываются на whole `TeamMemberActivityMeta` +- UI readers используют selector уровня facts, например `selectMemberActivityFacts(teamName)` +- routing / stale detection logic может отдельно читать `selectMemberActivityFeedRevision(teamName)` и `computedAt`, если это реально нужно + +Причина: + +- `feedRevision` может измениться без изменения member-facing facts +- `computedAt` почти никогда не должен быть render-driving полем +- подписка на весь wrapper снова создаст лишние re-renders в member list / hover / badges + +### Почему это важнее, чем просто больше `useMemo` + +Потому что store boundary определяет, что вообще считается "данные изменились". +Если boundary широкая, никакой `useMemo` потом уже красиво не спасёт. + +### Дополнительное правило + +Store после миграции становится единственной точкой orchestration для: + +- head refresh +- older-page loading +- optimistic message merge +- activity meta refresh +- fallback polling + +Компоненты после миграции только: + +- подписываются на store +- вызывают store actions +- не знают про IPC детали + +### Selector stability rule for merged messages + +Это критично. Иначе можно формально вынести messages из snapshot, но всё равно продолжить churn через новые массивы. + +Правило: + +- `selectTeamMessages(teamName)` обязан возвращать **stable array ref**, если `canonicalMessages` и `optimisticMessages` ref'ы не изменились +- `selectMemberMessages(teamName, memberName)` обязан строиться как memoized derived selector per pair, а не как новый `.filter(...)` на каждый store read +- `mergeTeamMessages()` не должен вызываться "в лоб" внутри обычного selector body без memoization + +Разрешённые варианты: + +- memoized selector factory per `teamName` +- precomputed merged view inside store entry с корректным structural sharing + +Недопустимый вариант: + +- каждый store read создаёт новый merged messages array даже при отсутствии изменений входов + +Иначе `MessagesPanel`, `ActivityTimeline`, graph и member tabs снова начнут получать churn уже после правильного split. + +### Optimistic storage rule + +Чтобы не терять optimistic rows во время canonical refresh, store не должен хранить один "голый" `messages[]`. + +Правильнее: + +- `canonicalMessages` - то, что пришло из main feed +- `optimisticMessages` - локальные optimistic rows, которые ещё не подтверждены canonical feed +- selector `selectTeamMessages(teamName)` возвращает уже merged view + +Это снимает типовую race-проблему: + +- user отправил сообщение +- optimistic row показался +- canonical head page ещё не успела включить это сообщение +- новый head refresh не должен "откатить" optimistic row + +### Cursor and page merge semantics + +Эта часть должна быть описана явно, иначе `loadOlderTeamMessages()` почти гарантированно получит race bugs. + +Правила: + +- `loadOlderTeamMessages()` не должен пытаться грузить older history, пока `headHydrated === false` +- если older load запрошен до first head hydration, store сначала делает `refreshTeamMessagesHead()` и только потом решает, есть ли что догружать +- cursor остаётся compound-format `timestamp|effectiveMessageId` +- older-page request должен помнить `baseFeedRevision`, на котором был выдан его `nextCursor` +- `loadOlderTeamMessages()` всегда использует `nextCursor` из текущего canonical store entry, а не локальное component state +- если `hasMore === false` или `nextCursor === null`, `loadOlderTeamMessages()` делает cheap no-op +- head refresh **не** заменяет весь canonical list целиком, если уже были подгружены older pages +- head refresh обновляет верхнюю часть feed и потом merge'ится с уже загруженной historical частью через единый merge helper +- older-page response тоже merge'ится, а не "append blindly" +- dedup и stable ordering должны reuse existing semantics вроде `mergeTeamMessages()` / shared message key contract +- canonical merge path не должен изобретать второй merge algorithm рядом с existing `mergeTeamMessages()` semantics без отдельной причины и отдельного тестового покрытия + +Особый case: + +- если older-page response приходит уже после нового head refresh или после другого older-page request +- store должен применить результат только если request guard ещё актуален +- иначе response silently ignored, без отката `nextCursor` и без reorder churn + +### Safety fallback for history rewrite / irreconcilable merge + +Нельзя молча предполагать, что история всегда append-only. + +В первой реализации должен быть безопасный fallback: + +- если после `feedRevision` change merge не может надёжно склеить fresh head и уже загруженную older history +- store обязан сбросить только historical tail и оставить свежий canonical head page как новый baseline +- при этом optimistic rows сохраняются отдельно и не теряются + +Триггеры для такого fallback: + +- нарушился stable newest-first ordering invariant после merge +- seam между fresh head и retained history не удаётся дедупнуть по effective identity без противоречий +- boundary anchor вокруг `nextCursor` стал недостоверным после newer revision +- response относится к старому `baseFeedRevision`, а в store уже живёт более новый head baseline + +Что важно: + +- лучше временно потерять локально подгруженный older tail, чем показать смешанное неконсистентное history state +- такой reset допустим только для canonical older window, но не для optimistic messages и не для structural snapshot + +### `selectedTeamData` / cache consistency rule + +Если в store временно живут и `teamDataCacheByName`, и `selectedTeamData`, правило должно быть жёстким: + +- сначала обновляется canonical cache entry per team +- потом `selectedTeamData` просто получает тот же ref, если `selectedTeamName === teamName` +- нельзя отдельно пересобирать `selectedTeamData` "для удобства UI" +- при смене `selectedTeamName` поле `selectedTeamData`, если оно ещё существует, synchronously repoint'ится на `teamDataCacheByName[selectedTeamName] ?? null` +- `selectedTeamData` не имеет права продолжать указывать на snapshot предыдущей команды после того, как `selectedTeamName` уже сменился + +Иначе: + +- no-op suppression может сработать для cache, но не сработать для current selection +- `TeamDetailView` продолжит видеть churn, хотя формально cache уже исправлен + +### Team switch response rule + +При switch `A -> B` store обязан вести себя так: + +- late async response для `A` может обновить только cache entry команды `A` +- late async response для `A` не имеет права переустановить `selectedTeamData`, если `selectedTeamName !== A` +- hydration/open-flow для `B` идёт по обычным правилам `visible active team` +- если cache для `B` уже существует, UI может сразу reuse'ить этот snapshot ref; если cache для `B` ещё нет, допускается `selectedTeamData === null` до первого успешного snapshot refresh + +Цель: + +- не показывать stale snapshot команды `A` под выбранной командой `B` +- не ломать per-team cache reuse ради selected-team convenience field + +### Fallback polling policy + +Polling остаётся как safety net, но только в store и только по строгим правилам: + +- включается для visible active team +- включается для team с active local pending-reply wait state +- не крутится для hidden inactive teams +- не переписывает structural snapshot +- делает только message-head refresh и при необходимости meta refresh + +### Initial visible-team hydration sequence + +Это должно быть описано отдельно, чтобы open-flow не собирался по-разному в разных местах. + +Когда команда становится `visible active team`, store обязан обеспечить такой порядок: + +1. `refreshTeamData(teamName)` для structural snapshot +2. `refreshTeamMessagesHead(teamName)` для canonical head hydration +3. `refreshMemberActivityMeta(teamName)` только после первого head result, если meta отсутствует или stale +4. `fetchMemberSpawnStatuses(teamName)` как независимый overlay refresh + +Допустимо: + +- запускать шаги 1 и 2 параллельно +- reuse shared single-flight/feed-cache между шагами 2 и 3 + +Недопустимо: + +- строить open-flow так, что `MemberDetailDialog`, `ActivityTimeline` или `StatusBlock` начинают сами триггерить свою собственную первичную hydration logic +- считать команду "полностью гидратированной" только потому, что приехал structural snapshot без message head + +### Hidden-team cache retention rule + +Когда команда перестаёт быть `visible active team`: + +- store прекращает background refresh/polling для этой команды, если нет `active local pending-reply wait state` +- уже гидратированные snapshot/message/meta caches **не** очищаются только из-за hide transition +- hide transition сам по себе не должен сбрасывать `headHydrated`, `canonicalMessages`, `memberActivityMetaByTeam[teamName]` или `teamDataCacheByName[teamName]` + +В этом PR не вводится отдельная eviction policy. + +Причина: + +- eager clear-on-hide легко превращает reopen в повторный burst hydration path +- cache retention и background refresh ownership - это разные вещи, их нельзя смешивать + +### Reopen rule after hide + +Если команда была скрыта, а потом снова стала `visible active team`: + +- store reuse'ит уже имеющиеся snapshot/message/meta caches как baseline +- open-flow может поверх этого сделать refresh по обычным visible-team правилам +- reopen не должен вести себя как forced cold-start только из-за предыдущего hide transition + +### Failure semantics for store actions + +Это тоже должно быть однозначно: + +- `refreshTeamData()` failure не очищает предыдущий structural snapshot +- `refreshTeamMessagesHead()` failure не очищает `canonicalMessages`, `nextCursor`, `feedRevision` +- `loadOlderTeamMessages()` failure не откатывает уже загруженную history window +- `refreshMemberActivityMeta()` failure не очищает предыдущий meta facts record +- любой из этих failures обязан снять соответствующий loading flag + +Если нужен user-visible signal: + +- он должен жить отдельным ephemeral error state / logger path +- но не через destructive reset уже загруженных данных + +## 10.2 `refreshTeamData()` после split + +После split `refreshTeamData()` должен заниматься только: + +- structural snapshot +- task change invalidation +- structural sharing +- no-op suppression + +Он **не** должен: + +- догружать messages +- дёргать member activity computations +- быть universal answer на любой `lead-message` + +## 10.3 Новый routing событий + +Правильнее распределить так: + +### `lead-message` + +Должен триггерить: + +- `refreshTeamMessagesHead(teamName)` +- `refreshMemberActivityMeta(teamName)` только если `feedChanged === true` или meta stale + +Но не full `refreshTeamData()` по умолчанию. + +И только если team реально нужна сейчас: + +- видима хотя бы в одном pane +- или у неё есть active local pending-reply wait state + +### `inbox` + +Тоже: + +- `refreshTeamMessagesHead(teamName)` +- `refreshMemberActivityMeta(teamName)` только если `feedChanged === true` или meta stale + +С тем же visibility правилом: + +- visible team +- или active local pending-reply wait state + +### `task` + +Должен триггерить: + +- `refreshTeamData(teamName)` + +### `config` + +Должен триггерить: + +- `refreshTeamData(teamName)` + +Этого достаточно, потому что: + +- roster и `currentTaskId` живут в structural snapshot +- `memberActivityMeta` после split зависит от message feed, а не от config + +### `process` + +Должен триггерить: + +- `refreshTeamData(teamName)` + +### `member-spawn` + +Как и сейчас: + +- `fetchMemberSpawnStatuses(teamName)` + +Но без косвенного втягивания full team detail refresh, если это не требуется. + +### Fallback polling + +Отдельно от event routing store держит лёгкий fallback poll: + +- только для visible active team +- или для team с active local pending-reply wait state +- интервал остаётся coarse, а не tight +- poll вызывает только `refreshTeamMessagesHead()` +- `refreshMemberActivityMeta()` вызывается только вслед за `feedChanged === true` или stale-meta condition + +Это нужно на случай: + +- пропущенных file/runtime events +- длинных сессий с нестабильным watcher delivery + +## 10.4 Пример роутинга + +```ts +if (event.type === 'lead-message' || event.type === 'inbox') { + const { feedChanged } = await refreshTeamMessagesHead(event.teamName); + if (feedChanged || isMemberActivityMetaStale(event.teamName)) { + scheduleMemberActivityMetaRefresh(event.teamName); + } + return; +} + +if (event.type === 'task' || event.type === 'config' || event.type === 'process') { + scheduleTeamSnapshotRefresh(event.teamName); +} +``` + +Это самое большое поведенческое исправление для renderer load pattern. + +### Event matrix without ambiguity + +| Event | Always do | Conditionally do | Must not do by default | +| --- | --- | --- | --- | +| `lead-message` | `refreshTeamMessagesHead()` | `refreshMemberActivityMeta()` when `feedChanged` or meta stale | `refreshTeamData()` | +| `inbox` | `refreshTeamMessagesHead()` | `refreshMemberActivityMeta()` when `feedChanged` or meta stale | `refreshTeamData()` | +| `task` | `refreshTeamData()` | nothing else unless separate UI needs it | blind message refresh | +| `config` | `refreshTeamData()` | nothing else unless separate UI needs it | blind message refresh | +| `process` | `refreshTeamData()` | nothing else unless separate UI needs it | blind message refresh | +| `member-spawn` | `fetchMemberSpawnStatuses()` | presentation overlay recompute in renderer | implicit full snapshot refresh | + +## 10.5 `TeamDetailView` должен перестать читать всё из одного blob + +Сейчас view примерно концептуально живёт так: + +- `data = selectedTeamData` +- `messages = data.messages` +- `members = data.members` + +После split правильнее: + +```ts +const snapshot = useStore(selectTeamSnapshot(teamName)); +const messages = useStore(selectTeamMessages(teamName)); +const memberActivityFacts = useStore(selectMemberActivityFacts(teamName)); +const memberSpawnStatuses = useStore(selectMemberSpawnStatuses(teamName)); +``` + +А дальше уже в selector / adapter layer собирается view model: + +```ts +const membersWithActivity = useMemo( + () => mergeMembersWithActivity(snapshot.members, memberActivityFacts, memberSpawnStatuses), + [snapshot.members, memberActivityFacts, memberSpawnStatuses] +); +``` + +Это делает invalidation адресным: + +- messages change не обязаны ломать tasks/processes/member roster UI +- member meta change не обязана пересоздавать task board + +### Как именно должен собираться member status + +После split итоговый `member.status` больше не приходит готовым из main snapshot. + +Правильная схема: + +- meta даёт raw activity facts +- snapshot даёт `currentTaskId` +- spawn layer даёт runtime/provisioning signals +- renderer helper собирает итоговый display status для UI + +Это важный момент, потому что иначе легко снова смешать transport facts и UI semantics. + +## 10.6 `MessagesPanel` должен работать только от message store + +Сейчас он смешивает: + +- prop seed messages +- fetched page messages + +После split: + +- `MessagesPanel` получает `selectTeamMessages(teamName)` +- optimistic send updates идут прямо в message store +- начальная head hydration делается через store action, а не через prop fallback +- `loadOlderMessages` идёт через store action, а не через прямой IPC call из компонента + +### Это особенно важно + +Пока у `MessagesPanel` есть `prop messages`, snapshot продолжает быть скрытым transport'ом для messages. + +Это надо убрать полностью. + +### И ещё одно важное правило + +`MessagesPanel` не должен стать вторым orchestration layer. + +То есть внутри него не должно остаться: + +- отдельного `fetchIdRef` +- собственного polling lifecycle +- второй логики merge/dedup поверх store ownership + +## 10.7 `ActivityTimeline` + +Это тоже message-heavy consumer, и его нельзя оставлять "подразумеваемым". + +После split: + +- `ActivityTimeline` читает store-backed messages selector или отдельный timeline view-model selector, а не `selectedTeamData.messages` +- timeline grouping/filtering не живёт от старого snapshot prop +- компонент не содержит собственного fetch/polling/orchestration path +- hidden mounted tab не должен получать лишний churn только потому, что timeline подписан слишком широко + +Если для timeline нужен специальный derived selector, это нормально. +Ненормально - снова фильтровать whole snapshot message blob прямо в render path. + +## 10.8 `MemberDetailDialog` / `MemberMessagesTab` / `MemberHoverCard` + +Сейчас dialog получает `messages` из `TeamDetailView`. + +После split: + +- dialog не должен принимать full team messages prop +- `MemberMessagesTab` должен брать member-relevant data из message store через team-scoped selector +- activity count в header должен приходить из `memberActivityMeta`, а не через `buildInlineActivityEntries(messages.filter(...))` на каждый reopen +- `MemberMessagesTab` не должен сам дёргать `api.teams.getMessagesPage(...)` +- `MemberHoverCard` должен читать `memberActivityFacts` или готовый view-model selector, а не whole snapshot wrapper и не whole meta wrapper + +### Пример + +```ts +const memberMeta = memberActivityFacts[member.name]; +const memberActivityCount = memberMeta?.messageCountExact ?? 0; +``` + +Если нужен более богатый recent activity counter, это отдельное future extension, не часть этого PR. + +## 10.9 Agent Graph + +Это один из самых опасных edge points. + +Сейчас graph adapter сидит на `TeamData.messages`. + +Если просто выкинуть `messages` из `TeamData`, graph сломается. + +### Правильный путь + +Graph должен перейти на тот же store-backed source, что и MessagesPanel. + +Locked choice: + +- store subscription живёт в graph hook / container layer +- pure adapter принимает уже готовые данные `(snapshot, messages, memberActivityFacts, teamName)` +- fetching и polling не уезжают внутрь graph adapter + +Примерно так: + +```ts +const snapshot = useStore(selectTeamSnapshot(teamName)); +const messages = useStore(selectTeamMessages(teamName)); +const memberActivityFacts = useStore(selectMemberActivityFacts(teamName)); +const graphData = useMemo( + () => TeamGraphAdapter.adapt(snapshot, messages, memberActivityFacts, teamName), + [snapshot, messages, memberActivityFacts, teamName] +); +``` + +### Почему это важно + +Если graph останется на legacy `TeamData.messages`, вы получите: + +- двойную модель +- race conditions +- скрытую потребность сохранять legacy field дольше, чем нужно + +## 11. Structural sharing and no-op suppression + +Это надо делать даже после split. + +## 11.1 Зачем + +Потому что даже structural snapshot без messages всё равно может пересоздаваться: + +- новые массивы задач +- новый `config` object +- новый `processes` array +- новые `members` array/object refs при одинаковом содержимом + +Если этого не подавить, вы получите меньшую, но всё ещё реальную churn-проблему. + +## 11.2 Принцип + +Нужно не просто "compare then skip". +Нужно **reuse old references for equal subtrees**. + +То есть не так: + +```ts +if (deepEqual(prev, next)) return prev; +return next; +``` + +А так: + +```ts +function structurallyShareTeamSnapshot( + prev: TeamViewSnapshot | null | undefined, + next: TeamViewSnapshot +): TeamViewSnapshot { + if (!prev) return next; + + const sharedConfig = areConfigsEqual(prev.config, next.config) ? prev.config : next.config; + const sharedTasks = reuseArrayIfEqual(prev.tasks, next.tasks, areTasksSemanticallyEqual); + const sharedMembers = reuseArrayIfEqual(prev.members, next.members, areMembersSemanticallyEqual); + const sharedProcesses = reuseArrayIfEqual( + prev.processes, + next.processes, + areProcessesSemanticallyEqual + ); + const sharedWarnings = reuseOptionalArrayIfEqual( + prev.warnings, + next.warnings, + (left, right) => left === right + ); + + if ( + sharedConfig === prev.config && + sharedTasks === prev.tasks && + sharedMembers === prev.members && + sharedProcesses === prev.processes && + prev.isAlive === next.isAlive && + sharedWarnings === prev.warnings + ) { + return prev; + } + + return { + ...next, + config: sharedConfig, + tasks: sharedTasks, + members: sharedMembers, + processes: sharedProcesses, + warnings: sharedWarnings, + }; +} +``` + +Примечание: + +- `warnings` тоже надо пускать через optional-array sharing, а не через голый ref compare +- иначе no-op suppression останется частичной и будет зря пересоздавать snapshot wrapper + +## 11.3 Где надо быть особенно осторожным + +С semantic equality нельзя бездумно игнорировать поля. + +Надо разделять: + +- поля, меняющие видимый UI +- поля, не меняющие видимый UI + +Пример: + +- `updatedAt` у meta - часто можно игнорировать +- `lastHeartbeatAt` - можно игнорировать для member spawn badge equality, если UI его не показывает +- `task.reviewState` игнорировать уже нельзя + +Нужны **целенаправленные semantic comparators**, а не generic deep-equal. + +## 12. Optimistic updates + +Это отдельный опасный блок. + +Сейчас `sendTeamMessage()` оптимистично пушит message в `selectedTeamData.messages`. + +После split надо перенести optimistic update в message store. + +### Правильнее так + +```ts +sendTeamMessage: async (teamName, request) => { + const optimistic = buildOptimisticMessage(request, result.messageId); + get().applyOptimisticTeamMessage(teamName, optimistic); + await get().refreshTeamMessagesHead(teamName); +} +``` + +### Почему здесь не нужен `refreshMemberActivityMeta()` + +Для обычного user -> member send это лишняя работа, потому что: + +- `messageCountExact` считает authored messages самого member +- `lastAuthoredMessageAt` тоже меняется только когда пишет сам member +- pending-reply UX уже покрывается local `pendingRepliesByMember` + +Значит после user send надо: + +- добавить optimistic message в store +- обновить local pending-reply state +- дождаться canonical head refresh + +Но не пересчитывать activity meta сразу же. + +### Send failure rollback semantics + +Если `sendTeamMessage()` завершается ошибкой до canonical confirmation: + +- соответствующая optimistic row удаляется из `optimisticMessages` +- local pending-reply state, поставленный этим send attempt, откатывается +- canonicalMessages не трогаются +- `refreshMemberActivityMeta()` по этому failure не запускается + +Если продукт позже захочет отдельный failed-message UX со статусом retry, это уже отдельное расширение. +В текущем плане failed optimistic send не должен навсегда оставлять висящую pseudo-message row в merged feed. + +### Почему нельзя оставить старую логику + +Потому что она снова начнёт: + +- мутировать snapshot semantics через messages +- держать legacy coupling + +### Отдельно про pending replies + +Local `pendingRepliesByMember` остаётся в renderer: + +- на send отмечаем `sentAtMs` +- на incoming member reply чистим локальное состояние +- delayed waiting refresh в `TeamDetailView` после split должен вызывать `refreshTeamMessagesHead(teamName)`, а не full `refreshTeamData(teamName)` + +### Merge semantics for optimistic rows + +Когда canonical feed в итоге содержит сообщение с тем же `messageId`, store должен: + +- убрать соответствующую optimistic row +- оставить canonical row +- не дублировать обе версии в merged selector + +## 13. Что делать с `messageCount` + +Это один из самых важных product semantics вопросов. + +Сейчас `ResolvedTeamMember.messageCount` - это exact count по full history. + +В этом плане решение уже принято: + +- `messageCount` в v1 split-реализации остаётся **exact historical count** +- значение приходит из `TeamMemberActivityMeta` +- значение не вычисляется в renderer по head page + +Причина: + +- это сохраняет текущую семантику UI и тестов +- это убирает скрытое product-изменение из и так большого performance PR +- это совместимо с shared normalized feed cache + meta-by-revision cache + +Если позже product решит, что exact count не нужен, это отдельный follow-up с отдельным обсуждением UX semantics, но не часть текущего плана. + +## 14. Edge cases и подводные камни + +## 14.1 Hidden tabs still mounted + +Пока `PaneContent` сохраняет tabs mounted, любое широкое store invalidation продолжает работать против вас. + +Следствие: + +- даже после split полезно сделать selectors максимально узкими +- не тянуть `messages` в скрытые team tabs, если они не нужны + +## 14.2 Team switch race + +Если пользователь быстро переключает команды: + +- `refreshTeamMessagesHead(alpha)` может завершиться после перехода на `beta` +- нельзя обновлять `selectedTeamData`-подобный selected-only state без teamName validation + +Нужны team-scoped caches и id guards, как уже сделано в ряде мест. + +И это же правило относится к: + +- older-page responses +- meta refresh responses +- delayed pending-reply refresh timers + +## 14.3 Member removed / renamed + +Если member удалён: + +- structural snapshot убирает его из active списка +- `memberActivityMeta` может ещё содержать старую запись + +Правильнее: + +- не терять meta сразу, если нужен historical dialog +- но UI current member list должен фильтровать по structural roster + +## 14.4 Pending replies semantics + +Сейчас pending replies partly derived from messages. + +После split нельзя потерять: + +- pending reply badges by member +- pending cross-team replies + +Здесь важно не перепутать две разные сущности. + +### Member pending replies + +Это остаётся renderer-local state: + +- источник истины - `pendingRepliesByMember` +- состояние ставится optimistically на send +- очищается, когда message feed показывает фактический reply от участника + +Это **не** надо класть в `TeamMemberActivityMeta`. + +### Cross-team pending replies + +Это остаётся renderer-derived состоянием: + +- источник истины - normalized message cache +- TTL считается локально от `Date.now()` +- `StatusBlock` может продолжать держать свой 1-second timer, но читать он должен уже из store-backed messages, а не из snapshot prop + +Это тоже **не** надо класть в `TeamMemberActivityMeta`. + +### Что тогда делает `TeamMemberActivityMeta` + +Только стабильные message-derived факты: + +- `lastAuthoredMessageAt` +- `messageCountExact` +- `latestAuthoredMessageSignalsTermination` + +## 14.5 New message before head hydration finishes + +Возможна ситуация: + +- открыли team +- `refreshTeamMessagesHead()` ещё в полёте +- пользователь отправил optimistic message +- потом приехала server head page + +Нужно merge по `messageId`, не замену массива вслепую. + +## 14.6 Message edits / dedup / source merging + +У вас уже есть логика dedup lead_session vs lead_process. + +Она должна остаться **единственным source of truth** на main side. + +Renderer не должен заново изобретать dedup semantics. + +## 14.7 `lastHeartbeatAt` и spawn statuses + +Это нельзя снова смешивать с message activity meta. + +Нужно разделять: + +- spawn liveness +- member conversational activity + +Их потом можно поверх объединить в `displayStatus`, но хранить в одном transport не надо. + +## 14.8 Team provisioning / TEAM_DRAFT / transient errors + +`refreshTeamData()` уже аккуратно обрабатывает provisioning-safe сценарии. + +После split надо сохранить тот же принцип для: + +- `refreshTeamMessagesHead()` +- `refreshMemberActivityMeta()` + +То есть: + +- transient failures не должны очищать structural snapshot +- отсутствие message meta не должно рушить весь screen + +## 14.9 Graph and TeamDetail open одновременно + +Если team tab и graph tab открыты одновременно для одной команды: + +- нельзя делать два разных polling loops, читающих одну и ту же head page + +Нужно shared store action / shared cache entry per team. + +## 14.10 Tests that currently assert `data.messages` + +Blast radius тестов реальный: + +- main service tests +- IPC tests +- renderer store tests +- graph adapter tests + +Надо сразу закладывать migration plan: + +- заменить ожидания на snapshot + messages/meta assertions +- не держать временный legacy field дольше, чем нужно + +## 14.11 Team list fan-out risk + +Это место легко пропустить, а потом получить новый performance regression уже не в detail view, а в overview. + +Если `TeamListView` или похожий multi-team экран: + +- делает `getData()` для многих команд +- и после split начнёт "для полноты" ещё дёргать `getMessagesPage()` / `getMemberActivityMeta()` по каждой строке + +то это создаст новый fan-out hot path. + +Правило: + +- message feed и member activity meta гидратятся только для selected / visible team detail contexts +- list/grid overview остаётся на structural snapshot +- если overview позже понадобится activity badge, для него нужен отдельный lightweight aggregate contract, а не скрытый fan-out тяжёлых вызовов + +## 15. Как именно я бы это реализовывал + +## 15.1 Принцип + +Не "фаза 1 как костыль, потом перепишем". + +А один coherent branch/PR, внутри которого есть правильный порядок сборки: + +1. новые типы и IPC surfaces +2. новый store shape +3. message/meta consumers переводятся на новые selectors +4. event routing меняется +5. structural sharing включается +6. legacy `TeamData.messages` usage выпиливается + +То есть rollout последовательный, но не архитектурно компромиссный. + +## 15.2 Пошаговый технический план + +Важно: + +- шаги ниже задают **implementation ownership order**, а не обещание, что каждая микрофаза сама по себе уже merge-safe +- merge-safe checkpoints для PR определяются секциями `Suggested commit slices`, `Mechanical execution checklist` и `Merge gates` +- если отдельный шаг временно делает ветку архитектурно неконсистентной, следующий связанный шаг должен приземляться в том же commit slice до локального smoke +- нельзя останавливать работу на половине coupled migration, если в таком состоянии код снова зависит от legacy mixed snapshot + +### Safe temporary states during migration + +Чтобы не собрать ветку в промежуточное состояние, которое уже компилируется, но архитектурно тянет старые баги, ниже разрешённые и запрещённые промежуточные формы. + +Разрешено временно: + +- держать `ResolvedTeamMemberView` renderer-only adapter, пока consumer-компоненты по очереди переводятся на новый overlay model +- держать branch-local compatibility adapters в renderer containers +- держать `selectedTeamData` как convenience alias, пока canonical owner уже `teamDataCacheByName` + +Но: + +- каждый compatibility adapter должен иметь одного конкретного remaining consumer owner +- adapter удаляется в том же commit slice, где уходит его последний consumer +- нельзя оставлять "универсальный временный adapter", который начинает жить своей отдельной жизнью + +Запрещено даже временно: + +- возвращать новый structural snapshot и одновременно ждать, что компоненты всё ещё возьмут из него `messages` +- перевести store на новый message cache, но оставить direct component fetch/polling "до следующего коммита" +- держать `selectedTeamData` как independently-built copy после того, как появился canonical cache +- держать второй message dedup/merge path в renderer после появления store-owned canonical path + +Если промежуточная ветка попадает в запрещённое состояние, её нельзя считать готовой даже для локального smoke. + +### Step 1 - Ввести новые shared contracts + +Сделать: + +- `TeamViewSnapshot` +- `TeamMemberSnapshot` +- `TeamMemberActivityMeta` +- расширить existing `MessagesPage` полем `feedRevision` +- перевести request shape `getMessagesPage()` c `beforeTimestamp` на `cursor` +- `getMemberActivityMeta()` в `TeamsAPI` + +Проверить: + +- типы компилируются без renderer migration +- paging contract в shared types уже cursor-based, а не timestamp-based + +### Step 2 - Разделить main-side services + +Сделать: + +- `TeamMemberResolver.resolveStructuralMembers(...)` +- новый `MemberActivityMetaService` +- `TeamDataService.getData()` перестаёт возвращать `messages` + +Проверить: + +- `getMessagesPage()` остаётся источником сообщений +- main unit tests покрывают structural snapshot отдельно от messages/meta + +### Step 3 - Добавить renderer caches + +Сделать: + +- `teamMessagesByName` +- `memberActivityMetaByTeam` +- `refreshTeamMessagesHead()` +- `refreshMemberActivityMeta()` +- `applyOptimisticTeamMessage()` +- merged selector over `canonicalMessages + optimisticMessages` + +Проверить: + +- message cache корректно merge'ит optimistic + fetched messages +- canonical refresh не откатывает optimistic row до подтверждения feed + +### Step 4 - Перевести `MessagesPanel` + +Сделать: + +- убрать `messages` prop как canonical input +- читать message entry из store + +Проверить: + +- initial load +- polling +- load older +- optimistic send + +### Step 5 - Перевести `MemberDetailDialog` / `MemberMessagesTab` + +Сделать: + +- dialog больше не получает full messages prop +- count/meta идут из `memberActivityMeta` +- detail tab читает relevant messages из message store + +Проверить: + +- open/close dialog +- team switch +- member switch + +### Step 6 - Перевести TeamDetail selectors + +Сделать: + +- `membersWithActivity` как overlay model +- `StatusBlock` перестаёт читать whole messages blob напрямую из snapshot + +Проверить: + +- pending replies +- active/idle badges +- no visible regression в member list + +### Step 7 - Перевести graph + +Сделать: + +- graph adapter читает `snapshot + messages + memberActivityMeta` + +Проверить: + +- graph не сломан +- graph не заставляет держать legacy `TeamData.messages` + +### Step 8 - Включить event routing split + +Сделать: + +- `lead-message` и `inbox` больше не зовут full `refreshTeamData()` по умолчанию +- зовут messages/meta refresh + +Проверить: + +- burst handling +- dedup +- no stale UI + +### Step 9 - Включить structural sharing + no-op suppression + +Сделать: + +- `structurallyShareTeamSnapshot(prev, next)` +- no-op return если snapshot semantically equal +- если до этого в ветке существует temporary old-shape guard на mixed `TeamData`, на этом шаге он либо удаляется, либо сужается до нового structural snapshot comparator + +Проверить: + +- `selectedTeamData` ref не меняется на no-op refresh +- hidden tabs не получают лишних commits +- в merged target не остаётся comparator, который всё ещё сравнивает legacy `messages` внутри snapshot + +### Step 10 - Удалить legacy coupling + +Сделать: + +- убрать `TeamData.messages` +- убрать prop plumbing `messages={data.messages}` +- обновить тесты + +## 15.3 File-by-file execution map + +Ниже не "точный diff inventory", а практическая карта, куда идти по шагам, чтобы реализация не расползлась. + +### Shared contracts and bridges + +- `src/shared/types/team.ts` + - добавить `TeamViewSnapshot` + - добавить `TeamMemberSnapshot` + - добавить `TeamMemberActivityMeta` + - удалить `messages` из snapshot contract +- `src/shared/types/api.ts` + - изменить `getData(): Promise` + - добавить `getMemberActivityMeta()` +- `src/preload/constants/ipcChannels.ts` + - добавить `TEAM_GET_MEMBER_ACTIVITY_META` +- `src/preload/index.ts` + - прокинуть `getMemberActivityMeta()` + +### Main process + +- `src/main/ipc/teams.ts` + - handler для `team:getMemberActivityMeta` + - `team:getData` теперь возвращает structural snapshot +- `src/main/services/team/TeamMessageFeedService.ts` + - новый shared normalized message feed cache/index + - используется и `getMessagesPage()`, и `getMemberActivityMeta()` +- `src/main/services/team/TeamDataService.ts` + - `getTeamData()` перестаёт включать `messages` + - больше не зовёт old `resolveMembers(..., messages)` + - `getMessagesPage()` перестаёт делать inline full normalize flow + - делегирует page slicing в shared feed service +- `src/main/services/team/TeamMemberResolver.ts` + - split на structural-only resolver +- `src/main/services/team/` + - новый `MemberActivityMetaService.ts` + - cache по `feedRevision` +- `src/main/services/team/TeamDataWorkerClient.ts` + - расширить worker ops для message feed / activity meta path + - обновить типы ответа +- `src/main/services/team/teamDataWorkerTypes.ts` + - добавить request/response ops для messages/meta path +- `src/main/workers/team-data-worker.ts` + - синхронизировать worker result types + - завести обработку new feed/meta ops + +### Renderer store and event routing + +- `src/renderer/store/slices/teamSlice.ts` + - добавить `teamMessagesByName` + - добавить `memberActivityMetaByTeam` + - добавить actions для head refresh / older pages / meta refresh / optimistic merge + - добавить single-flight request guards и stale-response guards + - добавить store-owned fallback polling control + - добавить structural sharing + no-op suppression для snapshot +- `src/renderer/store/index.ts` + - поменять routing team events + - `lead-message` / `inbox` перестают звать full `refreshTeamData()` + +### Renderer consumers + +- `src/renderer/components/team/TeamDetailView.tsx` + - переключить на snapshot + message store + memberActivityMeta + spawn statuses + - pending reply delayed refresh перевести на message-head refresh +- `src/renderer/components/team/messages/MessagesPanel.tsx` + - удалить прямой fetching logic + - читать messages из store +- `src/renderer/components/team/activity/ActivityTimeline.tsx` + - читать messages из store-backed selector или timeline view-model selector + - не держать local fetch/polling/orchestration +- `src/renderer/components/team/messages/StatusBlock.tsx` + - получать messages из store-backed source, не из snapshot prop +- `src/renderer/components/team/members/MemberDetailDialog.tsx` + - убрать `messages` prop +- `src/renderer/components/team/members/MemberMessagesTab.tsx` + - убрать прямой IPC fetch + - использовать store messages + selectors +- `src/renderer/components/team/members/MemberList.tsx` + - читать `hasPendingReply` из local overlay, не из meta +- `src/renderer/components/team/members/MemberHoverCard.tsx` + - читать facts/view-model selector, а не whole meta wrapper или snapshot messages +- `src/renderer/components/layout/PaneContent.tsx` + - не менять в этом PR, только учитывать mounted-hidden behavior + +### Graph + +- `src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts` + - pure adapter принимает `(snapshot, messages, memberActivityFacts, teamName)` +- graph-related tests + - заменить legacy `TeamData.messages` assumptions + +### Tests + +- `test/main/services/team/TeamDataService.test.ts` + - snapshot tests отдельно + - meta tests отдельно +- `test/main/ipc/teams.test.ts` + - новый IPC handler + - убрать ожидания `result.data.messages` +- `test/renderer/store/` + - refresh routing, no-op suppression, optimistic merges +- `test/renderer/components/team/` + - messages panel, member dialog, member list, pending replies +- `test/renderer/features/agent-graph/` + - graph adapter больше не зависит от snapshot messages + +## 15.4 Merge gates + +Это checkpoints, которые должны быть выполнены до merge, иначе PR выглядит "почти доделанным", но архитектурно остаётся дырявым. + +### Gate 1 - No dual message source + +- `MessagesPanel` не читает `messages` из props +- `MemberMessagesTab` не читает `messages` из team snapshot +- graph не читает `TeamData.messages` + +### Gate 2 - Event routing actually split + +- `lead-message` event тестом подтверждённо не вызывает full `refreshTeamData()` +- `inbox` event тоже не тянет full snapshot refresh по умолчанию +- `refreshMemberActivityMeta()` не дёргается без `feedChanged === true` или stale-meta condition + +### Gate 3 - Exact semantics preserved + +- `messageCount` остался exact +- `lastActiveAt` считается по authored messages, как раньше +- terminal message semantics не потеряны +- display status в renderer overlay не сломан для "no message yet but has active task" +- existing pending-reply / TTL / activity threshold constants не поменялись скрытно внутри performance refactor + +### Gate 4 - UI semantics preserved + +- optimistic send не моргает +- pending member replies всё ещё очищаются фактическим reply +- cross-team TTL badges всё ещё работают +- canonical head refresh не откатывает optimistic rows до server confirmation +- member UI не подписан на whole `TeamMemberActivityMeta` wrapper без необходимости + +### Gate 5 - Legacy field gone + +- в shared snapshot contract нет `messages` +- в merged renderer code нет чтения `selectedTeamData.messages` +- `selectedTeamData`, если поле сохранено, не является второй independently-built snapshot copy +- old mixed `TeamData` semantic comparator не пережил migration и не остался permanent hot path guard + +### Gate 6 - Shared feed cache actually used + +- `getMessagesPage()` не содержит второго самостоятельного normalize pipeline +- `MessagesPage` реально несёт `feedRevision`, а store использует его в routing/invalidation +- `getMessagesPage()` режет страницы по stable effective message identity, а не по "сырым optional ids" +- `getMemberActivityMeta()` не читает raw storage напрямую +- оба hot paths сходятся в `TeamMessageFeedService` + +### Gate 7 - Worker boundary preserved + +- expensive feed rebuild path не исполняется на main event loop +- worker ops для messages/meta path реально wired и покрыты тестом / smoke check +- packaged runtime не молча сваливается в main-thread hot path из-за пропавшего worker artifact + +### Gate 8 - Polling ownership preserved + +- `MessagesPanel` и `MemberMessagesTab` не держат собственный polling lifecycle +- fallback polling живёт только в store + +### Gate 8.5 - Single-flight preserved + +- burst events не создают пачку параллельных identical refresh requests на одну и ту же команду +- stale async responses не откатывают store после newer refresh + +### Gate 9 - No overview fan-out + +- `TeamListView` и похожие overview screens не инициируют скрытый fan-out `getMessagesPage()` / `getMemberActivityMeta()` по всем командам +- overview остаётся на structural snapshot semantics + +## 15.5 Suggested commit slices + +Если делать это не одним бесформенным diff, а нормальными кусками, я бы резал так: + +1. `refactor(team): introduce structural team snapshot contracts` + - новые shared types + - новый IPC contract для `getMemberActivityMeta()` + +2. `refactor(team): add shared team message feed cache` + - `TeamMessageFeedService` + - `getMessagesPage()` переводится на shared feed + - worker boundary расширяется для messages/meta path + +3. `refactor(team): split member activity meta from team snapshot` + - `MemberActivityMetaService` + - `TeamMemberResolver` становится structural-only + - `getData()` перестаёт возвращать `messages` + +4. `refactor(renderer): move team message orchestration into store` + - store caches and actions + - event routing split + - store-owned fallback polling + +5. `refactor(renderer): migrate team detail consumers to snapshot plus message store` + - `TeamDetailView` + - `MessagesPanel` + - `ActivityTimeline` + - `MemberDetailDialog` + - `MemberMessagesTab` + - `MemberHoverCard` + - `StatusBlock` + - graph adapter + +6. `test(team): cover snapshot split and message feed ownership` + - main tests + - store tests + - component tests + - graph tests + +Не обязательно коммитить ровно так, но как execution model это сильно снижает хаос. + +## 15.6 Mechanical execution checklist + +Это section для прямого исполнения. Идея простая: не переходить к следующему шагу, пока текущий не прошёл свой exit check. + +### Checklist 0 - Safety prep + +- убедиться, что worktree чистый +- прогнать baseline tests, которые покрывают team detail / messages / graph +- зафиксировать baseline perf probes, если уже есть локальный soak scenario + +Exit criteria: + +- baseline известен +- есть с чем сравнивать после миграции + +### Checklist 1 - Contracts first + +Сделать: + +- ввести `TeamViewSnapshot` +- ввести `TeamMemberSnapshot` +- ввести `TeamMemberActivityMeta` +- расширить existing `MessagesPage` полем `feedRevision` +- перевести `getMessagesPage()` request contract на `cursor` +- описать новые worker request/response contracts для messages/meta path + +Проверить: + +- types compile +- пока можно держать локальные compatibility adapters, но не merged aliases +- если `selectedTeamData` сохраняется на этом шаге, он уже должен reuse'ить ref canonical cache entry +- `beforeTimestamp` больше не фигурирует как canonical paging API в shared contracts + +Exit criteria: + +- transport contracts готовы +- дальше можно переписывать main/services без изобретения типов на ходу + +### Checklist 2 - Shared feed path + +Сделать: + +- добавить `TeamMessageFeedService` +- перевести `getMessagesPage()` на shared feed +- нормализовать stable effective message identity до выдачи page response +- провести expensive rebuild path через worker boundary + +Проверить: + +- один canonical normalize pipeline +- cursor строится по effective identity +- `getMessagesPage()` больше не повторяет old inline normalize flow + +Exit criteria: + +- message feed path централизован +- `getMessagesPage()` уже не является скрытой legacy дырой + +### Checklist 3 - Structural snapshot split + +Сделать: + +- `getData()` перестаёт возвращать `messages` +- `TeamMemberResolver` становится structural-only +- `MemberActivityMetaService` строится от shared feed + +Проверить: + +- snapshot без messages компилируется +- meta даёт `messageCountExact` / `lastAuthoredMessageAt` + +Exit criteria: + +- main-side split завершён +- transport границы больше не смешаны + +### Checklist 4 - Store ownership + +Сделать: + +- store держит `teamMessagesByName` +- store держит `memberActivityMetaByTeam` +- store держит fallback polling +- `refreshTeamMessagesHead()` возвращает semantic result с `feedChanged` / `headChanged` +- store делает single-flight/coalesced refresh orchestration per team +- selector возвращает merged canonical + optimistic messages +- selector layer разделяет `memberActivityFacts` и `memberActivityFeedRevision` + +Проверить: + +- компоненты ещё могут быть не переведены полностью, но orchestration уже в store + +Exit criteria: + +- fetching/polling/optimistic merge больше не размазаны по компонентам + +### Checklist 5 - UI consumers migration + +Сделать: + +- `MessagesPanel` без direct fetch/polling +- `ActivityTimeline` от store-backed messages или timeline view-model selector +- `MemberMessagesTab` без direct fetch/polling +- `MemberDetailDialog` без `messages` prop +- `MemberHoverCard` от facts/view-model selector, не от whole wrappers +- `StatusBlock` от store-backed messages +- `TeamDetailView` собирает overlay model +- UI consumers переходят на data/view-model selectors, а не на whole wrappers + +Проверить: + +- `rg` по renderer не находит direct `api.teams.getMessagesPage(` внутри этих компонентов +- `selectedTeamData.messages` больше не читается +- `MemberMessagesTab` больше не фильтрует whole team messages array прямо в render body +- `ActivityTimeline` больше не строится от snapshot message prop +- `MemberHoverCard` не подписан на whole `TeamMemberActivityMeta` wrapper + +Exit criteria: + +- UI больше не зависит от legacy message transport + +### Checklist 6 - Graph migration + +Сделать: + +- graph adapter читает snapshot + messages + memberActivityFacts + +Проверить: + +- graph open не ломается +- больше нет зависимости от `TeamData.messages` + +Exit criteria: + +- последний крупный consumer legacy messages отрезан + +### Checklist 7 - Cleanup and hard gates + +Сделать: + +- убрать compatibility plumbing +- убрать legacy fields / props +- обновить тесты и perf probes + +Проверить: + +- проходят merge gates +- проходят critical tests +- soak/perf лучше baseline + +Exit criteria: + +- PR не только компилируется, но и реально дошёл до целевого shape + +## 16. Конкретные code patterns + +## 16.1 Reusable array sharing helper + +```ts +function reuseArrayIfEqual( + prev: readonly T[], + next: readonly T[], + areEqual: (left: T, right: T) => boolean +): readonly T[] { + if (prev === next) return prev; + if (prev.length !== next.length) return next; + for (let index = 0; index < prev.length; index += 1) { + if (!areEqual(prev[index], next[index])) { + return next; + } + } + return prev; +} +``` + +## 16.2 Narrow selector pattern + +```ts +const EMPTY_MESSAGES: readonly InboxMessage[] = Object.freeze([]); +const EMPTY_MEMBER_ACTIVITY_FACTS: Readonly> = + Object.freeze({}); +const teamMessagesSelectors = new Map readonly InboxMessage[]>(); +const memberMessagesSelectors = new Map readonly InboxMessage[]>(); + +export function selectTeamSnapshot(teamName: string) { + return (state: AppState) => + state.teamDataCacheByName[teamName] ?? + (state.selectedTeamName === teamName ? state.selectedTeamData : null); +} + +export function selectTeamMessagesEntry(teamName: string) { + return (state: AppState) => state.teamMessagesByName[teamName] ?? null; +} + +function getOrCreateTeamMessagesSelector(teamName: string) { + let selector = teamMessagesSelectors.get(teamName); + if (!selector) { + selector = createMemoizedSelector( + (state: AppState) => state.teamMessagesByName[teamName]?.canonicalMessages ?? EMPTY_MESSAGES, + (state: AppState) => state.teamMessagesByName[teamName]?.optimisticMessages ?? EMPTY_MESSAGES, + (canonicalMessages, optimisticMessages) => + mergeTeamMessages(canonicalMessages, optimisticMessages) + ); + teamMessagesSelectors.set(teamName, selector); + } + return selector; +} + +export function selectTeamMessages(teamName: string) { + return getOrCreateTeamMessagesSelector(teamName); +} + +/** Low-level/internal selector. UI should usually prefer facts/revision selectors below. */ +export function selectMemberActivityMeta(teamName: string) { + return (state: AppState) => state.memberActivityMetaByTeam[teamName] ?? null; +} + +export function selectMemberActivityFacts(teamName: string) { + return (state: AppState) => + state.memberActivityMetaByTeam[teamName]?.members ?? EMPTY_MEMBER_ACTIVITY_FACTS; +} + +export function selectMemberActivityFeedRevision(teamName: string) { + return (state: AppState) => state.memberActivityMetaByTeam[teamName]?.feedRevision ?? null; +} + +function getOrCreateMemberMessagesSelector(teamName: string, memberName: string) { + const key = `${teamName}::${memberName}`; + let selector = memberMessagesSelectors.get(key); + if (!selector) { + selector = createMemoizedSelector(selectTeamMessages(teamName), (messages) => + messages.filter((message) => message.from === memberName || message.to === memberName) + ); + memberMessagesSelectors.set(key, selector); + } + return selector; +} + +export function selectMemberMessages(teamName: string, memberName: string) { + return getOrCreateMemberMessagesSelector(teamName, memberName); +} +``` + +Важно: + +- `createMemoizedSelector` здесь условное имя для любой стабильной selector factory, которую команда уже использует +- важно не конкретное API helper'а, а то, что merged selectors действительно memoized и возвращают stable refs +- fallback на `selectedTeamData` в примере выше нужен только пока поле ещё существует; если `selectedTeamData` удалён, selector упрощается до чтения `teamDataCacheByName` + +### Selector usage rule + +- `TeamDetailView`, `MemberList`, `MemberHoverCard`, `MemberDetailDialog` должны читать facts selector, а не whole meta wrapper +- routing / polling logic может читать revision selector отдельно +- components, которым нужен только message array, не должны подписываться на whole `TeamMessagesCacheEntry`, если им не нужны loading flags +- `MemberMessagesTab` по умолчанию должен читать `selectMemberMessages(teamName, memberName)` или аналогичный memoized selector, а не фильтровать whole array прямо в render body +- empty selector fallbacks должны возвращать stable frozen references, а не новый `{}` / `[]` на каждый вызов + +### Practical selector split + +Для ясности полезно сразу мыслить селекторы тремя слоями: + +- data selectors + - `selectTeamSnapshot(teamName)` + - `selectTeamMessages(teamName)` + - `selectMemberActivityFacts(teamName)` + - `selectMemberMessages(teamName, memberName)` +- control selectors + - `selectTeamMessagesEntry(teamName)` только для loading flags / cursor / hasMore + - `selectMemberActivityFeedRevision(teamName)` только для routing / stale checks +- view-model selectors + - `selectResolvedTeamMembersView(teamName)` + - `selectPendingRepliesView(teamName)` + +Правило: + +- components по умолчанию читают data/view-model selector +- control selectors не должны становиться случайным render dependency для большого UI subtree + +## 16.3 Overlay model builder + +```ts +function mergeMembersWithActivity( + members: TeamMemberSnapshot[], + activityFacts: Record, + spawnStatuses: Record +): ResolvedTeamMemberView[] { + return members.map((member) => { + const activity = activityFacts[member.name]; + const spawn = spawnStatuses[member.name]; + return { + ...member, + lastActiveAt: activity?.lastAuthoredMessageAt ?? null, + messageCount: activity?.messageCountExact ?? 0, + status: resolveDisplayMemberStatus(member, activity, spawn), + }; + }); +} +``` + +### Это важный паттерн + +View-model можно смешивать в renderer. +Transport contract смешивать нельзя. + +### Что добавляется поверх этого отдельно + +`pendingRepliesByMember` overlay надо мержить отдельным путём: + +```ts +const membersWithActivityAndPending = membersWithActivity.map((member) => ({ + ...member, + hasPendingReply: Boolean(pendingRepliesByMember[member.name]), +})); +``` + +То есть: + +- stable facts идут из `memberActivityMeta` +- ephemeral pending-reply UX идёт из local renderer state + +## 16.4 Display status helper + +```ts +function resolveDisplayMemberStatus( + member: TeamMemberSnapshot, + activity: MemberActivityMetaEntry | undefined, + spawn: MemberSpawnStatusEntry | undefined, + nowMs = Date.now() +): MemberStatus { + if (member.removedAt) return 'terminated'; + if (activity?.latestAuthoredMessageSignalsTermination) return 'terminated'; + + const lastAuthoredAt = activity?.lastAuthoredMessageAt; + if (!lastAuthoredAt) { + return member.currentTaskId ? 'active' : 'idle'; + } + + const ts = Date.parse(lastAuthoredAt); + if (!Number.isFinite(ts)) return 'unknown'; + + return nowMs - ts < 5 * 60 * 1000 ? 'active' : 'idle'; +} +``` + +Важно: + +- helper повторяет старую message-authored semantics +- task presence влияет только на case "сообщений ещё не было" +- spawn/runtime state не переписывает этот base status, а накладывается отдельно в presentation helpers + +## 17. Что обязательно не сломать + +Вот места, где проще всего внести регрессию. + +### 17.1 `sendTeamMessage()` UX + +Пользователь не должен видеть: + +- пропадающее только что отправленное сообщение +- дубль optimistic + fetched +- откат scroll position + +### 17.2 `pendingRepliesByMember` + +Если сейчас pending reply badge обновляется от локального state и timers, новый split не должен сделать его laggy. + +### 17.3 Scroll and expanded state in `MessagesPanel` + +Сообщения больше не придут как prop re-seed. +Нужно проверить, что: + +- scroll memory сохраняется +- expanded item state не сбрасывается на каждый head refresh + +### 17.4 `MemberHoverCard` + +Он сейчас читает selected team member data из snapshot. +После split не надо случайно вернуть message-derived churn в hover path. + +### 17.5 `ActivityTimeline` + +Это один из исходных hot consumers, поэтому его нельзя считать "само как-то переедет". + +После split важно проверить: + +- timeline derivations не зависят от `selectedTeamData.messages` +- hidden tab не получает wide invalidation только из-за timeline selectors +- timeline grouping не пересчитывается от whole wrapper change, если message slice фактически не менялся + +### 17.6 TeamListView / global task dialogs + +Они не должны внезапно стать зависимыми от message caches. + +Особенно важно: + +- не тащить `getMessagesPage()` / `getMemberActivityMeta()` в list-row hydration path +- не вводить скрытый fan-out по всем видимым командам +- если `StatusBlock` или похожий badge показывается в overview context, он использует только уже гидратированный cache или structural fallback и не имеет права сам инициировать hidden team hydration + +## 18. Тестовый план + +## 18.1 Main unit tests + +Нужны тесты на: + +- `getData()` не возвращает messages +- structural members строятся без message history +- `getMessagesPage()` возвращает `feedRevision`, описывающий весь normalized feed +- historical-only feed change может обновить `feedRevision` даже если top page slice тот же +- forced rebuild того же normalized feed не меняет `feedRevision` +- successful empty head fetch возвращает non-null `feedRevision` и корректно инициализирует empty canonical state +- каждый message row в page response несёт stable effective identity +- `getMemberActivityMeta()` корректно считает `lastAuthoredMessageAt` +- `getMemberActivityMeta()` сохраняет exact `messageCount` +- `getMemberActivityMeta()` корректно помечает `latestAuthoredMessageSignalsTermination` +- shared message feed cache не пересобирается без изменения feed inputs +- meta cache переиспользуется при том же `feedRevision` +- expensive rebuild path для messages/meta идёт через worker op, а не мимо worker boundary + +## 18.2 Renderer store tests + +Нужны тесты на: + +- `refreshTeamData()` no-op suppression сохраняет ref +- `selectedTeamData` reuse'ит exact same ref as `teamDataCacheByName[selectedTeamName]` +- при `selectedTeamName` switch `selectedTeamData` не продолжает указывать на snapshot предыдущей команды +- отсутствие `teamDataCacheByName[teamName]` и `memberActivityMetaByTeam[teamName]` до first success не заменяется fake placeholder objects +- `refreshTeamMessagesHead()` merge'ит новые head messages +- `refreshTeamMessagesHead()` различает `feedChanged` и `headChanged` +- `refreshTeamMessagesHead()` не возвращает невозможное состояние `feedChanged === false && headChanged === true` +- store single-flight coalescing не допускает burst из параллельных head refresh на одну команду +- head refresh и older-page load для одной команды не мутируют canonical window параллельно +- `loadingHead === true && loadingOlder === true` для одной команды не возникает +- `selectTeamMessages(teamName)` сохраняет stable ref, если canonical/optimistic inputs не менялись +- UI selectors, читающие member activity facts, не re-render'ятся только из-за смены `computedAt` / `feedRevision` +- in-flight/dirty/visibility bookkeeping не становится случайным render-driving reactive state без отдельной причины +- failure в `refreshTeamMessagesHead()` не очищает уже загруженный canonical window +- `lastFetchedAt` остаётся `null` до первого успешного head/message fetch и не меняется на failed request +- failure в `refreshMemberActivityMeta()` не очищает предыдущий facts record +- `loadOlderTeamMessages()` before head hydration не делает некорректный older-page request +- `loadOlderTeamMessages()` при `hasMore === false` делает cheap no-op +- `headHydrated === false` не сочетается с non-empty `canonicalMessages` или с `loadingOlder === true` +- `headHydrated === false` сочетается только с bootstrap `feedRevision/null`, `nextCursor/null` и `lastFetchedAt/null` +- optimistic row может жить поверх `headHydrated === false` bootstrap entry до первого successful head fetch +- optimistic send + fetched confirmation dedup +- failed optimistic send удаляет optimistic row и откатывает local pending-reply state +- optimistic row survives canonical refresh until matching `messageId` appears +- user send сам по себе не триггерит лишний `refreshMemberActivityMeta()` +- `lead-message` event больше не вызывает `refreshTeamData()` +- `task` event по-прежнему вызывает `refreshTeamData()` +- delayed waiting refresh для pending member reply зовёт `refreshTeamMessagesHead()`, а не full snapshot refresh +- hidden inactive team не получает message/meta refresh от чужих событий +- одного `selectedTeamName` без explicit visibility signal недостаточно для запуска visible-team polling/hydration policy +- late response для предыдущей команды после switch не переустанавливает `selectedTeamData` под новую выбранную команду +- hide transition не очищает уже гидратированные snapshot/message/meta caches сам по себе +- reopen после hide reuse'ит существующий cache baseline, а не требует forced cold-start reset +- `refreshMemberActivityMeta()` после lead/inbox идёт только при `feedChanged === true` или stale-meta condition +- historical-only `feedChanged === true` при `headChanged === false` всё равно запускает meta refresh +- older-page response после newer head refresh не откатывает `nextCursor` и не ломает canonical ordering +- irreconcilable merge after `feedRevision` change сбрасывает только canonical older tail и не теряет optimistic rows +- fallback polling запускается только для visible active team или local pending-reply wait state + +## 18.3 Component tests + +Нужны тесты на: + +- `MessagesPanel` initial hydration from store +- `ActivityTimeline` читает store-backed messages/view-model path, а не snapshot prop +- `MemberDetailDialog` without snapshot messages prop +- `MemberHoverCard` читает facts/view-model selector, а не whole meta wrapper +- `StatusBlock` отрабатывает member pending replies из local overlay +- overview `StatusBlock` или аналогичный badge не триггерит hidden team hydration +- graph adapter берёт messages не из snapshot +- `StatusBlock` корректно считает cross-team pending replies из message cache + local TTL +- `MessagesPanel` и `MemberMessagesTab` не содержат собственного polling/fetch orchestration +- older-page loading не ломает scroll/order при одновременном head refresh + +## 18.4 Soak / perf validation + +Нужны реальные runtime probes: + +- count of `refreshTeamData` calls +- count of suppressed no-op snapshot writes +- count of `refreshTeamMessagesHead` +- count of `refreshMemberActivityMeta` +- commit count `TeamDetailView` +- longtask count and max before/after +- IPC payload size before/after for `team:getData` + +## 19. Acceptance criteria + +Фикс можно считать правильным, если одновременно выполняется всё: + +1. `lead-message` storm больше не вызывает repeated `refreshTeamData()` для visible team +2. identical structural snapshot не меняет `selectedTeamData` ref +3. `MessagesPanel` живёт без `data.messages` prop +4. member list/status block не зависят от full messages array inside snapshot +5. graph не зависит от `TeamData.messages` +6. `MessagesPanel` и `MemberMessagesTab` не делают direct IPC fetch из компонента +7. long tasks на 4-member soak заметно падают +8. нет regressions в optimistic send, member dialog, pending replies +9. hot path `getMessagesPage()` больше не делает raw full rescan на каждый visible refresh +10. multi-team overview screens не создают hidden fan-out на `getMessagesPage()` / `getMemberActivityMeta()` +11. burst event storm не порождает параллельную очередь одинаковых head/meta refresh requests + +### Практический perf target + +Хотя бы такой: + +- skip-rate no-op structural refreshes высокий в heartbeat windows +- `team:getData` payload ощутимо меньше +- long tasks больше не накапливаются без видимых изменений UI + +## 19.1 Reviewer checklist + +Это короткий список для финальной проверки PR человеком, который не писал реализацию. + +Reviewer должен уметь ответить "да" на каждый пункт ниже без догадок: + +- `rg` по merged code не находит чтения `selectedTeamData.messages` +- `getData()` типизирован как `TeamViewSnapshot`, а не legacy mixed transport +- `getMessagesPage()` в shared API больше не использует `beforeTimestamp` как canonical paging contract +- `MessagesPage.feedRevision` выглядит как content-stable revision, а не timestamp-like token +- `getMessagesPage()` и `getMemberActivityMeta()` сходятся в один shared feed backend +- `MessagesPanel` и `MemberMessagesTab` не содержат прямых IPC fetch/polling путей +- UI member list читает facts selector или view-model selector, а не whole `TeamMemberActivityMeta` +- `selectedTeamData`, если сохранён, reuse'ит тот же ref, что и canonical cache entry +- worker path для heavy messages/meta rebuild реально задействован в нормальном runtime +- older-history merge имеет safety fallback, а не assumes append-only forever +- tests покрывают `feedChanged === true` при `headChanged === false` + +Если хотя бы на один пункт ответ "не уверен", PR ещё слишком двусмысленный и план выполнен не полностью. + +## 20. Нужен ли future split ещё дальше + +Эта секция не открывает scope текущего PR. + +Правило: + +- ничего из списка ниже не является blocker для merge текущего split +- если реализация текущего PR начинает зависеть от одного из этих future ideas, это уже scope creep и его надо отдельно остановить +- acceptance current PR определяется только секциями выше, а не будущими optional split ideas + +Возможно, но не обязательно сразу. + +### Что имеет смысл split'ить позже, если понадобится + +- task comments/history, если они станут heavy +- graph-specific activity feed +- process diagnostics/log metadata + +### Что не надо split'ить сейчас + +- `config` +- `tasks` +- `kanbanState` +- `processes` + +Они пока выглядят как разумный structural snapshot. + +То есть ответ на вопрос "мы в будущем ещё больше разрежем `getData`?" такой: + +- возможно да +- но **не надо делать это заранее** +- прямо сейчас правильная граница проходит по messages и message-derived member activity + +## 21. Отдельно про Linux task manager и "Electron 12.1 GB" + +Это важно понимать правильно. + +Если на Linux в системном мониторинге видны отдельные строки: + +- `electron` +- `chrome --type=renderer` +- `node` +- `claude-multimodel` + +то это обычно **отдельные OS processes**, а не "всё сложено в electron row". + +Следствие: + +- `electron 12.1 GB` очень похоже на реальный RSS browser/main процесса Electron +- spawned Claude/Codex/node subprocesses обычно не должны магически считаться внутрь этой строки, если они уже видны отдельно + +Это не доказывает leak само по себе, но и не выглядит как "да это просто все дети туда суммировались". + +### Что добавить для подтверждения + +Нужна отдельная main-side telemetry: + +```ts +const mem = process.memoryUsage(); +const metrics = app.getAppMetrics(); +``` + +И логировать хотя бы каждые 30s: + +- `rss` +- `heapUsed` +- `external` +- per-process Electron metrics + +Тогда станет видно: + +- реально ли main/browser process растёт +- есть ли рост после renderer recovery +- совпадает ли это с observed long stalls + +## 22. Мой итоговый вывод + +Если хочется сделать **сразу правильно**, а не делать цепочку полуфиксов, то целевой дизайн должен быть именно таким: + +- `getData(teamName)` -> structural snapshot +- `getMessagesPage(teamName, { limit, cursor })` -> message feed +- `getMemberActivityMeta(teamName)` -> lightweight message-derived overlay +- renderer store хранит их раздельно +- event routing тоже раздельный +- `refreshTeamData()` имеет structural sharing + no-op suppression + +Самый частый неправильный компромисс здесь: + +- "давайте просто сравним новый `TeamData` с предыдущим и всё" + +Это хороший emergency mitigation, но не лучший final state. + +Самый надёжный final state: + +- split boundaries +- убрать message-derived смысл из structural snapshot +- сохранить semantic guard как страховку + +Именно это я считаю вариантом, который ближе всего к "сделать один раз и правильно", а не возвращаться потом ещё на два круга переделки. diff --git a/docs/team-management/research-messaging.md b/docs/team-management/research-messaging.md index 02388c1a..ea5789e1 100644 --- a/docs/team-management/research-messaging.md +++ b/docs/team-management/research-messaging.md @@ -55,7 +55,7 @@ import Anthropic from '@anthropic-ai/sdk'; const client = new Anthropic(); const response = await client.messages.create({ - model: 'claude-opus-4-6', + model: 'claude-opus-4-7', messages: [{ role: 'user', content: 'Send message to teammate...' }], tools: [/* SendMessage, TaskUpdate, etc. */] }); diff --git a/eslint.config.js b/eslint.config.js index 5a191478..e207d1da 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -459,6 +459,14 @@ export default defineConfig([ }, }, + { + name: 'team-transcript-project-resolver-sonar-override', + files: ['src/main/services/team/TeamTranscriptProjectResolver.ts'], + rules: { + 'sonarjs/no-identical-functions': 'off', + }, + }, + // Preload script (Electron bridge) { name: 'electron-preload', diff --git a/package.json b/package.json index 1d963d43..9809f68c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "claude-agent-teams-ui", "type": "module", - "version": "1.1.0", + "version": "1.3.0", "description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls", "license": "AGPL-3.0", "author": { @@ -143,6 +143,7 @@ "motion": "12.38.0", "node-diff3": "^3.2.0", "node-pty": "^1.1.0", + "pidusage": "4.0.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-grid-layout": "^2.2.2", @@ -174,6 +175,7 @@ "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "@types/node": "^25.0.7", + "@types/pidusage": "2.0.5", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@types/ssh2": "^1.15.5", @@ -213,7 +215,7 @@ }, "build": { "appId": "com.agent-teams.app", - "productName": "Claude Agent Teams UI", + "productName": "Agent Teams UI", "directories": { "output": "release" }, @@ -303,7 +305,8 @@ "pnpm": { "onlyBuiltDependencies": [ "electron", - "node-pty" + "node-pty", + "cpu-features" ] }, "knip": { diff --git a/packages/agent-graph/src/canvas/draw-handoff-cards.ts b/packages/agent-graph/src/canvas/draw-handoff-cards.ts index 098e439f..a0e62860 100644 --- a/packages/agent-graph/src/canvas/draw-handoff-cards.ts +++ b/packages/agent-graph/src/canvas/draw-handoff-cards.ts @@ -3,7 +3,10 @@ import { HANDOFF_CARD, NODE, TASK_PILL, MIN_VISIBLE_OPACITY } from '../constants import type { CameraTransform } from '../hooks/useGraphCamera'; import { getHandoffAnchorTarget } from '../layout/launchAnchor'; import type { GraphNode } from '../ports/types'; -import type { TransientHandoffCard } from '../ui/transientHandoffs'; +import { + getTransientHandoffCardAlpha, + type TransientHandoffCard, +} from '../ui/transientHandoffs'; import { truncateText } from './draw-misc'; import { hexWithAlpha, measureTextCached } from './render-cache'; @@ -20,24 +23,24 @@ export function drawHandoffCards( const { cards, nodeMap, time, camera, viewport } = params; if (cards.length === 0) return; - const stackIndexByDestination = new Map(); + const stackIndexByAnchor = new Map(); let drawnCount = 0; for (const card of cards) { if (drawnCount >= HANDOFF_CARD.maxVisible) break; - const destinationNode = nodeMap.get(card.destinationNodeId); - if (!destinationNode || destinationNode.x == null || destinationNode.y == null) continue; + const anchorNode = nodeMap.get(card.anchorNodeId); + if (!anchorNode || anchorNode.x == null || anchorNode.y == null) continue; - const alpha = getCardAlpha(card, time); + const alpha = getTransientHandoffCardAlpha(card, time); if (alpha <= MIN_VISIBLE_OPACITY) continue; const previewLines = buildPreviewLines(ctx, card.preview); const height = HANDOFF_CARD.baseHeight + previewLines.length * HANDOFF_CARD.previewLineHeight; - const stackIndex = stackIndexByDestination.get(card.destinationNodeId) ?? 0; - stackIndexByDestination.set(card.destinationNodeId, stackIndex + 1); + const stackIndex = stackIndexByAnchor.get(card.anchorNodeId) ?? 0; + stackIndexByAnchor.set(card.anchorNodeId, stackIndex + 1); const position = getCardPosition({ - node: destinationNode, + node: anchorNode, camera, viewport, height, @@ -59,15 +62,6 @@ export function drawHandoffCards( } } -function getCardAlpha(card: TransientHandoffCard, time: number): number { - const fadeIn = Math.min(1, (time - card.activatedAt) / HANDOFF_CARD.fadeInSeconds); - const fadeOutRemaining = card.expiresAt - time; - const fadeOut = fadeOutRemaining <= HANDOFF_CARD.fadeOutSeconds - ? Math.max(0, fadeOutRemaining / HANDOFF_CARD.fadeOutSeconds) - : 1; - return Math.max(0, Math.min(1, fadeIn * fadeOut)); -} - function getCardPosition(params: { node: GraphNode; camera: CameraTransform; diff --git a/packages/agent-graph/src/hooks/useGraphCamera.ts b/packages/agent-graph/src/hooks/useGraphCamera.ts index 0a842b91..fbfa2b38 100644 --- a/packages/agent-graph/src/hooks/useGraphCamera.ts +++ b/packages/agent-graph/src/hooks/useGraphCamera.ts @@ -4,7 +4,7 @@ * All state in refs — no React re-renders. */ -import { useRef, useCallback } from 'react'; +import { useRef, useCallback, useMemo } from 'react'; import type { GraphNode } from '../ports/types'; import { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants'; import type { WorldBounds } from '../layout/launchAnchor'; @@ -170,17 +170,31 @@ export function useGraphCamera(): UseGraphCameraResult { t.zoom = Math.max(CAMERA.minZoom, t.zoom / 1.2); }, []); - return { - transformRef, - screenToWorld, - worldToScreen, - handleWheel, - handlePanStart, - handlePanMove, - handlePanEnd, - zoomToFit, - zoomIn, - zoomOut, - updateInertia, - }; + return useMemo( + () => ({ + transformRef, + screenToWorld, + worldToScreen, + handleWheel, + handlePanStart, + handlePanMove, + handlePanEnd, + zoomToFit, + zoomIn, + zoomOut, + updateInertia, + }), + [ + screenToWorld, + worldToScreen, + handleWheel, + handlePanStart, + handlePanMove, + handlePanEnd, + zoomToFit, + zoomIn, + zoomOut, + updateInertia, + ] + ); } diff --git a/packages/agent-graph/src/hooks/useGraphInteraction.ts b/packages/agent-graph/src/hooks/useGraphInteraction.ts index 33862ef3..7fdbf13e 100644 --- a/packages/agent-graph/src/hooks/useGraphInteraction.ts +++ b/packages/agent-graph/src/hooks/useGraphInteraction.ts @@ -3,7 +3,7 @@ * Delegates hit testing to strategy pattern. */ -import { useRef, useCallback } from 'react'; +import { useRef, useCallback, useMemo } from 'react'; import type { GraphNode } from '../ports/types'; import { ANIM } from '../constants/canvas-constants'; import { findNodeAt } from '../canvas/hit-detection'; @@ -81,13 +81,16 @@ export function useGraphInteraction( return findNodeAt(wx, wy, nodes); }, []); - return { - hoveredNodeId, - dragNodeId, - isDragging, - handleMouseDown, - handleMouseMove, - handleMouseUp, - handleDoubleClick, - }; + return useMemo( + () => ({ + hoveredNodeId, + dragNodeId, + isDragging, + handleMouseDown, + handleMouseMove, + handleMouseUp, + handleDoubleClick, + }), + [handleDoubleClick, handleMouseDown, handleMouseMove, handleMouseUp] + ); } diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index cd4d62ad..db34adde 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { ANIM_SPEED, NODE } from '../constants/canvas-constants'; import { getStateColor } from '../constants/colors'; @@ -239,19 +239,29 @@ export function useGraphSimulation(): UseGraphSimulationResult { }; }, []); - return { - stateRef, - updateData, - tick, - setNodePosition, - clearNodePosition, - clearTransientOwnerPositions, - resolveNearestOwnerSlot, - getLaunchAnchorWorldPosition: (leadNodeId: string) => - launchAnchorPositionsRef.current.get(leadNodeId) ?? null, - getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null, - getExtraWorldBounds: () => extraWorldBoundsRef.current, - }; + return useMemo( + () => ({ + stateRef, + updateData, + tick, + setNodePosition, + clearNodePosition, + clearTransientOwnerPositions, + resolveNearestOwnerSlot, + getLaunchAnchorWorldPosition: (leadNodeId: string) => + launchAnchorPositionsRef.current.get(leadNodeId) ?? null, + getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null, + getExtraWorldBounds: () => extraWorldBoundsRef.current, + }), + [ + updateData, + tick, + setNodePosition, + clearNodePosition, + clearTransientOwnerPositions, + resolveNearestOwnerSlot, + ] + ); } function applySnapshotToNodes( diff --git a/packages/agent-graph/src/index.ts b/packages/agent-graph/src/index.ts index 6eda9d4d..cd74c996 100644 --- a/packages/agent-graph/src/index.ts +++ b/packages/agent-graph/src/index.ts @@ -10,6 +10,8 @@ export { GraphView } from './ui/GraphView'; export type { GraphViewProps } from './ui/GraphView'; export { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from './layout/activityLane'; +export { getTransientHandoffCardAlpha } from './ui/transientHandoffs'; +export type { TransientHandoffCard } from './ui/transientHandoffs'; // ─── Port Interfaces (for adapters in host project) ───────────────────────── export type { GraphDataPort } from './ports/GraphDataPort'; diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index 070323ea..a0c31ab9 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -173,12 +173,16 @@ export function buildStableSlotLayoutSnapshot({ ); const leadActivityRect = leadSlotFrame.activityColumnRect; const launchHudRect = createRect(leadCoreRect.right, leadCoreRect.top, 0, 0); - const leadCentralReservedBlock = leadSlotFrame.bounds; + const leadCentralReservedBlock = buildLeadCentralReservedBlock({ + leadCoreRect, + leadSlotFrame, + }); const ownerFootprints = computeOwnerFootprints(nodes, layout); const unassignedTaskRect = buildUnassignedTaskRect(nodes, leadCentralReservedBlock); const centralCollisionRects = buildCentralCollisionRects({ - leadCentralReservedBlock, + leadCoreRect, + leadSlotFrame, unassignedTaskRect, }); const runtimeCentralExclusion = padRect( @@ -222,16 +226,34 @@ export function buildStableSlotLayoutSnapshot({ } function buildCentralCollisionRects(args: { - leadCentralReservedBlock: StableRect; + leadCoreRect: StableRect; + leadSlotFrame: SlotFrame; unassignedTaskRect: StableRect | null; }): StableRect[] { - const rects = [args.leadCentralReservedBlock]; + const rects = [ + args.leadCoreRect, + args.leadSlotFrame.processBandRect, + args.leadSlotFrame.activityColumnRect, + args.leadSlotFrame.kanbanBandRect, + ]; if (args.unassignedTaskRect) { rects.push(args.unassignedTaskRect); } return rects; } +function buildLeadCentralReservedBlock(args: { + leadCoreRect: StableRect; + leadSlotFrame: SlotFrame; +}): StableRect { + return unionRects([ + args.leadCoreRect, + args.leadSlotFrame.processBandRect, + args.leadSlotFrame.activityColumnRect, + args.leadSlotFrame.kanbanBandRect, + ]); +} + function padCentralCollisionRects( rects: readonly StableRect[], padding: number @@ -648,6 +670,12 @@ function validateLeadSnapshotRects( if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) { return { valid: false, reason: 'leadActivityRect must fit inside leadCentralReservedBlock' }; } + if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.processBandRect)) { + return { valid: false, reason: 'lead processBandRect must fit inside leadCentralReservedBlock' }; + } + if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.kanbanBandRect)) { + return { valid: false, reason: 'lead kanbanBandRect must fit inside leadCentralReservedBlock' }; + } if (snapshot.leadActivityRect.left !== snapshot.leadSlotFrame.activityColumnRect.left) { return { valid: false, @@ -660,9 +688,6 @@ function validateLeadSnapshotRects( reason: 'leadActivityRect must mirror leadSlotFrame.activityColumnRect', }; } - if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.bounds)) { - return { valid: false, reason: 'leadSlotFrame must fit inside leadCentralReservedBlock' }; - } if (!rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.leadCentralReservedBlock)) { return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' }; } diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx index 516e3a5f..c4f13588 100644 --- a/packages/agent-graph/src/ui/GraphCanvas.tsx +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -34,6 +34,7 @@ import { import { createTransientHandoffState, selectRenderableTransientHandoffCards, + type TransientHandoffCard, updateTransientHandoffState, } from './transientHandoffs'; import type { CameraTransform } from '../hooks/useGraphCamera'; @@ -70,6 +71,14 @@ export interface GraphCanvasHandle { draw: (state: GraphDrawState) => void; /** Get the canvas element for coordinate transforms */ getCanvas: () => HTMLCanvasElement | null; + /** Read current transient handoff cards for DOM HUD rendering */ + getTransientHandoffSnapshot: (options?: { + focusNodeIds?: ReadonlySet | null; + focusEdgeIds?: ReadonlySet | null; + }) => { + cards: TransientHandoffCard[]; + time: number; + }; } export interface GraphCanvasProps { @@ -163,6 +172,7 @@ export const GraphCanvas = forwardRef(funct const activeParticleEdgesCache = useRef(new Set()); const handoffStateRef = useRef(createTransientHandoffState()); const lastTeamNameRef = useRef(null); + const lastDrawTimeRef = useRef(0); // Imperative draw function — called from RAF, NOT from React render useImperativeHandle( @@ -181,6 +191,7 @@ export const GraphCanvas = forwardRef(funct if (w === 0 || h === 0) return; try { + lastDrawTimeRef.current = state.time; if (lastTeamNameRef.current !== state.teamName) { handoffStateRef.current = createTransientHandoffState(); lastTeamNameRef.current = state.teamName; @@ -309,9 +320,7 @@ export const GraphCanvas = forwardRef(funct focusNodeIds: state.focusNodeIds, focusEdgeIds: prioritizedEdgeIds ?? state.focusEdgeIds, } - ).filter( - (card) => card.destinationKind !== 'lead' && card.destinationKind !== 'member' - ); + ).filter((card) => card.anchorKind !== 'lead' && card.anchorKind !== 'member'); drawParticles(ctx, renderableParticles, edgeMap, nodeMap, state.time, prioritizedEdgeIds); // 2c. Visible nodes only (back to front: process → task → member/lead) @@ -419,6 +428,10 @@ export const GraphCanvas = forwardRef(funct } }, getCanvas: () => canvasRef.current, + getTransientHandoffSnapshot: (options) => ({ + cards: selectRenderableTransientHandoffCards(handoffStateRef.current, options), + time: lastDrawTimeRef.current, + }), }), [showHexGrid, showStarField, bloomIntensity] ); diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index c20ed598..4f26e365 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -22,6 +22,7 @@ import { GraphControls, type GraphFilterState } from './GraphControls'; import { GraphOverlay } from './GraphOverlay'; import { GraphEdgeOverlay } from './GraphEdgeOverlay'; import { buildFocusState } from './buildFocusState'; +import type { TransientHandoffCard } from './transientHandoffs'; import { useGraphSimulation } from '../hooks/useGraphSimulation'; import { useGraphCamera } from '../hooks/useGraphCamera'; import { useGraphInteraction } from '../hooks/useGraphInteraction'; @@ -31,7 +32,7 @@ import { findNodeAt, getEdgeMidpoint, } from '../canvas/hit-detection'; -import { ANIM_SPEED } from '../constants/canvas-constants'; +import { ANIM, ANIM_SPEED } from '../constants/canvas-constants'; import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor'; export interface GraphViewProps { @@ -73,11 +74,16 @@ export interface GraphViewProps { leadNodeId: string, ) => { x: number; y: number; scale: number; visible: boolean } | null; getActivityWorldRect: (ownerNodeId: string) => StableRect | null; + getTransientHandoffSnapshot: (options?: { + focusNodeIds?: ReadonlySet | null; + focusEdgeIds?: ReadonlySet | null; + }) => { cards: TransientHandoffCard[]; time: number }; getCameraZoom: () => number; worldToScreen: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition: (nodeId: string) => { x: number; y: number } | null; getViewportSize: () => { width: number; height: number }; focusNodeIds: ReadonlySet | null; + focusEdgeIds: ReadonlySet | null; }) => React.ReactNode; } @@ -142,13 +148,6 @@ export function GraphView({ // ─── Hooks ────────────────────────────────────────────────────────────── const simulation = useGraphSimulation(); const camera = useGraphCamera(); - - // Stable refs for RAF loop (avoid recreating animate on hook identity change) - const simulationRef = useRef(simulation); - simulationRef.current = simulation; - const cameraRef = useRef(camera); - cameraRef.current = camera; - const interaction = useGraphInteraction( useCallback( (nodeId: string, x: number, y: number) => { @@ -158,6 +157,20 @@ export function GraphView({ ) ); + // Stable refs for RAF loop (avoid recreating animate on hook identity change) + const simulationRef = useRef(simulation); + simulationRef.current = simulation; + const cameraRef = useRef(camera); + cameraRef.current = camera; + const interactionRef = useRef(interaction); + interactionRef.current = interaction; + const processActivePointerMoveRef = useRef<((clientX: number, clientY: number) => boolean) | null>( + null + ); + const completePointerInteractionRef = useRef<((clientX: number, clientY: number) => void) | null>( + null + ); + const getVisibleNodes = useCallback( (nodes: GraphNode[]): GraphNode[] => nodes.filter((node) => { @@ -250,6 +263,17 @@ export function GraphView({ (ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId), [] ); + const getTransientHandoffSnapshot = useCallback( + (options?: { + focusNodeIds?: ReadonlySet | null; + focusEdgeIds?: ReadonlySet | null; + }) => + canvasHandle.current?.getTransientHandoffSnapshot(options) ?? { + cards: [], + time: 0, + }, + [] + ); const getNodeWorldPosition = useCallback((nodeId: string) => { const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId); if (node?.x == null || node?.y == null) { @@ -416,16 +440,16 @@ export function GraphView({ }, []); useLayoutEffect(() => { - if (!isSurfaceActive) { + if (isSurfaceActive) { return; } - interaction.handleMouseUp(); - simulation.clearTransientOwnerPositions(); + interactionRef.current.handleMouseUp(); + simulationRef.current.clearTransientOwnerPositions(); dragPreviewRef.current = null; isPanningRef.current = false; edgeMouseDownRef.current = null; setInteractionGuards(false); - }, [interaction, isSurfaceActive, simulation]); + }, [isSurfaceActive, setInteractionGuards]); const handleWheel = useCallback( (e: WheelEvent) => { @@ -437,7 +461,13 @@ export function GraphView({ // ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─ const isPanningRef = useRef(false); - const edgeMouseDownRef = useRef<{ id: string; x: number; y: number } | null>(null); + const edgeMouseDownRef = useRef<{ + id: string; + worldX: number; + worldY: number; + clientX: number; + clientY: number; + } | null>(null); const handleMouseDown = useCallback( (e: React.MouseEvent) => { @@ -474,7 +504,13 @@ export function GraphView({ if (hitEdge) { markUserInteracted(); isPanningRef.current = false; - edgeMouseDownRef.current = { id: hitEdge, x: world.x, y: world.y }; + edgeMouseDownRef.current = { + id: hitEdge, + worldX: world.x, + worldY: world.y, + clientX: e.clientX, + clientY: e.clientY, + }; hoveredEdgeIdRef.current = hitEdge; } else { // Hit empty space → pan @@ -501,11 +537,6 @@ export function GraphView({ const processActivePointerMove = useCallback( (clientX: number, clientY: number) => { - if (!activePrimaryInteractionRef.current) { - dragPreviewRef.current = null; - return false; - } - if (isPanningRef.current) { if (typeof document !== 'undefined') { document.getSelection()?.removeAllRanges(); @@ -514,6 +545,36 @@ export function GraphView({ return true; } + const edgeMouseDown = edgeMouseDownRef.current; + if ( + edgeMouseDown && + !interaction.dragNodeId.current && + !interaction.isDragging.current + ) { + const dx = clientX - edgeMouseDown.clientX; + const dy = clientY - edgeMouseDown.clientY; + if (dx * dx + dy * dy > ANIM.dragThresholdPx * ANIM.dragThresholdPx) { + if (typeof document !== 'undefined') { + document.getSelection()?.removeAllRanges(); + } + hoveredEdgeIdRef.current = null; + edgeMouseDownRef.current = null; + isPanningRef.current = true; + camera.handlePanStart(edgeMouseDown.clientX, edgeMouseDown.clientY); + camera.handlePanMove(clientX, clientY); + return true; + } + } + + if ( + !activePrimaryInteractionRef.current && + !interaction.dragNodeId.current && + !interaction.isDragging.current + ) { + dragPreviewRef.current = null; + return false; + } + const canvas = canvasHandle.current?.getCanvas(); if (!canvas) { dragPreviewRef.current = null; @@ -610,8 +671,8 @@ export function GraphView({ if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) { const rect = canvas.getBoundingClientRect(); const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top); - const dx = world.x - edgeMouseDownRef.current.x; - const dy = world.y - edgeMouseDownRef.current.y; + const dx = world.x - edgeMouseDownRef.current.worldX; + const dy = world.y - edgeMouseDownRef.current.worldY; if (dx * dx + dy * dy <= 25) { clickedEdgeId = edgeMouseDownRef.current.id; } @@ -639,6 +700,8 @@ export function GraphView({ }, [camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation] ); + processActivePointerMoveRef.current = processActivePointerMove; + completePointerInteractionRef.current = completePointerInteraction; const handleMouseMove = useCallback( (e: React.MouseEvent) => { @@ -694,36 +757,40 @@ export function GraphView({ if ( !activePrimaryInteractionRef.current && !isPanningRef.current && - !interaction.dragNodeId.current && - !interaction.isDragging.current && + !interactionRef.current.dragNodeId.current && + !interactionRef.current.isDragging.current && !edgeMouseDownRef.current ) { return; } event.preventDefault(); - processActivePointerMove(event.clientX, event.clientY); + processActivePointerMoveRef.current?.(event.clientX, event.clientY); }; const handleWindowMouseUp = (event: MouseEvent): void => { if ( !activePrimaryInteractionRef.current && !isPanningRef.current && - !interaction.dragNodeId.current && - !interaction.isDragging.current && + !interactionRef.current.dragNodeId.current && + !interactionRef.current.isDragging.current && !edgeMouseDownRef.current ) { setInteractionGuards(false); return; } - completePointerInteraction(event.clientX, event.clientY); + completePointerInteractionRef.current?.(event.clientX, event.clientY); }; const clearInteraction = (): void => { - if (!activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.isDragging.current) { + if ( + !activePrimaryInteractionRef.current && + !isPanningRef.current && + !interactionRef.current.isDragging.current + ) { return; } - interaction.handleMouseUp(); - camera.handlePanEnd(); + interactionRef.current.handleMouseUp(); + cameraRef.current.handlePanEnd(); isPanningRef.current = false; edgeMouseDownRef.current = null; dragPreviewRef.current = null; @@ -739,9 +806,14 @@ export function GraphView({ window.removeEventListener('mouseup', handleWindowMouseUp); window.removeEventListener('blur', clearInteraction); window.removeEventListener('dragstart', clearInteraction); + }; + }, [setInteractionGuards]); + + useEffect(() => { + return () => { setInteractionGuards(false); }; - }, [camera, completePointerInteraction, interaction, processActivePointerMove, setInteractionGuards]); + }, [setInteractionGuards]); const handleDoubleClick = useCallback( (e: React.MouseEvent) => { @@ -946,11 +1018,13 @@ export function GraphView({ {renderHud({ getLaunchAnchorScreenPlacement, getActivityWorldRect, + getTransientHandoffSnapshot, getCameraZoom, worldToScreen: camera.worldToScreen, getNodeWorldPosition, getViewportSize, focusNodeIds: focusState.focusNodeIds, + focusEdgeIds: focusState.focusEdgeIds, })} ) : null} diff --git a/packages/agent-graph/src/ui/transientHandoffs.ts b/packages/agent-graph/src/ui/transientHandoffs.ts index 78465e0f..54dbff98 100644 --- a/packages/agent-graph/src/ui/transientHandoffs.ts +++ b/packages/agent-graph/src/ui/transientHandoffs.ts @@ -8,12 +8,16 @@ export interface TransientHandoffCard { edgeId: string; sourceNodeId: string; destinationNodeId: string; + anchorNodeId: string; + anchorKind: GraphNode['kind']; sourceLabel: string; destinationLabel: string; destinationKind: GraphNode['kind']; kind: HandoffParticleKind; color: string; preview?: string; + relatedTaskId?: string; + relatedTaskDisplayId?: string; count: number; activatedAt: number; updatedAt: number; @@ -70,6 +74,12 @@ export function updateTransientHandoffState( const sourceNode = nodeMap.get(sourceNodeId); const destinationNode = nodeMap.get(destinationNodeId); if (!sourceNode || !destinationNode) continue; + const anchorNode = + destinationNode.kind === 'lead' || destinationNode.kind === 'member' + ? destinationNode + : sourceNode.kind === 'lead' || sourceNode.kind === 'member' + ? sourceNode + : destinationNode; const previewText = normalizePreviewText(particle.preview ?? particle.label); if (particle.kind === 'inbox_message' && isLowSignalInboxPreview(previewText)) { @@ -86,12 +96,16 @@ export function updateTransientHandoffState( edgeId: edge.id, sourceNodeId, destinationNodeId, + anchorNodeId: anchorNode.id, + anchorKind: anchorNode.kind, sourceLabel: sourceNode.label, destinationLabel: destinationNode.label, destinationKind: destinationNode.kind, kind: particle.kind, color: particle.color, preview: previewText ?? existing?.preview, + relatedTaskId: edge.sourceTaskIds?.[0] ?? edge.targetTaskIds?.[0], + relatedTaskDisplayId: buildTaskDisplayId(edge.sourceTaskIds?.[0] ?? edge.targetTaskIds?.[0]), count: nextCount, activatedAt: existing?.activatedAt ?? time, updatedAt: time, @@ -112,19 +126,19 @@ export function selectRenderableTransientHandoffCards( const focusEdgeIds = options?.focusEdgeIds ?? null; const hasFocus = (focusNodeIds?.size ?? 0) > 0 || (focusEdgeIds?.size ?? 0) > 0; - const byDestination = new Map(); + const byAnchor = new Map(); for (const card of state.cardsByKey.values()) { if (hasFocus && !isCardInFocus(card, focusNodeIds, focusEdgeIds)) continue; - const destinationCards = byDestination.get(card.destinationNodeId); - if (destinationCards) { - destinationCards.push(card); + const anchorCards = byAnchor.get(card.anchorNodeId); + if (anchorCards) { + anchorCards.push(card); } else { - byDestination.set(card.destinationNodeId, [card]); + byAnchor.set(card.anchorNodeId, [card]); } } const selected: TransientHandoffCard[] = []; - for (const cards of byDestination.values()) { + for (const cards of byAnchor.values()) { cards.sort((a, b) => b.updatedAt - a.updatedAt); selected.push(...cards.slice(0, HANDOFF_CARD.maxPerDestination)); } @@ -145,10 +159,21 @@ function isCardInFocus( return ( !!focusEdgeIds?.has(card.edgeId) || !!focusNodeIds?.has(card.sourceNodeId) || - !!focusNodeIds?.has(card.destinationNodeId) + !!focusNodeIds?.has(card.destinationNodeId) || + !!focusNodeIds?.has(card.anchorNodeId) ); } +export function getTransientHandoffCardAlpha(card: TransientHandoffCard, time: number): number { + const fadeIn = Math.min(1, (time - card.activatedAt) / HANDOFF_CARD.fadeInSeconds); + const fadeOutRemaining = card.expiresAt - time; + const fadeOut = + fadeOutRemaining <= HANDOFF_CARD.fadeOutSeconds + ? Math.max(0, fadeOutRemaining / HANDOFF_CARD.fadeOutSeconds) + : 1; + return Math.max(0, Math.min(1, fadeIn * fadeOut)); +} + function normalizePreviewText(text: string | undefined): string | undefined { if (!text) return undefined; const normalized = text @@ -161,3 +186,10 @@ function normalizePreviewText(text: string | undefined): string | undefined { function isLowSignalInboxPreview(preview: string | undefined): boolean { return preview === 'idle'; } + +function buildTaskDisplayId(taskId: string | undefined): string | undefined { + if (!taskId) { + return undefined; + } + return taskId.slice(0, 8); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66dc8138..e5b3e08f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -239,6 +239,9 @@ importers: node-pty: specifier: ^1.1.0 version: 1.1.0 + pidusage: + specifier: 4.0.1 + version: 4.0.1 react: specifier: ^19.0.0 version: 19.2.4 @@ -327,6 +330,9 @@ importers: '@types/node': specifier: ^25.0.7 version: 25.0.7 + '@types/pidusage': + specifier: 2.0.5 + version: 2.0.5 '@types/react': specifier: ^19.0.0 version: 19.2.14 @@ -451,13 +457,13 @@ importers: version: 0.11.3(magicast@0.5.2)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))) '@vueuse/nuxt': specifier: ^10.11.1 - version: 10.11.1(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3)) + version: 10.11.1(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3)) nuxt: specifier: ^3.20.2 - version: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) + version: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2) nuxt-icon: specifier: ^0.6.10 - version: 0.6.10(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3)) + version: 0.6.10(magicast@0.5.2)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3)) pinia: specifier: ^3.0.4 version: 3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) @@ -482,7 +488,7 @@ importers: version: 7.4.47 '@nuxt/eslint': specifier: ^1.12.1 - version: 1.15.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 1.15.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)) eslint: specifier: ^9.39.2 version: 9.39.2(jiti@2.6.1) @@ -494,7 +500,7 @@ importers: version: 1.98.0 vite-plugin-vuetify: specifier: ^2.1.3 - version: 2.1.3(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3) + version: 2.1.3(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3) mcp-server: dependencies: @@ -4353,6 +4359,9 @@ packages: '@types/pg@8.15.6': resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + '@types/pidusage@2.0.5': + resolution: {integrity: sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==} + '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} @@ -8591,6 +8600,10 @@ packages: engines: {node: '>=0.10'} hasBin: true + pidusage@4.0.1: + resolution: {integrity: sha512-yCH2dtLHfEBnzlHUJymR/Z1nN2ePG3m392Mv8TFlTP1B0xkpMQNHAnfkY0n2tAi6ceKO6YWhxYfZ96V4vVkh/g==} + engines: {node: '>=18'} + pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -12589,20 +12602,20 @@ snapshots: '@nuxt/devalue@2.0.2': {} - '@nuxt/devtools-kit@1.7.0(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@nuxt/devtools-kit@1.7.0(magicast@0.5.2)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))': dependencies: '@nuxt/kit': 3.21.2(magicast@0.5.2) '@nuxt/schema': 3.21.2 execa: 7.2.0 - vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0) transitivePeerDependencies: - magicast - '@nuxt/devtools-kit@3.2.4(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@nuxt/devtools-kit@3.2.4(magicast@0.5.2)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))': dependencies: '@nuxt/kit': 4.4.2(magicast@0.5.2) execa: 8.0.1 - vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0) transitivePeerDependencies: - magicast @@ -12617,9 +12630,9 @@ snapshots: pkg-types: 2.3.0 semver: 7.7.4 - '@nuxt/devtools@3.2.4(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))': + '@nuxt/devtools@3.2.4(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3))': dependencies: - '@nuxt/devtools-kit': 3.2.4(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@nuxt/devtools-kit': 3.2.4(magicast@0.5.2)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)) '@nuxt/devtools-wizard': 3.2.4 '@nuxt/kit': 4.4.2(magicast@0.5.2) '@vue/devtools-core': 8.1.0(vue@3.5.30(typescript@5.9.3)) @@ -12647,9 +12660,9 @@ snapshots: sirv: 3.0.2 structured-clone-es: 2.0.0 tinyglobby: 0.2.15 - vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - vite-plugin-inspect: 11.3.3(@nuxt/kit@4.4.2(magicast@0.5.2))(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) - vite-plugin-vue-tracer: 1.3.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3)) + vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0) + vite-plugin-inspect: 11.3.3(@nuxt/kit@4.4.2(magicast@0.5.2))(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)) + vite-plugin-vue-tracer: 1.3.0(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3)) which: 6.0.1 ws: 8.20.0 transitivePeerDependencies: @@ -12698,10 +12711,10 @@ snapshots: - supports-color - typescript - '@nuxt/eslint@1.15.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@nuxt/eslint@1.15.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))': dependencies: '@eslint/config-inspector': 1.5.0(eslint@9.39.2(jiti@2.6.1)) - '@nuxt/devtools-kit': 3.2.4(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@nuxt/devtools-kit': 3.2.4(magicast@0.5.2)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)) '@nuxt/eslint-config': 1.15.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@nuxt/eslint-plugin': 1.15.2(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@nuxt/kit': 4.4.2(magicast@0.5.2) @@ -12777,7 +12790,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/nitro-server@3.21.2(db0@0.3.4)(encoding@0.1.13)(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(typescript@5.9.3)': + '@nuxt/nitro-server@3.21.2(db0@0.3.4)(encoding@0.1.13)(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2))(typescript@5.9.3)': dependencies: '@nuxt/devalue': 2.0.2 '@nuxt/kit': 3.21.2(magicast@0.5.2) @@ -12795,7 +12808,7 @@ snapshots: klona: 2.0.6 mocked-exports: 0.1.1 nitropack: 2.13.2(encoding@0.1.13)(idb-keyval@6.2.2) - nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) + nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2) ohash: 2.0.11 pathe: 2.0.3 pkg-types: 2.3.0 @@ -12860,7 +12873,7 @@ snapshots: rc9: 3.0.0 std-env: 3.10.0 - '@nuxt/vite-builder@3.21.2(@types/node@25.0.7)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))(yaml@2.8.2)': + '@nuxt/vite-builder@3.21.2(@types/node@25.0.7)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))(yaml@2.8.2)': dependencies: '@nuxt/kit': 3.21.2(magicast@0.5.2) '@rollup/plugin-replace': 6.0.3(rollup@4.60.0) @@ -12879,7 +12892,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.2 mocked-exports: 0.1.1 - nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) + nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2) nypm: 0.6.5 ohash: 2.0.11 pathe: 2.0.3 @@ -15002,6 +15015,8 @@ snapshots: pg-protocol: 1.13.0 pg-types: 2.2.0 + '@types/pidusage@2.0.5': {} + '@types/plist@3.0.5': dependencies: '@types/node': 25.0.7 @@ -15595,13 +15610,13 @@ snapshots: '@vueuse/metadata@10.11.1': {} - '@vueuse/nuxt@10.11.1(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))': + '@vueuse/nuxt@10.11.1(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))': dependencies: '@nuxt/kit': 3.21.2(magicast@0.5.2) '@vueuse/core': 10.11.1(vue@3.5.30(typescript@5.9.3)) '@vueuse/metadata': 10.11.1 local-pkg: 0.5.1 - nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) + nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2) vue-demi: 0.14.10(vue@3.5.30(typescript@5.9.3)) transitivePeerDependencies: - '@vue/composition-api' @@ -20143,27 +20158,27 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt-icon@0.6.10(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3)): + nuxt-icon@0.6.10(magicast@0.5.2)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3)): dependencies: '@iconify/collections': 1.0.665 '@iconify/vue': 4.3.0(vue@3.5.30(typescript@5.9.3)) - '@nuxt/devtools-kit': 1.7.0(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@nuxt/devtools-kit': 1.7.0(magicast@0.5.2)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)) '@nuxt/kit': 3.21.2(magicast@0.5.2) transitivePeerDependencies: - magicast - vite - vue - nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2): + nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2): dependencies: '@dxup/nuxt': 0.4.0(magicast@0.5.2)(typescript@5.9.3) '@nuxt/cli': 3.34.0(@nuxt/schema@3.21.2)(cac@6.7.14)(magicast@0.5.2) - '@nuxt/devtools': 3.2.4(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3)) + '@nuxt/devtools': 3.2.4(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3)) '@nuxt/kit': 3.21.2(magicast@0.5.2) - '@nuxt/nitro-server': 3.21.2(db0@0.3.4)(encoding@0.1.13)(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(typescript@5.9.3) + '@nuxt/nitro-server': 3.21.2(db0@0.3.4)(encoding@0.1.13)(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2))(typescript@5.9.3) '@nuxt/schema': 3.21.2 '@nuxt/telemetry': 2.7.0(@nuxt/kit@3.21.2(magicast@0.5.2)) - '@nuxt/vite-builder': 3.21.2(@types/node@25.0.7)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))(yaml@2.8.2) + '@nuxt/vite-builder': 3.21.2(@types/node@25.0.7)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(yaml@2.8.2))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))(yaml@2.8.2) '@unhead/vue': 2.1.12(vue@3.5.30(typescript@5.9.3)) '@vue/shared': 3.5.30 c12: 3.3.3(magicast@0.5.2) @@ -20647,6 +20662,10 @@ snapshots: pidtree@0.6.0: {} + pidusage@4.0.1: + dependencies: + safe-buffer: 5.2.1 + pify@2.3.0: {} pinia@3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)): @@ -22710,15 +22729,15 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-dev-rpc@1.1.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + vite-dev-rpc@1.1.0(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)): dependencies: birpc: 2.9.0 - vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - vite-hot-client: 2.1.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0) + vite-hot-client: 2.1.0(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)) - vite-hot-client@2.1.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + vite-hot-client@2.1.0(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)): dependencies: - vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0) vite-node@3.2.4(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0): dependencies: @@ -22792,7 +22811,7 @@ snapshots: optionator: 0.9.4 typescript: 5.9.3 - vite-plugin-inspect@11.3.3(@nuxt/kit@4.4.2(magicast@0.5.2))(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + vite-plugin-inspect@11.3.3(@nuxt/kit@4.4.2(magicast@0.5.2))(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)): dependencies: ansis: 4.2.0 debug: 4.4.3 @@ -22802,29 +22821,29 @@ snapshots: perfect-debounce: 2.1.0 sirv: 3.0.2 unplugin-utils: 0.3.1 - vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - vite-dev-rpc: 1.1.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0) + vite-dev-rpc: 1.1.0(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)) optionalDependencies: '@nuxt/kit': 4.4.2(magicast@0.5.2) transitivePeerDependencies: - supports-color - vite-plugin-vue-tracer@1.3.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3)): + vite-plugin-vue-tracer@1.3.0(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3)): dependencies: estree-walker: 3.0.3 exsolve: 1.0.8 magic-string: 0.30.21 pathe: 2.0.3 source-map-js: 1.2.1 - vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0) vue: 3.5.30(typescript@5.9.3) - vite-plugin-vuetify@2.1.3(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3): + vite-plugin-vuetify@2.1.3(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3): dependencies: '@vuetify/loader-shared': 2.1.2(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3) debug: 4.4.3 upath: 2.0.1 - vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0) vue: 3.5.30(typescript@5.9.3) vuetify: 3.12.3(typescript@5.9.3)(vite-plugin-vuetify@2.1.3)(vue@3.5.30(typescript@5.9.3)) transitivePeerDependencies: @@ -23023,7 +23042,7 @@ snapshots: vue: 3.5.30(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 - vite-plugin-vuetify: 2.1.3(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3) + vite-plugin-vuetify: 2.1.3(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3) w3c-keyname@2.2.8: {} diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 5e9322b8..69b58351 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -1,5 +1,5 @@ /** - * TeamGraphAdapter — transforms Zustand TeamData → GraphDataPort. + * TeamGraphAdapter — transforms store-backed team graph input → GraphDataPort. * * This adapter owns the graph projection from team runtime state into the * reusable package port model. Renderer hooks may still read store state, but @@ -57,12 +57,18 @@ import type { LeadActivityState, MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, - TeamData, + ResolvedTeamMember, TeamProcess, TeamProvisioningProgress, + TeamViewSnapshot, } from '@shared/types/team'; import type { LeadContextUsage } from '@shared/types/team'; +export interface TeamGraphData extends TeamViewSnapshot { + members: ResolvedTeamMember[]; + messageFeed: InboxMessage[]; +} + export class TeamGraphAdapter { // ─── ES #private fields ────────────────────────────────────────────────── #lastTeamName = ''; @@ -89,7 +95,7 @@ export class TeamGraphAdapter { * Adapt team data into a GraphDataPort snapshot. */ adapt( - teamData: TeamData | null, + teamData: TeamGraphData | null, teamName: string, spawnStatuses?: Record, leadActivity?: LeadActivityState, @@ -175,13 +181,22 @@ export class TeamGraphAdapter { isTeamProvisioning, isLaunchSettling ); - this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState, memberNodeIdByAlias); + this.#buildTaskNodes( + nodes, + edges, + teamData, + teamName, + commentReadState, + memberNodeIdByAlias, + leadId, + leadName + ); this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias); this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName); this.#buildMessageParticles( particles, nodes, - teamData.messages, + teamData.messageFeed, teamName, leadId, leadName, @@ -224,11 +239,11 @@ export class TeamGraphAdapter { // ─── Private: node builders ────────────────────────────────────────────── - static #getLeadMemberName(data: TeamData, teamName: string): string { + static #getLeadMemberName(data: TeamGraphData, teamName: string): string { return getGraphLeadMemberName(data, teamName); } - static #buildMemberNodeIdByAlias(data: TeamData, teamName: string): Map { + static #buildMemberNodeIdByAlias(data: TeamGraphData, teamName: string): Map { return buildGraphMemberNodeIdAliasMap( teamName, data.members.filter((member) => !isLeadMember(member)) @@ -236,7 +251,7 @@ export class TeamGraphAdapter { } static #buildLayoutPort( - data: TeamData, + data: TeamGraphData, teamName: string, slotAssignments?: Record ): GraphLayoutPort { @@ -254,7 +269,7 @@ export class TeamGraphAdapter { ); const assignedStableOwnerIds = new Set(Object.keys(slotAssignments ?? {})); - const pushMember = (member: TeamData['members'][number] | undefined): void => { + const pushMember = (member: TeamGraphData['members'][number] | undefined): void => { if (!member) { return; } @@ -306,7 +321,7 @@ export class TeamGraphAdapter { } static #collectDuplicateStableOwnerIds( - members: readonly TeamData['members'][number][] + members: readonly TeamGraphData['members'][number][] ): string[] { const counts = new Map(); for (const member of members) { @@ -328,9 +343,9 @@ export class TeamGraphAdapter { } static #getRuntimeLabel( - providerId: TeamData['members'][number]['providerId'], - model: TeamData['members'][number]['model'], - effort: TeamData['members'][number]['effort'] + providerId: ResolvedTeamMember['providerId'], + model: ResolvedTeamMember['model'], + effort: ResolvedTeamMember['effort'] ): string | undefined { return formatTeamRuntimeSummary(providerId, model, effort); } @@ -351,7 +366,7 @@ export class TeamGraphAdapter { #buildLeadNode( nodes: GraphNode[], leadId: string, - data: TeamData, + data: TeamGraphData, teamName: string, leadName: string, pendingApprovalAgents?: Set, @@ -362,7 +377,7 @@ export class TeamGraphAdapter { toolHistory?: Record, isTeamProvisioning = false ): void { - const percent = leadContext?.percent; + const percent = leadContext?.contextUsedPercent; const leadMember = data.members.find((member) => member.name === leadName); const activeTool = TeamGraphAdapter.#selectVisibleTool( activeTools?.[leadName], @@ -447,7 +462,7 @@ export class TeamGraphAdapter { nodes: GraphNode[], edges: GraphEdge[], leadId: string, - data: TeamData, + data: TeamGraphData, teamName: string, memberNodeIdByAlias: ReadonlyMap, spawnStatuses?: Record, @@ -551,12 +566,14 @@ export class TeamGraphAdapter { #buildTaskNodes( nodes: GraphNode[], edges: GraphEdge[], - data: TeamData, + data: TeamGraphData, teamName: string, commentReadState?: Record, - memberNodeIdByAlias?: ReadonlyMap + memberNodeIdByAlias?: ReadonlyMap, + leadId?: string, + leadName?: string ): void { - const taskStateById = new Map>(); + const taskStateById = new Map>(); const taskDisplayIds = new Map(); const memberColorByName = new Map(); @@ -575,7 +592,12 @@ export class TeamGraphAdapter { for (const task of data.tasks) { if (task.status === 'deleted') continue; const taskId = `task:${teamName}:${task.id}`; - const ownerMemberId = task.owner ? (memberNodeIdByAlias?.get(task.owner) ?? null) : null; + const ownerMemberId = + leadId && memberNodeIdByAlias + ? TeamGraphAdapter.#resolveTaskOwnerId(task.owner, leadId, leadName, memberNodeIdByAlias) + : task.owner + ? (memberNodeIdByAlias?.get(task.owner) ?? null) + : null; const kanbanTaskState = data.kanbanState.tasks[task.id]; const reviewerName = resolveTaskReviewer(task, kanbanTaskState); const isReviewCycle = isTaskInReviewCycle(task); @@ -736,7 +758,7 @@ export class TeamGraphAdapter { #buildProcessNodes( nodes: GraphNode[], edges: GraphEdge[], - data: TeamData, + data: TeamGraphData, teamName: string, memberNodeIdByAlias?: ReadonlyMap ): void { @@ -814,7 +836,7 @@ export class TeamGraphAdapter { #attachActivityFeeds( nodes: GraphNode[], - data: TeamData, + data: TeamGraphData, teamName: string, leadId: string, leadName: string @@ -831,7 +853,10 @@ export class TeamGraphAdapter { } const entriesByOwnerNodeId = buildInlineActivityEntries({ - data, + data: { + ...data, + messages: data.messageFeed, + }, teamName, leadId, leadName, @@ -992,7 +1017,7 @@ export class TeamGraphAdapter { #buildCommentParticles( particles: GraphParticle[], - data: TeamData, + data: TeamGraphData, teamName: string, leadId: string, leadName: string, @@ -1085,8 +1110,8 @@ export class TeamGraphAdapter { } static #buildMemberException( - runtimeAdvisory: TeamData['members'][number]['runtimeAdvisory'], - providerId: TeamData['members'][number]['providerId'], + runtimeAdvisory: ResolvedTeamMember['runtimeAdvisory'], + providerId: ResolvedTeamMember['providerId'], spawn: MemberSpawnStatusEntry | undefined, pendingApproval: boolean ): Pick | undefined { @@ -1228,6 +1253,25 @@ export class TeamGraphAdapter { return memberNodeIdByAlias.get(name) ?? leadId; } + static #resolveTaskOwnerId( + ownerName: string | null | undefined, + leadId: string, + leadName: string | undefined, + memberNodeIdByAlias: ReadonlyMap + ): string | null { + if (!ownerName?.trim()) { + return null; + } + const normalized = ownerName.trim().toLowerCase(); + if (normalized === 'user' || normalized === 'team-lead') { + return leadId; + } + if (normalized === leadName?.trim().toLowerCase()) { + return leadId; + } + return memberNodeIdByAlias.get(ownerName) ?? null; + } + /** Extract external team name from cross-team "from" field like "team-b.alice" */ static #extractExternalTeamName(from: string): string | null { const dotIdx = from.indexOf('.'); diff --git a/src/features/agent-graph/renderer/hooks/useGraphActivityContext.ts b/src/features/agent-graph/renderer/hooks/useGraphActivityContext.ts index ae50b51d..84cc991c 100644 --- a/src/features/agent-graph/renderer/hooks/useGraphActivityContext.ts +++ b/src/features/agent-graph/renderer/hooks/useGraphActivityContext.ts @@ -1,17 +1,34 @@ import { useStore } from '@renderer/store'; -import { selectTeamDataForName } from '@renderer/store/slices/teamSlice'; +import { + selectResolvedMembersForTeamName, + selectTeamDataForName, + selectTeamMessages, +} from '@renderer/store/slices/teamSlice'; import { useShallow } from 'zustand/react/shallow'; -import type { TeamData, TeamSummary } from '@shared/types/team'; +import type { TeamGraphData } from '../adapters/TeamGraphAdapter'; +import type { TeamSummary } from '@shared/types/team'; export function useGraphActivityContext(teamName: string): { - teamData: TeamData | null; + teamData: TeamGraphData | null; teams: TeamSummary[]; } { return useStore( - useShallow((state) => ({ - teamData: selectTeamDataForName(state, teamName), - teams: state.teams, - })) + useShallow((state) => { + const snapshot = selectTeamDataForName(state, teamName); + const members = selectResolvedMembersForTeamName(state, teamName); + const messages = selectTeamMessages(state, teamName); + + return { + teamData: snapshot + ? { + ...snapshot, + members, + messageFeed: messages, + } + : null, + teams: state.teams, + }; + }) ); } diff --git a/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.tsx b/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.tsx index 18b4e414..3daa41d8 100644 --- a/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.tsx +++ b/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.tsx @@ -3,7 +3,11 @@ import { useCallback, useMemo, useState } from 'react'; import { api } from '@renderer/api'; import { CreateTaskDialog } from '@renderer/components/team/dialogs/CreateTaskDialog'; import { useStore } from '@renderer/store'; -import { isTeamProvisioningActive, selectTeamDataForName } from '@renderer/store/slices/teamSlice'; +import { + isTeamProvisioningActive, + selectResolvedMembersForTeamName, + selectTeamDataForName, +} from '@renderer/store/slices/teamSlice'; import { useShallow } from 'zustand/react/shallow'; import type { TaskRef } from '@shared/types'; @@ -25,19 +29,17 @@ export function useGraphCreateTaskDialog(teamName: string): UseGraphCreateTaskDi }); const [submitting, setSubmitting] = useState(false); - const { teamData, createTeamTask, isTeamProvisioning } = useStore( + const { teamData, activeMembers, createTeamTask, isTeamProvisioning } = useStore( useShallow((state) => ({ teamData: selectTeamDataForName(state, teamName), + activeMembers: selectResolvedMembersForTeamName(state, teamName).filter( + (member) => !member.removedAt + ), createTeamTask: state.createTeamTask, isTeamProvisioning: isTeamProvisioningActive(state, teamName), })) ); - const activeMembers = useMemo( - () => (teamData?.members ?? []).filter((member) => !member.removedAt), - [teamData?.members] - ); - const openCreateTaskDialog = useCallback((owner = ''): void => { setDialogState({ open: true, diff --git a/src/features/agent-graph/renderer/hooks/useGraphMemberPopoverContext.ts b/src/features/agent-graph/renderer/hooks/useGraphMemberPopoverContext.ts index 6ac0fdad..92dcf194 100644 --- a/src/features/agent-graph/renderer/hooks/useGraphMemberPopoverContext.ts +++ b/src/features/agent-graph/renderer/hooks/useGraphMemberPopoverContext.ts @@ -1,19 +1,34 @@ import { useStore } from '@renderer/store'; import { getCurrentProvisioningProgressForTeam, + selectResolvedMembersForTeamName, selectTeamDataForName, } from '@renderer/store/slices/teamSlice'; import { useShallow } from 'zustand/react/shallow'; +import type { TeamGraphData } from '../adapters/TeamGraphAdapter'; + export function useGraphMemberPopoverContext(teamName: string, memberName: string) { return useStore( - useShallow((state) => ({ - teamData: teamName ? selectTeamDataForName(state, teamName) : null, - spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined, - leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined, - progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null, - memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined, - memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined, - })) + useShallow((state) => { + const snapshot = teamName ? selectTeamDataForName(state, teamName) : null; + const teamMembers = teamName ? selectResolvedMembersForTeamName(state, teamName) : []; + + return { + teamData: snapshot + ? { + ...snapshot, + members: teamMembers, + messageFeed: [], + } + : null, + teamMembers, + spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined, + leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined, + progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null, + memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined, + memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined, + }; + }) ); } diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts index 42545bb3..2506d5a0 100644 --- a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts @@ -10,20 +10,25 @@ import { useStore } from '@renderer/store'; import { getCurrentProvisioningProgressForTeam, isTeamGraphSlotPersistenceDisabled, + selectResolvedMembersForTeamName, selectTeamDataForName, + selectTeamMessages, } from '@renderer/store/slices/teamSlice'; import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout'; import { useShallow } from 'zustand/react/shallow'; import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter'; +import type { TeamGraphData } from '../adapters/TeamGraphAdapter'; import type { GraphDataPort } from '@claude-teams/agent-graph'; export function useTeamGraphAdapter(teamName: string): GraphDataPort { const adapterRef = useRef(TeamGraphAdapter.create()); const { - teamData, + teamSnapshot, + members, + messages, spawnStatuses, leadActivity, leadContext, @@ -38,7 +43,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { ensureTeamGraphSlotAssignments, } = useStore( useShallow((s) => ({ - teamData: selectTeamDataForName(s, teamName), + teamSnapshot: selectTeamDataForName(s, teamName), + members: selectResolvedMembersForTeamName(s, teamName), + messages: selectTeamMessages(s, teamName), spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined, leadActivity: teamName ? s.leadActivityByTeam[teamName] : undefined, leadContext: teamName ? s.leadContextByTeam[teamName] : undefined, @@ -64,6 +71,17 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { return agents; }, [pendingApprovals, teamName]); + const teamData = useMemo(() => { + if (!teamSnapshot) { + return null; + } + return { + ...teamSnapshot, + members, + messageFeed: messages, + }; + }, [members, messages, teamSnapshot]); + const commentReadState = useSyncExternalStore(subscribe, getSnapshot); const effectiveSlotAssignments = useMemo(() => { @@ -97,9 +115,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { const currentAssignment = slotAssignments[stableOwnerId]; const defaultAssignment = defaultSeed.assignments[stableOwnerId]; return ( - currentAssignment && - defaultAssignment && - currentAssignment.ringIndex === defaultAssignment.ringIndex && + currentAssignment?.ringIndex === defaultAssignment?.ringIndex && currentAssignment.sectorIndex === defaultAssignment.sectorIndex ); }); diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphSurfaceActions.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphSurfaceActions.ts index ed30a998..b7565a16 100644 --- a/src/features/agent-graph/renderer/hooks/useTeamGraphSurfaceActions.ts +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphSurfaceActions.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react'; import { useStore } from '@renderer/store'; +import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice'; import { parseGraphMemberNodeId } from '../../core/domain/graphOwnerIdentity'; @@ -8,6 +9,7 @@ import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph'; export function useTeamGraphSurfaceActions(teamName: string): { openTeamPage: () => void; + resetOwnerSlotAssignmentsToDefaults: () => void; commitOwnerSlotDrop: (payload: { nodeId: string; assignment: GraphOwnerSlotAssignment; @@ -19,6 +21,13 @@ export function useTeamGraphSurfaceActions(teamName: string): { useStore.getState().openTeamTab(teamName); }, [teamName]); + const resetOwnerSlotAssignmentsToDefaults = useCallback(() => { + if (!isTeamGraphSlotPersistenceDisabled()) { + return; + } + useStore.getState().resetTeamGraphSlotAssignmentsToDefaults(teamName); + }, [teamName]); + const commitOwnerSlotDrop = useCallback( (payload: { nodeId: string; @@ -51,6 +60,7 @@ export function useTeamGraphSurfaceActions(teamName: string): { return { openTeamPage, + resetOwnerSlotAssignmentsToDefaults, commitOwnerSlotDrop, }; } diff --git a/src/features/agent-graph/renderer/index.ts b/src/features/agent-graph/renderer/index.ts index 91f45840..ba6320ac 100644 --- a/src/features/agent-graph/renderer/index.ts +++ b/src/features/agent-graph/renderer/index.ts @@ -5,6 +5,7 @@ * into ui/, hooks/, or core/ directly. */ +export type { InlineActivityEntry } from '../core/domain/buildInlineActivityEntries'; export { buildInlineActivityEntries } from '../core/domain/buildInlineActivityEntries'; export { buildGraphMemberNodeIdForMember } from '../core/domain/graphOwnerIdentity'; export { TeamGraphAdapter } from './adapters/TeamGraphAdapter'; diff --git a/src/features/agent-graph/renderer/ui/GraphActivityCard.tsx b/src/features/agent-graph/renderer/ui/GraphActivityCard.tsx new file mode 100644 index 00000000..363cc711 --- /dev/null +++ b/src/features/agent-graph/renderer/ui/GraphActivityCard.tsx @@ -0,0 +1,96 @@ +import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; +import { + type MessageContext, + resolveMessageRenderProps, +} from '@renderer/components/team/activity/activityMessageContext'; + +import type { + MemberActivityFilter, + MemberDetailTab, +} from '@renderer/components/team/members/memberDetailTypes'; +import type { InboxMessage } from '@shared/types'; + +interface GraphActivityCardProps { + message: InboxMessage; + teamName: string; + messageContext: MessageContext; + teamNames: string[]; + teamColorByName: ReadonlyMap; + isUnread?: boolean; + zebraShade?: boolean; + className?: string; + onClick?: () => void; + onOpenTaskDetail?: (taskId: string) => void; + onOpenMemberProfile?: ( + memberName: string, + options?: { + initialTab?: MemberDetailTab; + initialActivityFilter?: MemberActivityFilter; + } + ) => void; +} + +export const GraphActivityCard = ({ + message, + teamName, + messageContext, + teamNames, + teamColorByName, + isUnread = false, + zebraShade = false, + className, + onClick, + onOpenTaskDetail, + onOpenMemberProfile, +}: GraphActivityCardProps): React.JSX.Element => { + const renderProps = resolveMessageRenderProps(message, messageContext); + const interactive = Boolean(onClick); + + return ( +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onClick?.(); + } + } + : undefined + } + onDragStart={(event) => { + event.preventDefault(); + }} + > + onOpenMemberProfile?.(memberName)} + onTaskIdClick={onOpenTaskDetail} + zebraShade={zebraShade} + teamNames={teamNames} + teamColorByName={teamColorByName} + /> +
+ ); +}; diff --git a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx index 7a5d4eda..4e31fec3 100644 --- a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx @@ -1,11 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ACTIVITY_LANE } from '@claude-teams/agent-graph'; -import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; -import { - buildMessageContext, - resolveMessageRenderProps, -} from '@renderer/components/team/activity/activityMessageContext'; +import { buildMessageContext } from '@renderer/components/team/activity/activityMessageContext'; import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog'; import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; @@ -18,6 +14,8 @@ import { } from '../../core/domain/buildInlineActivityEntries'; import { useGraphActivityContext } from '../hooks/useGraphActivityContext'; +import { GraphActivityCard } from './GraphActivityCard'; + import type { GraphNode } from '@claude-teams/agent-graph'; import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup'; import type { @@ -77,6 +75,9 @@ export const GraphActivityHud = ({ const connectorPathRefs = useRef(new Map()); const [expandedItem, setExpandedItem] = useState(null); const { teamData, teams } = useGraphActivityContext(teamName); + const teamSnapshot = teamData; + const members = teamData?.members ?? []; + const messages = teamData?.messageFeed ?? []; const ownerNodes = useMemo( () => @@ -87,21 +88,27 @@ export const GraphActivityHud = ({ [nodes] ); const leadNodeId = ownerNodes.find((node) => node.kind === 'lead')?.id ?? `lead:${teamName}`; - const leadName = teamData ? getGraphLeadMemberName(teamData, teamName) : `${teamName}-lead`; + const leadName = teamSnapshot + ? getGraphLeadMemberName({ members }, teamName) + : `${teamName}-lead`; const ownerNodeIds = useMemo(() => new Set(ownerNodes.map((node) => node.id)), [ownerNodes]); const entryMapByOwnerNodeId = useMemo(() => { - if (!teamData) { + if (!teamSnapshot) { return new Map(); } return buildInlineActivityEntries({ - data: teamData, + data: { + members, + tasks: teamSnapshot.tasks, + messages, + }, teamName, leadId: leadNodeId, leadName, ownerNodeIds, }); - }, [leadName, leadNodeId, ownerNodeIds, teamData, teamName]); - const messageContext = useMemo(() => buildMessageContext(teamData?.members), [teamData?.members]); + }, [leadName, leadNodeId, members, messages, ownerNodeIds, teamName, teamSnapshot]); + const messageContext = useMemo(() => buildMessageContext(members), [members]); const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams); const { readSet } = useTeamMessagesRead(teamName); @@ -279,38 +286,10 @@ export const GraphActivityHud = ({ visibleLanes, ]); - const expandedItemsByKey = useMemo(() => { - const items = new Map(); - for (const lane of visibleLanes) { - for (const entry of lane.entries) { - const key = toMessageKey(entry.message); - items.set(key, { type: 'message', message: entry.message }); - } - } - return items; - }, [visibleLanes]); - - const handleExpandItem = useCallback( - (key: string) => { - const next = expandedItemsByKey.get(key); - if (next) { - setExpandedItem(next); - } - }, - [expandedItemsByKey] - ); - const handleMessageClick = useCallback((item: TimelineItem) => { setExpandedItem(item); }, []); - const handleMemberNameClick = useCallback( - (memberName: string) => { - onOpenMemberProfile?.(memberName); - }, - [onOpenMemberProfile] - ); - const handleMemberClick = useCallback( (member: ResolvedTeamMember) => { onOpenMemberProfile?.(member.name); @@ -381,7 +360,7 @@ export const GraphActivityHud = ({ }; }, [enabled, forwardWheelToGraph, visibleLanes]); - if (!enabled || !teamData || visibleLanes.length === 0) { + if (!enabled || !teamSnapshot || visibleLanes.length === 0) { return null; } @@ -444,10 +423,6 @@ export const GraphActivityHud = ({ ) : null} {lane.entries.map((entry, index) => { const messageKey = toMessageKey(entry.message); - const renderProps = resolveMessageRenderProps( - entry.message, - messageContext - ); const timelineItem: TimelineItem = { type: 'message', message: entry.message, @@ -468,26 +443,17 @@ export const GraphActivityHud = ({ } }} > - handleMessageClick(timelineItem)} + onOpenTaskDetail={onOpenTaskDetail} + onOpenMemberProfile={onOpenMemberProfile} /> ); @@ -521,7 +487,7 @@ export const GraphActivityHud = ({ } }} teamName={teamName} - members={teamData.members} + members={members} onMemberClick={handleMemberClick} onTaskIdClick={onOpenTaskDetail} teamNames={teamNames} diff --git a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx index a341ec4a..a25e3c84 100644 --- a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx +++ b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx @@ -292,14 +292,21 @@ const MemberPopoverContent = ({ ? node.domainRef.teamName : ''; const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64); - const { teamData, spawnEntry, leadActivity, progress, memberSpawnSnapshot, memberSpawnStatuses } = - useGraphMemberPopoverContext(teamName, memberName); - const member = teamData?.members.find((candidate) => candidate.name === memberName) ?? null; + const { + teamData, + teamMembers, + spawnEntry, + leadActivity, + progress, + memberSpawnSnapshot, + memberSpawnStatuses, + } = useGraphMemberPopoverContext(teamName, memberName); + const member = teamMembers.find((candidate) => candidate.name === memberName) ?? null; const provisioningPresentation = teamData && teamName ? buildTeamProvisioningPresentation({ progress, - members: teamData.members, + members: teamMembers, memberSpawnStatuses, memberSpawnSnapshot, }) @@ -425,7 +432,7 @@ const MemberPopoverContent = ({ )} - {/* Context usage stays hidden for now because LeadContextUsage.percent is unreliable. */} + {/* Context usage stays hidden for now because lead context telemetry is still incomplete. */} {/* Current task indicator — reuses same pattern as MemberCard */} {node.currentTaskId && node.currentTaskSubject && ( diff --git a/src/features/agent-graph/renderer/ui/GraphTransientHandoffHud.tsx b/src/features/agent-graph/renderer/ui/GraphTransientHandoffHud.tsx new file mode 100644 index 00000000..72406bbe --- /dev/null +++ b/src/features/agent-graph/renderer/ui/GraphTransientHandoffHud.tsx @@ -0,0 +1,176 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; + +import { + ACTIVITY_LANE, + getTransientHandoffCardAlpha, + type TransientHandoffCard, +} from '@claude-teams/agent-graph'; +import { buildMessageContext } from '@renderer/components/team/activity/activityMessageContext'; +import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta'; + +import { useGraphActivityContext } from '../hooks/useGraphActivityContext'; + +import { buildTransientHandoffMessage } from './buildTransientHandoffMessage'; +import { GraphActivityCard } from './GraphActivityCard'; + +interface GraphTransientHandoffHudProps { + teamName: string; + getTransientHandoffSnapshot?: (options?: { + focusNodeIds?: ReadonlySet | null; + focusEdgeIds?: ReadonlySet | null; + }) => { cards: TransientHandoffCard[]; time: number }; + getCameraZoom?: () => number; + worldToScreen?: (x: number, y: number) => { x: number; y: number }; + getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; + focusNodeIds: ReadonlySet | null; + focusEdgeIds: ReadonlySet | null; + enabled?: boolean; +} + +const CARD_WIDTH = ACTIVITY_LANE.width; +const CARD_HEIGHT = 72; +const STACK_GAP = 10; + +export const GraphTransientHandoffHud = ({ + teamName, + getTransientHandoffSnapshot = () => ({ cards: [], time: 0 }), + getCameraZoom = () => 1, + worldToScreen, + getNodeWorldPosition = () => null, + focusNodeIds, + focusEdgeIds, + enabled = true, +}: GraphTransientHandoffHudProps): React.JSX.Element | null => { + const worldLayerRef = useRef(null); + const shellRefs = useRef(new Map()); + const signatureRef = useRef(''); + const [cards, setCards] = useState([]); + const { teamData, teams } = useGraphActivityContext(teamName); + const messageContext = useMemo(() => buildMessageContext(teamData?.members), [teamData?.members]); + const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams); + + useEffect(() => { + signatureRef.current = ''; + setCards([]); + }, [teamName]); + + useLayoutEffect(() => { + if (!enabled) { + setCards([]); + return; + } + + let frameId = 0; + const tick = (): void => { + const snapshot = getTransientHandoffSnapshot({ + focusNodeIds, + focusEdgeIds, + }); + const nextCards = snapshot.cards.filter( + (card) => card.anchorKind === 'lead' || card.anchorKind === 'member' + ); + const nextSignature = nextCards + .map((card) => `${card.key}:${card.count}:${card.updatedAt}:${card.anchorNodeId}`) + .join('|'); + if (nextSignature !== signatureRef.current) { + signatureRef.current = nextSignature; + setCards(nextCards); + } + + const worldLayer = worldLayerRef.current; + if (worldLayer && worldToScreen) { + const origin = worldToScreen(0, 0); + const zoom = Math.max(getCameraZoom(), 0.001); + worldLayer.style.transform = `translate(${Math.round(origin.x)}px, ${Math.round(origin.y)}px) scale(${zoom.toFixed(3)})`; + } + + const stackIndexByAnchor = new Map(); + for (const card of nextCards) { + const shell = shellRefs.current.get(card.key); + if (!shell) { + continue; + } + + const nodeWorld = getNodeWorldPosition(card.anchorNodeId); + const alpha = getTransientHandoffCardAlpha(card, snapshot.time); + if (!nodeWorld || !worldToScreen || alpha <= 0.001) { + shell.style.opacity = '0'; + continue; + } + + const stackIndex = stackIndexByAnchor.get(card.anchorNodeId) ?? 0; + stackIndexByAnchor.set(card.anchorNodeId, stackIndex + 1); + const lift = stackIndex * (CARD_HEIGHT * 0.34 + STACK_GAP); + const scale = 0.94 + alpha * 0.06; + + shell.style.left = `${Math.round(nodeWorld.x)}px`; + shell.style.top = `${Math.round(nodeWorld.y)}px`; + shell.style.opacity = String(alpha); + shell.style.transform = `translate(-50%, calc(-50% - ${lift.toFixed(1)}px)) scale(${scale.toFixed(3)})`; + } + + frameId = window.requestAnimationFrame(tick); + }; + + tick(); + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [ + enabled, + focusEdgeIds, + focusNodeIds, + getCameraZoom, + getNodeWorldPosition, + getTransientHandoffSnapshot, + worldToScreen, + ]); + + const handoffMessages = useMemo( + () => + cards.map((card, index) => ({ + card, + message: buildTransientHandoffMessage(teamName, card), + zebraShade: index % 2 === 1, + })), + [cards, teamName] + ); + + if (!enabled || !teamData || cards.length === 0) { + return null; + } + + return ( +
+ {handoffMessages.map(({ card, message, zebraShade }) => ( +
{ + shellRefs.current.set(card.key, element); + }} + className="pointer-events-none absolute z-[9] origin-center opacity-0 transition-opacity duration-150 ease-out" + style={{ + width: `${CARD_WIDTH}px`, + maxWidth: `${CARD_WIDTH}px`, + }} + onDragStart={(event) => { + event.preventDefault(); + }} + > + +
+ ))} +
+ ); +}; diff --git a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index c5e87d8c..69e3bc65 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -18,6 +18,7 @@ import { GraphActivityHud } from './GraphActivityHud'; import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover'; import { GraphNodePopover } from './GraphNodePopover'; import { GraphProvisioningHud } from './GraphProvisioningHud'; +import { GraphTransientHandoffHud } from './GraphTransientHandoffHud'; import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph'; import type { @@ -88,7 +89,6 @@ export const TeamGraphOverlay = ({ const openCreateTask = useCallback(() => { openCreateTaskDialog(''); }, [openCreateTaskDialog]); - const events: GraphEventPort = { onNodeDoubleClick: useCallback( (ref: GraphDomainRef) => { @@ -141,13 +141,30 @@ export const TeamGraphOverlay = ({ height: number; } | null; getCameraZoom?: () => number; + getTransientHandoffSnapshot?: (options?: { + focusNodeIds?: ReadonlySet | null; + focusEdgeIds?: ReadonlySet | null; + }) => { + cards: import('@claude-teams/agent-graph').TransientHandoffCard[]; + time: number; + }; worldToScreen?: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; + focusEdgeIds?: ReadonlySet | null; }; const { getViewportSize, focusNodeIds } = extraHudProps; return ( <> + { openCreateTaskDialog(''); }, [openCreateTaskDialog]); - // Task action dispatchers const dispatchTaskAction = useCallback( (action: string) => (taskId: string) => @@ -165,13 +165,31 @@ export const TeamGraphTab = ({ height: number; } | null; getCameraZoom?: () => number; + getTransientHandoffSnapshot?: (options?: { + focusNodeIds?: ReadonlySet | null; + focusEdgeIds?: ReadonlySet | null; + }) => { + cards: import('@claude-teams/agent-graph').TransientHandoffCard[]; + time: number; + }; worldToScreen?: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; + focusEdgeIds?: ReadonlySet | null; }; const { getViewportSize, focusNodeIds } = extraHudProps; return ( <> + ${card.destinationLabel}`; +} + +function buildText(card: TransientHandoffCard): string { + const preview = card.preview?.trim(); + switch (card.kind) { + case 'task_assign': { + const taskLabel = card.relatedTaskDisplayId ?? card.relatedTaskId ?? 'task'; + return `New task assigned to you: ${taskLabel}${preview ? ` - ${preview}` : ''}`; + } + case 'task_comment': + return preview ?? `${card.sourceLabel} added a comment`; + case 'review_request': + return preview ?? `Review requested by ${card.sourceLabel}`; + case 'review_response': + return preview ?? `Review response from ${card.sourceLabel}`; + case 'inbox_message': + default: + return preview ?? `${card.sourceLabel} -> ${card.destinationLabel}`; + } +} + +export function buildTransientHandoffMessage( + teamName: string, + card: TransientHandoffCard +): InboxMessage { + const messageKind = card.kind === 'task_comment' ? 'task_comment_notification' : 'default'; + const taskRefs = buildTaskRefs(teamName, card); + + return { + from: card.sourceLabel, + to: card.destinationLabel, + text: buildText(card), + timestamp: new Date(card.updatedAt * 1000).toISOString(), + read: true, + summary: buildSummary(card), + color: card.color, + messageId: `graph-handoff:${card.key}`, + source: 'inbox', + messageKind, + taskRefs, + }; +} diff --git a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts index 56985538..86805355 100644 --- a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts +++ b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts @@ -199,7 +199,7 @@ export class CodexAppServerClient { { clientInfo: { name: 'claude-agent-teams-ui', - title: 'Claude Agent Teams UI', + title: 'Agent Teams UI', version: '0.1.0', }, capabilities: { diff --git a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts index baa2f48b..79ae9ce3 100644 --- a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts +++ b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts @@ -3,10 +3,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type DashboardRecentProject } from '@features/recent-projects/contracts'; import { api, isElectronMode } from '@renderer/api'; import { useStore } from '@renderer/store'; -import { buildTaskCountsByProject, normalizePath } from '@renderer/utils/pathNormalize'; +import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; +import { buildTaskCountsByProject } from '@renderer/utils/pathNormalize'; import { useShallow } from 'zustand/react/shallow'; import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter'; +import { buildActiveTeamsByProject } from '../utils/activeProjectTeams'; import { sortRecentProjectsByDisplayPriority, subscribeRecentProjectOpenHistory, @@ -62,16 +64,27 @@ export function useRecentProjectsSection( openProjectPath: (projectPath: string) => Promise; selectProjectFolder: () => Promise; } { - const { globalTasks, globalTasksInitialized, globalTasksLoading, fetchAllTasks, teams } = - useStore( - useShallow((state) => ({ - globalTasks: state.globalTasks, - globalTasksInitialized: state.globalTasksInitialized, - globalTasksLoading: state.globalTasksLoading, - fetchAllTasks: state.fetchAllTasks, - teams: state.teams, - })) - ); + const { + globalTasks, + globalTasksInitialized, + globalTasksLoading, + fetchAllTasks, + teams, + provisioningRuns, + currentProvisioningRunIdByTeam, + provisioningSnapshotByTeam, + } = useStore( + useShallow((state) => ({ + globalTasks: state.globalTasks, + globalTasksInitialized: state.globalTasksInitialized, + globalTasksLoading: state.globalTasksLoading, + fetchAllTasks: state.fetchAllTasks, + teams: state.teams, + provisioningRuns: state.provisioningRuns, + currentProvisioningRunIdByTeam: state.currentProvisioningRunIdByTeam, + provisioningSnapshotByTeam: state.provisioningSnapshotByTeam, + })) + ); const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []); const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject(); const [recentProjects, setRecentProjects] = useState( @@ -92,6 +105,21 @@ export function useRecentProjectsSection( const recentProjectsRef = useRef( initialSnapshot?.payload.projects ?? [] ); + const provisioningState = useMemo( + () => ({ currentProvisioningRunIdByTeam, provisioningRuns }), + [currentProvisioningRunIdByTeam, provisioningRuns] + ); + const provisioningTeamNames = useMemo( + () => + Object.keys(currentProvisioningRunIdByTeam).filter((teamName) => + isTeamProvisioningActive(provisioningState, teamName) + ), + [currentProvisioningRunIdByTeam, provisioningState] + ); + const provisioningTeamNamesKey = useMemo( + () => [...provisioningTeamNames].sort().join('\u0000'), + [provisioningTeamNames] + ); useEffect(() => { recentProjectsRef.current = recentProjects; @@ -173,7 +201,7 @@ export function useRecentProjectsSection( return () => { cancelled = true; }; - }, [teams]); + }, [provisioningTeamNamesKey, teams]); useEffect(() => { if (!searchQuery.trim()) { @@ -189,25 +217,13 @@ export function useRecentProjectsSection( const taskCountsByProject = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]); const activeTeamsByProject = useMemo(() => { - const aliveSet = new Set(aliveTeams); - const teamsByProject = new Map(); - - for (const team of teams) { - if (!team.projectPath || !aliveSet.has(team.teamName)) { - continue; - } - - const key = normalizePath(team.projectPath); - const existing = teamsByProject.get(key); - if (existing) { - existing.push(team); - } else { - teamsByProject.set(key, [team]); - } - } - - return teamsByProject; - }, [aliveTeams, teams]); + return buildActiveTeamsByProject({ + teams, + aliveTeamNames: aliveTeams, + provisioningTeamNames, + provisioningSnapshotByTeam, + }); + }, [aliveTeams, provisioningSnapshotByTeam, provisioningTeamNames, teams]); const decoratedCards = useMemo( () => diff --git a/src/features/recent-projects/renderer/utils/activeProjectTeams.ts b/src/features/recent-projects/renderer/utils/activeProjectTeams.ts new file mode 100644 index 00000000..273d60f8 --- /dev/null +++ b/src/features/recent-projects/renderer/utils/activeProjectTeams.ts @@ -0,0 +1,48 @@ +import { normalizePath } from '@renderer/utils/pathNormalize'; + +import type { TeamSummary } from '@shared/types'; + +interface BuildActiveTeamsByProjectInput { + teams: TeamSummary[]; + aliveTeamNames: readonly string[]; + provisioningTeamNames: readonly string[]; + provisioningSnapshotByTeam: Record; +} + +export function buildActiveTeamsByProject({ + teams, + aliveTeamNames, + provisioningTeamNames, + provisioningSnapshotByTeam, +}: BuildActiveTeamsByProjectInput): Map { + const activeTeamNames = new Set([...aliveTeamNames, ...provisioningTeamNames]); + if (activeTeamNames.size === 0) { + return new Map(); + } + + const existingTeamNames = new Set(teams.map((team) => team.teamName)); + const syntheticProvisioningTeams = provisioningTeamNames + .filter((teamName) => !existingTeamNames.has(teamName)) + .map((teamName) => provisioningSnapshotByTeam[teamName]) + .filter((team): team is TeamSummary => Boolean(team)); + + const teamsByProject = new Map(); + const visibleTeams = + syntheticProvisioningTeams.length > 0 ? [...teams, ...syntheticProvisioningTeams] : teams; + + for (const team of visibleTeams) { + if (!team.projectPath || !activeTeamNames.has(team.teamName)) { + continue; + } + + const key = normalizePath(team.projectPath); + const existing = teamsByProject.get(key); + if (existing) { + existing.push(team); + } else { + teamsByProject.set(key, [team]); + } + } + + return teamsByProject; +} diff --git a/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts b/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts index 25e60cab..66c4cd3f 100644 --- a/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts +++ b/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts @@ -157,7 +157,7 @@ function resolveHistoryOpenedAt(lookup: HistoryLookup, projectPath: string): num } const foldedMatch = lookup.folded.get(foldHistoryPath(normalizedPath)); - if (!foldedMatch || foldedMatch.exactPaths.size !== 1) { + if (foldedMatch?.exactPaths.size !== 1) { return 0; } diff --git a/src/features/tmux-installer/main/composition/runtimeSupport.ts b/src/features/tmux-installer/main/composition/runtimeSupport.ts index ee6c41dc..183b918b 100644 --- a/src/features/tmux-installer/main/composition/runtimeSupport.ts +++ b/src/features/tmux-installer/main/composition/runtimeSupport.ts @@ -18,6 +18,12 @@ export async function killTmuxPaneForCurrentPlatform(paneId: string): Promise> { + return runtimeCommandExecutor.listPanePids(paneIds); +} + export function killTmuxPaneForCurrentPlatformSync(paneId: string): void { runtimeCommandExecutor.killPaneSync(paneId); invalidateTmuxRuntimeStatusCache(); diff --git a/src/features/tmux-installer/main/index.ts b/src/features/tmux-installer/main/index.ts index deddfc10..21c41e9f 100644 --- a/src/features/tmux-installer/main/index.ts +++ b/src/features/tmux-installer/main/index.ts @@ -9,4 +9,5 @@ export { isTmuxRuntimeReadyForCurrentPlatform, killTmuxPaneForCurrentPlatform, killTmuxPaneForCurrentPlatformSync, + listTmuxPanePidsForCurrentPlatform, } from './composition/runtimeSupport'; diff --git a/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts b/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts index 948c86ba..4b062134 100644 --- a/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts +++ b/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts @@ -54,6 +54,36 @@ export class TmuxPlatformCommandExecutor { } } + async listPanePids(paneIds: readonly string[]): Promise> { + const normalizedPaneIds = [...new Set(paneIds.map((paneId) => paneId.trim()).filter(Boolean))]; + if (normalizedPaneIds.length === 0) { + return new Map(); + } + + const result = await this.execTmux( + ['list-panes', '-a', '-F', '#{pane_id}\t#{pane_pid}'], + 3_000 + ); + if (result.exitCode !== 0) { + throw new Error(result.stderr || 'Failed to list tmux panes'); + } + + const wanted = new Set(normalizedPaneIds); + const panePidById = new Map(); + for (const line of result.stdout.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + const [paneId = '', rawPid = ''] = trimmed.split('\t'); + const normalizedPaneId = paneId.trim(); + if (!wanted.has(normalizedPaneId)) continue; + const pid = Number.parseInt(rawPid.trim(), 10); + if (Number.isFinite(pid) && pid > 0) { + panePidById.set(normalizedPaneId, pid); + } + } + return panePidById; + } + killPaneSync(paneId: string): void { if (process.platform === 'win32') { const preferredDistro = this.#wslService.getPersistedPreferredDistroSync(); diff --git a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts index 314fdbdc..376afc79 100644 --- a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts +++ b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts @@ -68,4 +68,26 @@ describe('TmuxPlatformCommandExecutor', () => { }) ); }); + + it('lists pane pids for the requested pane ids only', async () => { + const executor = new TmuxPlatformCommandExecutor( + { + getPersistedPreferredDistroSync: () => null, + } as never, + {} as never + ); + vi.spyOn(executor, 'execTmux').mockResolvedValue({ + exitCode: 0, + stdout: '%1\t111\n%2\t222\n%3\tnot-a-pid\n', + stderr: '', + }); + + await expect(executor.listPanePids(['%2', '%3', '%2'])).resolves.toEqual( + new Map([['%2', 222]]) + ); + expect(executor.execTmux).toHaveBeenCalledWith( + ['list-panes', '-a', '-F', '#{pane_id}\t#{pane_pid}'], + 3_000 + ); + }); }); diff --git a/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts index edbd8a4d..335a5e69 100644 --- a/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts +++ b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts @@ -471,7 +471,7 @@ export class TmuxWslService { ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', POWERSHELL_FEATURE_QUERY], 6_000 ); - if (!result || result.exitCode !== 0 || !result.stdout.trim()) { + if (result?.exitCode !== 0 || !result.stdout.trim()) { return null; } diff --git a/src/main/index.ts b/src/main/index.ts index baa887df..42b9d15a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,5 @@ /** - * Main process entry point for Claude Agent Teams UI. + * Main process entry point for Agent Teams UI. * * Responsibilities: * - Initialize Electron app and main window @@ -88,6 +88,7 @@ import { } from './services/extensions'; import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor'; import { HttpServer } from './services/infrastructure/HttpServer'; +import { clearAutoResumeService } from './services/team/AutoResumeService'; import { buildTeamControlApiBaseUrl, clearTeamControlApiState, @@ -563,6 +564,13 @@ function wireFileWatcherEvents(context: ServiceContext): void { const teamName = row.teamName.trim(); const detail = typeof row.detail === 'string' ? row.detail : ''; + if ( + teamDataService && + (row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config') + ) { + teamDataService.invalidateMessageFeed(teamName); + } + // --- Inbox change events: relay to lead + native OS notifications --- if (row.type === 'inbox') { if (reconcileScheduler) { @@ -905,6 +913,12 @@ async function initializeServices(): Promise { }); const forwardTeamChange = (event: TeamChangeEvent): void => { + if ( + teamDataService && + (event.type === 'inbox' || event.type === 'lead-message' || event.type === 'config') + ) { + teamDataService.invalidateMessageFeed(event.teamName); + } safeSendToRenderer(mainWindow, TEAM_CHANGE, event); httpServer?.broadcast('team-change', event); }; @@ -964,6 +978,7 @@ async function initializeServices(): Promise { boardTaskExactLogsService, boardTaskExactLogDetailService, teammateToolTracker ?? undefined, + teamLogSourceTracker, branchStatusService ?? undefined, { rewire: rewireContextEvents, @@ -1070,6 +1085,11 @@ async function startHttpServer( function shutdownServices(): void { logger.info('Shutting down services...'); + // Clear pending auto-resume timers before anything else — otherwise the + // dangling setTimeout handles keep the event loop alive past shutdown and + // may fire against a torn-down provisioning service. + clearAutoResumeService(); + // Kill all team CLI processes via SIGKILL BEFORE anything else. // This must happen before the OS closes stdin pipes (on app exit), // because stdin EOF triggers CLI's graceful shutdown which deletes team files. @@ -1212,7 +1232,7 @@ function createWindow(): void { backgroundColor: '#1a1a1a', ...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }), ...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }), - title: 'Claude Agent Teams UI', + title: 'Agent Teams UI', }); markRendererUnavailable(mainWindow); diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index 2c961d60..d52b0dcf 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -125,6 +125,7 @@ function validateNotificationsSection( 'notifyOnCrossTeamMessage', 'notifyOnTeamLaunched', 'notifyOnToolApproval', + 'autoResumeOnRateLimit', 'statusChangeOnlySolo', 'statusChangeStatuses', 'triggers', @@ -219,6 +220,12 @@ function validateNotificationsSection( } result.notifyOnToolApproval = value; break; + case 'autoResumeOnRateLimit': + if (typeof value !== 'boolean') { + return { valid: false, error: `notifications.${key} must be a boolean` }; + } + result.autoResumeOnRateLimit = value; + break; case 'statusChangeOnlySolo': if (typeof value !== 'boolean') { return { valid: false, error: `notifications.${key} must be a boolean` }; diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 772501e1..b7636889 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -106,6 +106,7 @@ import type { ServiceContextRegistry, SshConnectionManager, TeamDataService, + TeamLogSourceTracker, TeammateToolTracker, TeamMemberLogsFinder, TeamProvisioningService, @@ -141,6 +142,7 @@ export function initializeIpcHandlers( boardTaskExactLogsService: BoardTaskExactLogsService, boardTaskExactLogDetailService: BoardTaskExactLogDetailService, teammateToolTracker: TeammateToolTracker | undefined, + teamLogSourceTracker: TeamLogSourceTracker | undefined, branchStatusService: BranchStatusService | undefined, contextCallbacks: { rewire: (context: ServiceContext) => void; @@ -184,6 +186,7 @@ export function initializeIpcHandlers( memberStatsComputer, teamBackupService, teammateToolTracker, + teamLogSourceTracker, branchStatusService, boardTaskActivityService, boardTaskActivityDetailService, diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index c9fed835..ca2d6dbb 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -16,12 +16,14 @@ import { TEAM_DELETE_DRAFT, TEAM_DELETE_TASK_ATTACHMENT, TEAM_DELETE_TEAM, + TEAM_GET_AGENT_RUNTIME, TEAM_GET_ALL_TASKS, TEAM_GET_ATTACHMENTS, TEAM_GET_CLAUDE_LOGS, TEAM_GET_DATA, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, + TEAM_GET_MEMBER_ACTIVITY_META, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_GET_MESSAGES_PAGE, @@ -34,6 +36,7 @@ import { TEAM_GET_TASK_EXACT_LOG_DETAIL, TEAM_GET_TASK_EXACT_LOG_SUMMARIES, TEAM_GET_TASK_LOG_STREAM, + TEAM_GET_TASK_LOG_STREAM_SUMMARY, TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, @@ -50,6 +53,7 @@ import { TEAM_REMOVE_TASK_RELATIONSHIP, TEAM_REPLACE_MEMBERS, TEAM_REQUEST_REVIEW, + TEAM_RESTART_MEMBER, TEAM_RESTORE, TEAM_RESTORE_TASK, TEAM_SAVE_TASK_ATTACHMENT, @@ -57,6 +61,7 @@ import { TEAM_SET_CHANGE_PRESENCE_TRACKING, TEAM_SET_PROJECT_BRANCH_TRACKING, TEAM_SET_TASK_CLARIFICATION, + TEAM_SET_TASK_LOG_STREAM_TRACKING, TEAM_SET_TOOL_ACTIVITY_TRACKING, TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SOFT_DELETE_TASK, @@ -92,7 +97,7 @@ import { parseStandaloneSlashCommand, } from '@shared/utils/slashCommands'; import crypto from 'crypto'; -import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron'; +import { app, BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron'; import * as fs from 'fs'; import * as path from 'path'; @@ -103,10 +108,15 @@ import { buildActionModeAgentBlock, isAgentActionMode, } from '../services/team/actionModeInstructions'; +import { + getAutoResumeService, + initializeAutoResumeService, +} from '../services/team/AutoResumeService'; import { buildReplaceMembersDiff, buildReplaceMembersSummaryMessage, } from '../services/team/memberUpdateNotifications'; +import { mergeLiveLeadProcessMessages } from '../services/team/mergeLiveLeadProcessMessages'; import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore'; import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore'; import { TeamMetaStore } from '../services/team/TeamMetaStore'; @@ -130,6 +140,7 @@ import type { BranchStatusService, MemberStatsComputer, TeamDataService, + TeamLogSourceTracker, TeammateToolTracker, TeamMemberLogsFinder, TeamProvisioningService, @@ -146,17 +157,16 @@ import type { BoardTaskExactLogDetailResult, BoardTaskExactLogSummariesResponse, BoardTaskLogStreamResponse, + BoardTaskLogStreamSummary, CreateTaskRequest, EffortLevel, GlobalTask, IpcResult, KanbanColumnId, LeadActivitySnapshot, - LeadContextUsage, LeadContextUsageSnapshot, MemberFullStats, MemberLogSummary, - MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, MessagesPage, SendMessageRequest, @@ -164,15 +174,16 @@ import type { TaskAttachmentMeta, TaskComment, TaskRef, + TeamAgentRuntimeSnapshot, TeamClaudeLogsQuery, TeamClaudeLogsResponse, TeamConfig, TeamCreateConfigRequest, TeamCreateRequest, TeamCreateResponse, - TeamData, TeamLaunchRequest, TeamLaunchResponse, + TeamMemberActivityMeta, TeamMessageNotificationData, TeamProvisioningPrepareResult, TeamProvisioningProgress, @@ -180,6 +191,7 @@ import type { TeamTask, TeamTaskStatus, TeamUpdateConfigRequest, + TeamViewSnapshot, ToolApprovalFileContent, ToolApprovalSettings, UpdateKanbanPatch, @@ -196,6 +208,16 @@ const logger = createLogger('IPC:teams'); const seenRateLimitKeys = new Set(); const SEEN_RATE_LIMIT_KEYS_MAX = 500; +function noteHeavyTeamDataWorkerFallback(operation: string): void { + if (!app.isPackaged) { + return; + } + + logger.error( + `[${operation}] team-data-worker unavailable in packaged runtime; falling back to main-thread execution for heavy message/activity path` + ); +} + async function getDurableLeadTeammateRoster( teamName: string, leadName: string @@ -301,11 +323,25 @@ const SEEN_API_ERROR_KEYS_MAX = 500; * and NotificationManager dedupeKey (to prevent storage duplicates). */ function checkRateLimitMessages( - messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[], + messages: readonly { + messageId?: string; + from: string; + text: string; + timestamp: string; + to?: string; + source?: string; + leadSessionId?: string; + }[], teamName: string, teamDisplayName: string, - projectPath?: string + projectPath?: string, + teamIsAlive = true, + currentLeadSessionId: string | null = null ): void { + const observedAt = new Date(); + const autoResumeEnabled = + ConfigManager.getInstance().getConfig().notifications.autoResumeOnRateLimit; + for (const msg of messages) { if (msg.from === 'user') continue; if (!isRateLimitMessage(msg.text)) continue; @@ -313,28 +349,55 @@ function checkRateLimitMessages( const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`; const dedupeKey = `rate-limit:${teamName}:${rawKey}`; - // In-memory guard: prevents resurrection after user deletes the notification - if (seenRateLimitKeys.has(dedupeKey)) continue; - seenRateLimitKeys.add(dedupeKey); + // In-memory guard: prevents resurrection after user deletes the notification. + if (!seenRateLimitKeys.has(dedupeKey)) { + seenRateLimitKeys.add(dedupeKey); - // Evict oldest entries to prevent unbounded growth - if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) { - const first = seenRateLimitKeys.values().next().value; - if (first) seenRateLimitKeys.delete(first); + // Evict oldest entries to prevent unbounded growth + if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) { + const first = seenRateLimitKeys.values().next().value; + if (first) seenRateLimitKeys.delete(first); + } + + void NotificationManager.getInstance() + .addTeamNotification({ + teamEventType: 'rate_limit', + teamName, + teamDisplayName, + from: msg.from, + summary: `Rate limit: ${msg.from}`, + body: msg.text.slice(0, 200), + dedupeKey, + projectPath, + }) + .catch(() => undefined); } - void NotificationManager.getInstance() - .addTeamNotification({ - teamEventType: 'rate_limit', + // Only schedule auto-resume while a live team run currently exists. + // 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) { + // 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( teamName, - teamDisplayName, - from: msg.from, - summary: `Rate limit: ${msg.from}`, - body: msg.text.slice(0, 200), - dedupeKey, - projectPath, - }) - .catch(() => undefined); + msg.text, + observedAt, + new Date(msg.timestamp) + ); + } } } @@ -385,12 +448,26 @@ function checkApiErrorMessages( } } +function scanTeamMessageNotifications( + messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[], + teamName: string, + teamDisplayName: string, + projectPath?: string +): void { + if (messages.length === 0) { + return; + } + checkRateLimitMessages(messages, teamName, teamDisplayName, projectPath); + checkApiErrorMessages(messages, teamName, teamDisplayName, projectPath); +} + let teamDataService: TeamDataService | null = null; let teamProvisioningService: TeamProvisioningService | null = null; let teamMemberLogsFinder: TeamMemberLogsFinder | null = null; let memberStatsComputer: MemberStatsComputer | null = null; let teamBackupService: TeamBackupService | null = null; let teammateToolTracker: TeammateToolTracker | null = null; +let teamLogSourceTracker: TeamLogSourceTracker | null = null; let branchStatusService: BranchStatusService | null = null; let boardTaskActivityService: BoardTaskActivityService | null = null; let boardTaskActivityDetailService: BoardTaskActivityDetailService | null = null; @@ -427,6 +504,7 @@ export function initializeTeamHandlers( statsComputer?: MemberStatsComputer, backupService?: TeamBackupService, toolTracker?: TeammateToolTracker, + logSourceTracker?: TeamLogSourceTracker, branchTracker?: BranchStatusService, taskActivityService?: BoardTaskActivityService, taskActivityDetailService?: BoardTaskActivityDetailService, @@ -436,10 +514,12 @@ export function initializeTeamHandlers( ): void { teamDataService = service; teamProvisioningService = provisioningService; + initializeAutoResumeService(provisioningService); teamMemberLogsFinder = logsFinder ?? null; memberStatsComputer = statsComputer ?? null; teamBackupService = backupService ?? null; teammateToolTracker = toolTracker ?? null; + teamLogSourceTracker = logSourceTracker ?? null; branchStatusService = branchTracker ?? null; boardTaskActivityService = taskActivityService ?? null; boardTaskActivityDetailService = taskActivityDetailService ?? null; @@ -454,6 +534,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_GET_TASK_CHANGE_PRESENCE, handleGetTaskChangePresence); ipcMain.handle(TEAM_SET_CHANGE_PRESENCE_TRACKING, handleSetChangePresenceTracking); ipcMain.handle(TEAM_SET_PROJECT_BRANCH_TRACKING, handleSetProjectBranchTracking); + ipcMain.handle(TEAM_SET_TASK_LOG_STREAM_TRACKING, handleSetTaskLogStreamTracking); ipcMain.handle(TEAM_SET_TOOL_ACTIVITY_TRACKING, handleSetToolActivityTracking); ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs); ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning); @@ -463,6 +544,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning); ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage); ipcMain.handle(TEAM_GET_MESSAGES_PAGE, handleGetMessagesPage); + ipcMain.handle(TEAM_GET_MEMBER_ACTIVITY_META, handleGetMemberActivityMeta); ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask); ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview); ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban); @@ -482,6 +564,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_GET_LOGS_FOR_TASK, handleGetLogsForTask); ipcMain.handle(TEAM_GET_TASK_ACTIVITY, handleGetTaskActivity); ipcMain.handle(TEAM_GET_TASK_ACTIVITY_DETAIL, handleGetTaskActivityDetail); + ipcMain.handle(TEAM_GET_TASK_LOG_STREAM_SUMMARY, handleGetTaskLogStreamSummary); ipcMain.handle(TEAM_GET_TASK_LOG_STREAM, handleGetTaskLogStream); ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_SUMMARIES, handleGetTaskExactLogSummaries); ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_DETAIL, handleGetTaskExactLogDetail); @@ -501,6 +584,8 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity); ipcMain.handle(TEAM_LEAD_CONTEXT, handleLeadContext); ipcMain.handle(TEAM_MEMBER_SPAWN_STATUSES, handleMemberSpawnStatuses); + ipcMain.handle(TEAM_GET_AGENT_RUNTIME, handleGetAgentRuntime); + ipcMain.handle(TEAM_RESTART_MEMBER, handleRestartMember); ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask); ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask); ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks); @@ -526,6 +611,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_GET_TASK_CHANGE_PRESENCE); ipcMain.removeHandler(TEAM_SET_CHANGE_PRESENCE_TRACKING); ipcMain.removeHandler(TEAM_SET_PROJECT_BRANCH_TRACKING); + ipcMain.removeHandler(TEAM_SET_TASK_LOG_STREAM_TRACKING); ipcMain.removeHandler(TEAM_SET_TOOL_ACTIVITY_TRACKING); ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS); ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING); @@ -535,6 +621,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING); ipcMain.removeHandler(TEAM_SEND_MESSAGE); ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE); + ipcMain.removeHandler(TEAM_GET_MEMBER_ACTIVITY_META); ipcMain.removeHandler(TEAM_CREATE_TASK); ipcMain.removeHandler(TEAM_REQUEST_REVIEW); ipcMain.removeHandler(TEAM_UPDATE_KANBAN); @@ -554,6 +641,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK); ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY); ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY_DETAIL); + ipcMain.removeHandler(TEAM_GET_TASK_LOG_STREAM_SUMMARY); ipcMain.removeHandler(TEAM_GET_TASK_LOG_STREAM); ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_SUMMARIES); ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_DETAIL); @@ -573,6 +661,8 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_LEAD_ACTIVITY); ipcMain.removeHandler(TEAM_LEAD_CONTEXT); ipcMain.removeHandler(TEAM_MEMBER_SPAWN_STATUSES); + ipcMain.removeHandler(TEAM_GET_AGENT_RUNTIME); + ipcMain.removeHandler(TEAM_RESTART_MEMBER); ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK); ipcMain.removeHandler(TEAM_RESTORE_TASK); ipcMain.removeHandler(TEAM_GET_DELETED_TASKS); @@ -612,6 +702,13 @@ function getTeammateToolTracker(): TeammateToolTracker { return teammateToolTracker; } +function getTeamLogSourceTracker(): TeamLogSourceTracker { + if (!teamLogSourceTracker) { + throw new Error('Team log source tracker is not initialized'); + } + return teamLogSourceTracker; +} + function getBranchStatusService(): BranchStatusService { if (!branchStatusService) { throw new Error('Branch status service is not initialized'); @@ -702,14 +799,14 @@ async function handleListTeams(_event: IpcMainInvokeEvent): Promise> { +): Promise> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } const tn = validated.value!; const startedAt = Date.now(); - let data: TeamData; + let data: TeamViewSnapshot; setCurrentMainOp('team:getData'); try { // Prefer worker thread to keep main event loop responsive @@ -721,9 +818,11 @@ async function handleGetData( logger.warn( `[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}` ); + noteHeavyTeamDataWorkerFallback('teams:getData'); data = await getTeamDataService().getTeamData(tn); } } else { + noteHeavyTeamDataWorkerFallback('teams:getData'); data = await getTeamDataService().getTeamData(tn); } } catch (error) { @@ -759,95 +858,52 @@ async function handleGetData( } const provisioning = getTeamProvisioningService(); const isAlive = provisioning.isTeamAlive(tn); + const currentLeadSessionId = provisioning.getCurrentLeadSessionId(tn); const displayName = data.config.name || tn; const projectPath = data.config.projectPath; - const live = provisioning.getLiveLeadProcessMessages(tn); + const durableMessages = Array.isArray((data as { messages?: unknown }).messages) + ? ((data as { messages?: typeof live }).messages ?? []) + : []; + if (live.length === 0) { - checkRateLimitMessages(data.messages, tn, displayName, projectPath); - checkApiErrorMessages(data.messages, tn, displayName, projectPath); + if (durableMessages.length > 0) { + checkRateLimitMessages( + durableMessages, + tn, + displayName, + projectPath, + isAlive, + currentLeadSessionId + ); + checkApiErrorMessages(durableMessages, tn, displayName, projectPath); + } else { + scanTeamMessageNotifications(live, tn, displayName, projectPath); + } return { success: true, data: { ...data, isAlive } }; } - const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); - const isLeadThoughtLike = (msg: { source?: unknown; to?: string }): boolean => - !msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session'); - const getLeadThoughtFingerprint = (msg: { - from: string; - text: string; - leadSessionId?: string; - }): string => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text)}`; - - // Collect fingerprints only for thought-like lead messages. Include leadSessionId so a - // repeated thought in a new session does not get collapsed into an old session's history. - const existingTextFingerprints = new Set(); - for (const msg of data.messages) { - if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue; - if (!isLeadThoughtLike(msg)) continue; - existingTextFingerprints.add(getLeadThoughtFingerprint(msg)); + let merged = mergeLiveLeadProcessMessages(durableMessages, live); + if (durableMessages.length >= 50) { + try { + const newestPage = await teamDataService.getMessagesPage(tn, { + limit: 50, + liveMessages: live, + }); + merged = newestPage.messages; + } catch (error) { + logger.warn( + `[teams:getData] failed to rebuild newest merged messages for ${tn}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } } - const keyFor = (m: { - messageId?: string; - timestamp: string; - from: string; - text: string; - }): string => { - if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) { - return m.messageId; - } - return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`; - }; - - // Text-based fingerprints for live lead thoughts to catch duplicates with different - // messageIds inside the same session (e.g. lead-turn-* re-emits). - const leadProcessTextFingerprints = new Set(); - - // Content-based dedup for SendMessage captures: Claude Code CLI and our - // persistInboxMessage both write to inboxes/{member}.json, producing two entries - // with identical content but different messageIds. Track content fingerprints - // (from+to+text) with timestamps to collapse them within a 5-second window. - const contentSeen = new Map(); // fingerprint → timestamp ms - - const merged: typeof data.messages = []; - const seen = new Set(); - for (const msg of [...data.messages, ...live]) { - if ((msg as { source?: unknown }).source === 'lead_process' && !msg.to) { - const fp = getLeadThoughtFingerprint(msg); - // Skip if the same thought already exists in persisted history for the same session. - if (existingTextFingerprints.has(fp)) { - continue; - } - // Dedup live lead_process thoughts with the same text in the same session. - if (leadProcessTextFingerprints.has(fp)) { - continue; - } - leadProcessTextFingerprints.add(fp); - } - - // Content dedup for directed messages (SendMessage captures): - // same from+to+text within 5 seconds = duplicate from CLI + our persist. - if (typeof msg.to === 'string' && msg.to.trim().length > 0) { - const contentFp = `${msg.from}\0${msg.to}\0${(msg.text ?? '').replace(/\s+/g, ' ').slice(0, 100)}`; - const msgMs = Date.parse(msg.timestamp); - const existingMs = contentSeen.get(contentFp); - if (existingMs !== undefined && Math.abs(msgMs - existingMs) <= 5000) { - continue; // duplicate within 5s window — skip - } - contentSeen.set(contentFp, msgMs); - } - - const key = keyFor(msg); - if (seen.has(key)) continue; - seen.add(key); - merged.push(msg); - } - merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); - - checkRateLimitMessages(merged, tn, displayName, projectPath); + checkRateLimitMessages(merged, tn, displayName, projectPath, isAlive, currentLeadSessionId); checkApiErrorMessages(merged, tn, displayName, projectPath); - return { success: true, data: { ...data, isAlive, messages: merged } }; + return { success: true, data: { ...data, isAlive } }; } async function handleGetTaskChangePresence( @@ -917,6 +973,28 @@ async function handleSetToolActivityTracking( }); } +async function handleSetTaskLogStreamTracking( + _event: IpcMainInvokeEvent, + teamName: unknown, + enabled: unknown +): Promise> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + if (typeof enabled !== 'boolean') { + return { success: false, error: 'enabled must be a boolean' }; + } + + return wrapTeamHandler('setTaskLogStreamTracking', async () => { + if (enabled) { + await getTeamLogSourceTracker().enableTracking(validated.value!, 'task_log_stream'); + return; + } + await getTeamLogSourceTracker().disableTracking(validated.value!, 'task_log_stream'); + }); +} + async function handleDeleteTeam( _event: IpcMainInvokeEvent, teamName: unknown @@ -926,6 +1004,7 @@ async function handleDeleteTeam( return { success: false, error: validated.error ?? 'Invalid teamName' }; } return wrapTeamHandler('deleteTeam', async () => { + getAutoResumeService().cancelPendingAutoResume(validated.value!); getTeamProvisioningService().stopTeam(validated.value!); await getTeamDataService().deleteTeam(validated.value!); }); @@ -951,6 +1030,7 @@ async function handlePermanentlyDeleteTeam( return { success: false, error: validated.error ?? 'Invalid teamName' }; } return wrapTeamHandler('permanentlyDeleteTeam', async () => { + getAutoResumeService().cancelPendingAutoResume(validated.value!); await getTeamDataService().permanentlyDeleteTeam(validated.value!); // Clean up app-owned data (attachments, task-attachments) that lives outside ~/.claude/ const appData = getAppDataPath(); @@ -1724,16 +1804,89 @@ async function handleGetMessagesPage( return { success: false, error: vTeam.error ?? 'Invalid teamName' }; } const opts = (options && typeof options === 'object' ? options : {}) as { - beforeTimestamp?: string; + cursor?: string | null; limit?: number; }; const limit = Math.min(Math.max(1, opts.limit ?? 50), 200); - const beforeTimestamp = - typeof opts.beforeTimestamp === 'string' ? opts.beforeTimestamp : undefined; + const cursor = + typeof opts.cursor === 'string' ? opts.cursor : opts.cursor === null ? null : undefined; return wrapTeamHandler('getMessagesPage', async () => { - const service = getTeamDataService(); - return service.getMessagesPage(vTeam.value!, { beforeTimestamp, limit }); + let page: MessagesPage; + const notificationContext = await getTeamDataService().getTeamNotificationContext(vTeam.value!); + const liveMessages = + cursor == null ? getTeamProvisioningService().getLiveLeadProcessMessages(vTeam.value!) : []; + + if (liveMessages.length > 0) { + page = await getTeamDataService().getMessagesPage(vTeam.value!, { + cursor, + limit, + liveMessages, + }); + scanTeamMessageNotifications( + page.messages, + vTeam.value!, + notificationContext.displayName, + notificationContext.projectPath + ); + 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 + ); + return page; + } catch (workerErr) { + logger.warn( + `[teams:getMessagesPage] worker failed, falling back: ${ + workerErr instanceof Error ? workerErr.message : workerErr + }` + ); + } + } + noteHeavyTeamDataWorkerFallback('teams:getMessagesPage'); + page = await getTeamDataService().getMessagesPage(vTeam.value!, { cursor, limit }); + scanTeamMessageNotifications( + page.messages, + vTeam.value!, + notificationContext.displayName, + notificationContext.projectPath + ); + return page; + }); +} + +async function handleGetMemberActivityMeta( + _event: IpcMainInvokeEvent, + teamName: unknown +): Promise> { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) { + return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + } + + return wrapTeamHandler('getMemberActivityMeta', async () => { + const worker = getTeamDataWorkerClient(); + if (worker.isAvailable()) { + try { + return await worker.getMemberActivityMeta(vTeam.value!); + } catch (workerErr) { + logger.warn( + `[teams:getMemberActivityMeta] worker failed, falling back: ${ + workerErr instanceof Error ? workerErr.message : workerErr + }` + ); + } + } + noteHeavyTeamDataWorkerFallback('teams:getMemberActivityMeta'); + return getTeamDataService().getMemberActivityMeta(vTeam.value!); }); } @@ -2603,6 +2756,24 @@ async function handleGetTaskLogStream( ); } +async function handleGetTaskLogStreamSummary( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown +): Promise> { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) { + return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + } + const vTask = validateTaskId(taskId); + if (!vTask.valid) { + return { success: false, error: vTask.error ?? 'Invalid taskId' }; + } + return wrapTeamHandler('getTaskLogStreamSummary', () => + getBoardTaskLogStreamService().getTaskLogStreamSummary(vTeam.value!, vTask.value!) + ); +} + async function handleGetTaskExactLogSummaries( _event: IpcMainInvokeEvent, teamName: unknown, @@ -2723,6 +2894,37 @@ async function handleMemberSpawnStatuses( ); } +async function handleGetAgentRuntime( + _event: IpcMainInvokeEvent, + teamName: unknown +): Promise> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + return wrapTeamHandler('getAgentRuntime', async () => + getTeamProvisioningService().getTeamAgentRuntimeSnapshot(validated.value!) + ); +} + +async function handleRestartMember( + _event: IpcMainInvokeEvent, + teamName: unknown, + memberName: unknown +): Promise> { + const validatedTeamName = validateTeamName(teamName); + if (!validatedTeamName.valid) { + return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; + } + const validatedMemberName = validateMemberName(memberName); + if (!validatedMemberName.valid) { + return { success: false, error: validatedMemberName.error ?? 'Invalid memberName' }; + } + return wrapTeamHandler('restartMember', async () => + getTeamProvisioningService().restartMember(validatedTeamName.value!, validatedMemberName.value!) + ); +} + async function handleStopTeam( _event: IpcMainInvokeEvent, teamName: unknown @@ -2733,6 +2935,7 @@ async function handleStopTeam( } return wrapTeamHandler('stop', async () => { addMainBreadcrumb('team', 'stop', { teamName: validated.value! }); + getAutoResumeService().cancelPendingAutoResume(validated.value!); getTeamProvisioningService().stopTeam(validated.value!); }); } diff --git a/src/main/services/extensions/apikeys/ApiKeyService.ts b/src/main/services/extensions/apikeys/ApiKeyService.ts index 19c4500a..bb9280fa 100644 --- a/src/main/services/extensions/apikeys/ApiKeyService.ts +++ b/src/main/services/extensions/apikeys/ApiKeyService.ts @@ -88,7 +88,7 @@ export class ApiKeyService { ); } if (!request.value) throw new Error('Key value is required'); - if (request.scope === 'project' && (!request.projectPath || !request.projectPath.trim())) { + if (request.scope === 'project' && !request.projectPath?.trim()) { throw new Error('Project-scoped API keys require a project path'); } diff --git a/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts b/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts index 9fb3e4ce..716236db 100644 --- a/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts +++ b/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts @@ -21,7 +21,6 @@ async function buildManagementCliEnvForBinary(binaryPath: string): Promise; diff --git a/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts b/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts index e35d6b7f..e43c87bf 100644 --- a/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts +++ b/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts @@ -48,7 +48,6 @@ function isSensitiveCliFlag(flag: string): boolean { const normalizedFlag = flag.toLowerCase().replace(/^--/, '').replace(/[-_]/g, ''); return SENSITIVE_FLAG_NAMES.has(normalizedFlag); } - function extractJsonObject(raw: string): T { const trimmed = raw.trim(); try { diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index 74f47017..96cc048d 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -62,6 +62,12 @@ export interface NotificationConfig { notifyOnTeamLaunched: boolean; /** Whether to show native OS notifications when a tool needs user approval */ notifyOnToolApproval: boolean; + /** Whether to automatically resume a rate-limited team when the limit resets. + * When enabled, the app parses the reset time from Claude's rate-limit + * message and schedules a nudge to the team lead once the limit expires. + * Default is `false` — opt-in to avoid unexpected API usage after the reset. + */ + autoResumeOnRateLimit: boolean; /** Only notify on status changes in solo teams (no teammates) */ statusChangeOnlySolo: boolean; /** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */ @@ -306,6 +312,7 @@ const DEFAULT_CONFIG: AppConfig = { notifyOnCrossTeamMessage: true, notifyOnTeamLaunched: true, notifyOnToolApproval: true, + autoResumeOnRateLimit: false, statusChangeOnlySolo: false, statusChangeStatuses: ['in_progress', 'completed'], triggers: DEFAULT_TRIGGERS, @@ -502,8 +509,55 @@ export class ConfigManager { return { notifications: { - ...DEFAULT_CONFIG.notifications, - ...loadedNotifications, + enabled: loadedNotifications.enabled ?? DEFAULT_CONFIG.notifications.enabled, + soundEnabled: loadedNotifications.soundEnabled ?? DEFAULT_CONFIG.notifications.soundEnabled, + ignoredRegex: loadedNotifications.ignoredRegex ?? DEFAULT_CONFIG.notifications.ignoredRegex, + ignoredRepositories: + loadedNotifications.ignoredRepositories ?? + DEFAULT_CONFIG.notifications.ignoredRepositories, + snoozedUntil: loadedNotifications.snoozedUntil ?? DEFAULT_CONFIG.notifications.snoozedUntil, + snoozeMinutes: + loadedNotifications.snoozeMinutes ?? DEFAULT_CONFIG.notifications.snoozeMinutes, + includeSubagentErrors: + loadedNotifications.includeSubagentErrors ?? + DEFAULT_CONFIG.notifications.includeSubagentErrors, + notifyOnLeadInbox: + loadedNotifications.notifyOnLeadInbox ?? DEFAULT_CONFIG.notifications.notifyOnLeadInbox, + notifyOnUserInbox: + loadedNotifications.notifyOnUserInbox ?? DEFAULT_CONFIG.notifications.notifyOnUserInbox, + notifyOnClarifications: + loadedNotifications.notifyOnClarifications ?? + DEFAULT_CONFIG.notifications.notifyOnClarifications, + notifyOnStatusChange: + loadedNotifications.notifyOnStatusChange ?? + DEFAULT_CONFIG.notifications.notifyOnStatusChange, + notifyOnTaskComments: + loadedNotifications.notifyOnTaskComments ?? + DEFAULT_CONFIG.notifications.notifyOnTaskComments, + notifyOnTaskCreated: + loadedNotifications.notifyOnTaskCreated ?? + DEFAULT_CONFIG.notifications.notifyOnTaskCreated, + notifyOnAllTasksCompleted: + loadedNotifications.notifyOnAllTasksCompleted ?? + DEFAULT_CONFIG.notifications.notifyOnAllTasksCompleted, + notifyOnCrossTeamMessage: + loadedNotifications.notifyOnCrossTeamMessage ?? + DEFAULT_CONFIG.notifications.notifyOnCrossTeamMessage, + notifyOnTeamLaunched: + loadedNotifications.notifyOnTeamLaunched ?? + DEFAULT_CONFIG.notifications.notifyOnTeamLaunched, + notifyOnToolApproval: + loadedNotifications.notifyOnToolApproval ?? + DEFAULT_CONFIG.notifications.notifyOnToolApproval, + autoResumeOnRateLimit: + loadedNotifications.autoResumeOnRateLimit ?? + DEFAULT_CONFIG.notifications.autoResumeOnRateLimit, + statusChangeOnlySolo: + loadedNotifications.statusChangeOnlySolo ?? + DEFAULT_CONFIG.notifications.statusChangeOnlySolo, + statusChangeStatuses: + loadedNotifications.statusChangeStatuses ?? + DEFAULT_CONFIG.notifications.statusChangeStatuses, triggers: mergedTriggers, }, general: mergedGeneral, diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index 1f493c96..ba3ce0ab 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -543,10 +543,10 @@ export class NotificationManager extends EventEmitter { logger.debug(`[test-notification] creating Notification (platform=${process.platform})`); const notification = new NotificationClass({ title: 'Test Notification', - ...(isMac ? { subtitle: 'Claude Agent Teams UI' } : {}), + ...(isMac ? { subtitle: 'Agent Teams UI' } : {}), body: isMac ? 'Notifications are working correctly!' - : 'Claude Agent Teams UI\nNotifications are working correctly!', + : 'Agent Teams UI\nNotifications are working correctly!', ...(iconPath ? { icon: iconPath } : {}), }); diff --git a/src/main/services/runtime/CliProviderModelAvailabilityService.ts b/src/main/services/runtime/CliProviderModelAvailabilityService.ts index 75ee0d79..0637839a 100644 --- a/src/main/services/runtime/CliProviderModelAvailabilityService.ts +++ b/src/main/services/runtime/CliProviderModelAvailabilityService.ts @@ -161,7 +161,7 @@ function classifyFailedProbe( export class CliProviderModelAvailabilityService { private readonly cache = new Map(); - private readonly queue: Array<() => void> = []; + private readonly queue: (() => void)[] = []; private activeProbeCount = 0; constructor(private readonly onUpdate?: ProviderAvailabilityUpdateHandler) {} diff --git a/src/main/services/team/AutoResumeService.ts b/src/main/services/team/AutoResumeService.ts new file mode 100644 index 00000000..0ec6f237 --- /dev/null +++ b/src/main/services/team/AutoResumeService.ts @@ -0,0 +1,209 @@ +import { createLogger } from '@shared/utils/logger'; +import { parseRateLimitResetTime } from '@shared/utils/rateLimitDetector'; + +import { ConfigManager } from '../infrastructure/ConfigManager'; + +import type { TeamProvisioningService } from './TeamProvisioningService'; + +const logger = createLogger('Service:AutoResume'); + +const AUTO_RESUME_BUFFER_MS = 30 * 1000; +const AUTO_RESUME_MAX_DELAY_MS = 12 * 60 * 60 * 1000; +const AUTO_RESUME_HISTORY_FRESH_MS = 5 * 1000; +const AUTO_RESUME_MESSAGE = + 'Your rate limit has reset. Please resume the work you were doing before the limit was hit.'; + +interface PendingAutoResumeEntry { + timer: NodeJS.Timeout; + fireAtMs: number; + sourceMessageAtMs: number; + sourceRunId: string | null; +} + +type AutoResumeProvisioning = Pick< + TeamProvisioningService, + 'getCurrentRunId' | 'isTeamAlive' | 'sendMessageToTeam' +>; +type AutoResumeConfigReader = Pick; + +export class AutoResumeService { + private readonly pendingTimers = new Map(); + + constructor( + private readonly provisioningService: AutoResumeProvisioning, + private readonly configManager: AutoResumeConfigReader = ConfigManager.getInstance() + ) {} + + handleRateLimitMessage( + teamName: string, + messageText: string, + 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); + + if (existing && messageAtMs < existing.sourceMessageAtMs) { + logger.info( + `[auto-resume] Ignoring older rate-limit message for "${teamName}" because a newer timer is already pending` + ); + 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; + } + + 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) { + 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` + ); + return; + } + + if ( + existing?.fireAtMs === fireAtMs && + existing.sourceMessageAtMs === messageAtMs && + existing.sourceRunId === sourceRunId + ) { + return; + } + + if (existing) { + clearTimeout(existing.timer); + this.pendingTimers.delete(teamName); + logger.info( + `[auto-resume] Rescheduling resume for "${teamName}" to ${resetTime.toISOString()}` + ); + } else { + logger.info( + `[auto-resume] Scheduling resume for "${teamName}" at ${resetTime.toISOString()} (in ${Math.round(delayMs / 1000)}s)` + ); + } + + const timer = setTimeout(() => { + this.pendingTimers.delete(teamName); + void this.fireResumeNudge(teamName, sourceRunId); + }, delayMs); + + this.pendingTimers.set(teamName, { + timer, + fireAtMs, + sourceMessageAtMs: messageAtMs, + sourceRunId, + }); + } + + cancelPendingAutoResume(teamName: string): void { + const pending = this.pendingTimers.get(teamName); + if (!pending) return; + clearTimeout(pending.timer); + this.pendingTimers.delete(teamName); + } + + clearAllPendingAutoResume(): void { + for (const pending of this.pendingTimers.values()) { + clearTimeout(pending.timer); + } + this.pendingTimers.clear(); + } + + private async fireResumeNudge(teamName: string, sourceRunId: string | null): Promise { + const current = this.configManager.getConfig(); + if (!current.notifications.autoResumeOnRateLimit) { + logger.info( + `[auto-resume] Config flag was disabled while timer was pending - skipping nudge for "${teamName}"` + ); + return; + } + + try { + if (!this.provisioningService.isTeamAlive(teamName)) { + logger.info( + `[auto-resume] Team "${teamName}" is no longer alive at fire time - skipping resume nudge` + ); + return; + } + const currentRunId = this.provisioningService.getCurrentRunId(teamName); + if (sourceRunId && currentRunId !== sourceRunId) { + logger.info( + `[auto-resume] Team "${teamName}" advanced from run "${sourceRunId}" to "${currentRunId ?? 'none'}" before fire time - skipping stale resume nudge` + ); + return; + } + await this.provisioningService.sendMessageToTeam(teamName, AUTO_RESUME_MESSAGE); + logger.info(`[auto-resume] Sent resume nudge to "${teamName}"`); + } catch (error) { + logger.error( + `[auto-resume] Failed to send resume nudge to "${teamName}": ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } +} + +let autoResumeService: AutoResumeService | null = null; + +export function initializeAutoResumeService( + provisioningService: AutoResumeProvisioning +): AutoResumeService { + autoResumeService?.clearAllPendingAutoResume(); + autoResumeService = new AutoResumeService(provisioningService); + return autoResumeService; +} + +export function getAutoResumeService(): AutoResumeService { + if (!autoResumeService) { + throw new Error('AutoResumeService is not initialized'); + } + return autoResumeService; +} + +export function peekAutoResumeService(): AutoResumeService | null { + return autoResumeService; +} + +export function clearAutoResumeService(): void { + autoResumeService?.clearAllPendingAutoResume(); + autoResumeService = null; +} diff --git a/src/main/services/team/MemberActivityMetaService.ts b/src/main/services/team/MemberActivityMetaService.ts new file mode 100644 index 00000000..3a91dc29 --- /dev/null +++ b/src/main/services/team/MemberActivityMetaService.ts @@ -0,0 +1,128 @@ +import type { TeamMessageFeedService } from './TeamMessageFeedService'; +import type { InboxMessage, MemberActivityMetaEntry, TeamMemberActivityMeta } from '@shared/types'; + +interface MemberActivityMetaCacheEntry { + feedRevision: string; + meta: TeamMemberActivityMeta; +} + +function messageSignalsTermination(message: InboxMessage | null | undefined): boolean { + if (!message) return false; + try { + const parsed = JSON.parse(message.text) as { + type?: string; + approve?: boolean; + approved?: boolean; + }; + return ( + (parsed.type === 'shutdown_response' && + (parsed.approve === true || parsed.approved === true)) || + parsed.type === 'shutdown_approved' + ); + } catch { + return false; + } +} + +function areMemberActivityEntriesEqual( + left: MemberActivityMetaEntry | undefined, + right: MemberActivityMetaEntry +): boolean { + if (!left) { + return false; + } + return ( + left.memberName === right.memberName && + left.lastAuthoredMessageAt === right.lastAuthoredMessageAt && + left.messageCountExact === right.messageCountExact && + left.latestAuthoredMessageSignalsTermination === right.latestAuthoredMessageSignalsTermination + ); +} + +function structurallyShareMemberFacts( + previous: Record | undefined, + next: Record +): Record { + if (!previous) { + return next; + } + + const nextKeys = Object.keys(next); + const previousKeys = Object.keys(previous); + let changed = nextKeys.length !== previousKeys.length; + const shared: Record = {}; + + for (const key of nextKeys) { + const nextEntry = next[key]; + const previousEntry = previous[key]; + if (!areMemberActivityEntriesEqual(previousEntry, nextEntry)) { + changed = true; + shared[key] = nextEntry; + continue; + } + shared[key] = previousEntry; + } + + return changed ? shared : previous; +} + +export class MemberActivityMetaService { + private readonly cacheByTeam = new Map(); + + constructor(private readonly feedService: TeamMessageFeedService) {} + + invalidate(teamName: string): void { + this.cacheByTeam.delete(teamName); + } + + async getMeta(teamName: string): Promise { + const feed = await this.feedService.getFeed(teamName); + const cached = this.cacheByTeam.get(teamName); + if (cached?.feedRevision === feed.feedRevision) { + return cached.meta; + } + + const latestByMember = new Map(); + const countsByMember = new Map(); + + for (const message of feed.messages) { + const memberName = typeof message.from === 'string' ? message.from.trim() : ''; + if (!memberName || memberName === 'user' || memberName === 'system') { + continue; + } + + countsByMember.set(memberName, (countsByMember.get(memberName) ?? 0) + 1); + if (!latestByMember.has(memberName)) { + latestByMember.set(memberName, message); + } + } + + const nextMembers = Object.fromEntries( + Array.from(new Set([...countsByMember.keys(), ...latestByMember.keys()])) + .sort((left, right) => left.localeCompare(right)) + .map((memberName) => { + const latestMessage = latestByMember.get(memberName) ?? null; + return [ + memberName, + { + memberName, + lastAuthoredMessageAt: latestMessage?.timestamp ?? null, + messageCountExact: countsByMember.get(memberName) ?? 0, + latestAuthoredMessageSignalsTermination: messageSignalsTermination(latestMessage), + }, + ] as const; + }) + ); + const members = structurallyShareMemberFacts(cached?.meta.members, nextMembers); + + const meta: TeamMemberActivityMeta = { + teamName, + computedAt: new Date().toISOString(), + members, + feedRevision: feed.feedRevision, + }; + + this.cacheByTeam.set(teamName, { feedRevision: feed.feedRevision, meta }); + return meta; + } +} diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 307cfa67..5d29e6db 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -1,12 +1,5 @@ import { yieldToEventLoop } from '@main/utils/asyncYield'; -import { - encodePath, - extractBaseDir, - getClaudeBasePath, - getProjectsBasePath, - getTasksBasePath, - getTeamsBasePath, -} from '@main/utils/pathDecoder'; +import { getClaudeBasePath, getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder'; import { killProcessByPid } from '@main/utils/processKill'; import { AGENT_BLOCK_CLOSE, @@ -16,7 +9,7 @@ import { } from '@shared/constants/agentBlocks'; import { getMemberColorByName } from '@shared/constants/memberColors'; import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics'; -import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; +import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState'; import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; @@ -39,6 +32,11 @@ import { } from './cache/LeadSessionParseCache'; import { atomicWriteAsync } from './atomicWrite'; import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor'; +import { MemberActivityMetaService } from './MemberActivityMetaService'; +import { + getLiveLeadProcessMessageKey, + mergeLiveLeadProcessMessages, +} from './mergeLiveLeadProcessMessages'; import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils'; import { TeamConfigReader } from './TeamConfigReader'; import { TeamInboxReader } from './TeamInboxReader'; @@ -47,11 +45,13 @@ import { TeamKanbanManager } from './TeamKanbanManager'; import { TeamMemberResolver } from './TeamMemberResolver'; import { TeamMemberRuntimeAdvisoryService } from './TeamMemberRuntimeAdvisoryService'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; +import { TeamMessageFeedService } from './TeamMessageFeedService'; import { TeamMetaStore } from './TeamMetaStore'; import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal'; import { TeamTaskReader } from './TeamTaskReader'; import { TeamTaskWriter } from './TeamTaskWriter'; +import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes'; import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository'; @@ -65,7 +65,6 @@ import type { KanbanColumnId, KanbanState, MessagesPage, - ResolvedTeamMember, SendMessageRequest, SendMessageResult, TaskAttachmentMeta, @@ -74,13 +73,14 @@ import type { TaskRef, TeamConfig, TeamCreateConfigRequest, - TeamData, TeamMember, + TeamMemberActivityMeta, TeamProcess, TeamSummary, TeamTask, TeamTaskStatus, TeamTaskWithKanban, + TeamViewSnapshot, ToolCallMeta, UpdateKanbanPatch, } from '@shared/types'; @@ -98,6 +98,14 @@ const TASK_MAP_YIELD_EVERY = 250; const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification'; const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000; +function requireCanonicalMessageId(message: InboxMessage): string { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (messageId.length > 0) { + return messageId; + } + throw new Error('Canonical team message is missing effective messageId'); +} + interface EligibleTaskCommentNotification { key: string; messageId: string; @@ -162,6 +170,8 @@ export class TeamDataService { private taskChangePresenceRepository: TaskChangePresenceRepository | null = null; private teamLogSourceTracker: TeamLogSourceTracker | null = null; private fileWatchReconcileDiagnostics = new Map(); + private readonly messageFeedService: TeamMessageFeedService; + private readonly memberActivityMetaService: MemberActivityMetaService; constructor( private readonly configReader: TeamConfigReader = new TeamConfigReader(), @@ -182,8 +192,19 @@ export class TeamDataService { private readonly taskCommentNotificationJournal: TeamTaskCommentNotificationJournal = new TeamTaskCommentNotificationJournal(), private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(), private memberRuntimeAdvisoryService: TeamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(), - private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache() - ) {} + private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache(), + private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver( + configReader + ) + ) { + this.messageFeedService = new TeamMessageFeedService({ + getConfig: (teamName) => this.configReader.getConfig(teamName), + getInboxMessages: (teamName) => this.inboxReader.getMessages(teamName), + getLeadSessionMessages: (teamName, config) => this.extractLeadSessionTexts(teamName, config), + getSentMessages: (teamName) => this.sentMessagesStore.readMessages(teamName), + }); + this.memberActivityMetaService = new MemberActivityMetaService(this.messageFeedService); + } private getController(teamName: string): AgentTeamsController { return this.controllerFactory(teamName); @@ -622,7 +643,7 @@ export class TeamDataService { await fs.promises.rm(tasksDir, { recursive: true, force: true }); } - async getTeamData(teamName: string): Promise { + async getTeamData(teamName: string): Promise { const startedAt = Date.now(); const marks: Record = {}; const mark = (label: string): void => { @@ -726,12 +747,6 @@ export class TeamDataService { warningText: 'Inboxes failed to load', load: () => this.inboxReader.listInboxNames(teamName), }); - const sentMessagesStep = startReadStep({ - label: 'sentMessages', - createFallback: () => [], - warningText: 'Sent messages failed to load', - load: () => this.sentMessagesStore.readMessages(teamName), - }); const metaMembersStep = startReadStep({ label: 'metaMembers', createFallback: () => [], @@ -756,40 +771,8 @@ export class TeamDataService { load: () => this.taskReader.getTasks(teamName), }) ); - const messagesStep = runWithConcurrencyLimit(() => - startReadStep({ - label: 'messages', - createFallback: () => [], - warningText: 'Messages failed to load', - load: () => this.inboxReader.getMessages(teamName), - }) - ); - const leadTextsStep = runWithConcurrencyLimit(() => - startReadStep({ - label: 'leadTexts', - createFallback: () => [], - warningText: 'Lead session texts failed to load', - load: () => this.extractLeadSessionTexts(config), - }) - ); - - const [ - tasksStepResult, - inboxNamesStepResult, - messagesStepResult, - leadTextsStepResult, - sentMessagesStepResult, - metaMembersStepResult, - kanbanStateStepResult, - ] = await Promise.all([ - tasksStep, - inboxNamesStep, - messagesStep, - leadTextsStep, - sentMessagesStep, - metaMembersStep, - kanbanStateStep, - ]); + const [tasksStepResult, inboxNamesStepResult, metaMembersStepResult, kanbanStateStepResult] = + await Promise.all([tasksStep, inboxNamesStep, metaMembersStep, kanbanStateStep]); // After parallelizing the top read phase, these marks no longer represent // serial stage boundaries. They now capture the actual completion time for @@ -797,178 +780,18 @@ export class TeamDataService { // diagnostics useful without mutating marks from concurrent branches. marks.tasks = tasksStepResult.completedAt; marks.inboxNames = inboxNamesStepResult.completedAt; - marks.messages = messagesStepResult.completedAt; - marks.leadTexts = leadTextsStepResult.completedAt; - marks.sentMessages = sentMessagesStepResult.completedAt; marks.metaMembers = metaMembersStepResult.completedAt; marks.kanbanState = kanbanStateStepResult.completedAt; if (tasksStepResult.warning) warnings.push(tasksStepResult.warning); if (inboxNamesStepResult.warning) warnings.push(inboxNamesStepResult.warning); - if (messagesStepResult.warning) warnings.push(messagesStepResult.warning); - if (leadTextsStepResult.warning) warnings.push(leadTextsStepResult.warning); - if (sentMessagesStepResult.warning) warnings.push(sentMessagesStepResult.warning); if (metaMembersStepResult.warning) warnings.push(metaMembersStepResult.warning); if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning); const tasks: TeamTask[] = tasksStepResult.value; const inboxNames: string[] = inboxNamesStepResult.value; - let messages: InboxMessage[] = messagesStepResult.value; - const leadTexts: InboxMessage[] = leadTextsStepResult.value; - const sentMessages: InboxMessage[] = sentMessagesStepResult.value; mark('postStart'); - if (leadTexts.length > 0) { - messages = [...messages, ...leadTexts]; - } - if (sentMessages.length > 0) { - messages = [...messages, ...sentMessages]; - } - mark('mergeMessages'); - - // Dedup: if a lead_process message text is also present in lead_session, prefer lead_session. - // This avoids double-rendering when we persist lead process messages and later load the lead JSONL. - // Exception: lead_process messages with `to` field are captured SendMessage — never dedup those. - if (leadTexts.length > 0) { - const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); - const getLeadThoughtFingerprint = ( - msg: Pick - ) => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`; - const leadSessionFingerprints = new Set(); - for (const msg of leadTexts) { - if (msg.source !== 'lead_session') continue; - leadSessionFingerprints.add(getLeadThoughtFingerprint(msg)); - } - messages = messages.filter((m) => { - if (m.source !== 'lead_process') return true; - // Captured SendMessage messages (with recipient) are real messages — never dedup - if (m.to) return true; - const fp = getLeadThoughtFingerprint(m); - return !leadSessionFingerprints.has(fp); - }); - } - mark('dedupLeadTexts'); - - // Dedup exact message copies that can appear as both live lead_process rows and - // their persisted inbox/sent-message counterpart. If the messageId is identical, - // keep a single row so the UI does not show the same SendMessage twice - // (for example "LIVE" plus the stored copy). - const duplicateMessageIds = new Set(); - const messageIdCounts = new Map(); - for (const msg of messages) { - const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : ''; - if (!id) continue; - const nextCount = (messageIdCounts.get(id) ?? 0) + 1; - messageIdCounts.set(id, nextCount); - if (nextCount > 1) duplicateMessageIds.add(id); - } - if (duplicateMessageIds.size > 0) { - const choosePreferredMessage = ( - current: InboxMessage, - candidate: InboxMessage - ): InboxMessage => { - const score = (msg: InboxMessage): number => { - let value = 0; - if (msg.source !== 'lead_process') value += 4; - if (msg.read === false) value += 2; - if (msg.relayOfMessageId) value += 1; - if (msg.summary) value += 1; - if (msg.to) value += 1; - return value; - }; - const currentScore = score(current); - const candidateScore = score(candidate); - if (candidateScore !== currentScore) { - return candidateScore > currentScore ? candidate : current; - } - const currentTs = Date.parse(current.timestamp); - const candidateTs = Date.parse(candidate.timestamp); - if ( - Number.isFinite(currentTs) && - Number.isFinite(candidateTs) && - candidateTs !== currentTs - ) { - return candidateTs > currentTs ? candidate : current; - } - return current; - }; - - const dedupedById = new Map(); - const dedupedWithoutId: InboxMessage[] = []; - for (const msg of messages) { - const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : ''; - if (!id) { - dedupedWithoutId.push(msg); - continue; - } - const existing = dedupedById.get(id); - if (!existing) { - dedupedById.set(id, msg); - continue; - } - dedupedById.set(id, choosePreferredMessage(existing, msg)); - } - messages = [...dedupedWithoutId, ...dedupedById.values()]; - } - mark('dedupMessageIds'); - - messages = this.linkPassiveUserReplySummaries(messages); - mark('linkPassiveUserReplySummaries'); - - // Enrich inbox messages without leadSessionId by assigning the nearest neighbor's - // session ID (by timestamp). This avoids the old forward-only propagation bug. - if (config.leadSessionId || messages.some((m) => m.leadSessionId)) { - messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); - - const anchors: { index: number; time: number; sessionId: string }[] = []; - for (let i = 0; i < messages.length; i++) { - if (messages[i].leadSessionId) { - anchors.push({ - index: i, - time: Date.parse(messages[i].timestamp), - sessionId: messages[i].leadSessionId!, - }); - } - } - - if (anchors.length > 0) { - let anchorIdx = 0; - for (let i = 0; i < messages.length; i++) { - if (messages[i].leadSessionId) { - while (anchorIdx < anchors.length - 1 && anchors[anchorIdx].index < i) { - anchorIdx++; - } - continue; - } - - const msgTime = Date.parse(messages[i].timestamp); - let bestAnchor = anchors[0]; - let bestDist = Math.abs(msgTime - bestAnchor.time); - for (const anchor of anchors) { - const dist = Math.abs(msgTime - anchor.time); - if (dist < bestDist) { - bestDist = dist; - bestAnchor = anchor; - } else if (dist > bestDist && anchor.time > msgTime) { - break; - } - } - messages[i].leadSessionId = bestAnchor.sessionId; - } - } else if (config.leadSessionId) { - for (const msg of messages) { - msg.leadSessionId = config.leadSessionId; - } - } - } - mark('attachLeadSessionIds'); - - messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); - this.annotateSlashCommandResponses(messages); - - messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); - mark('normalizeMessages'); - const metaMembers: TeamConfig['members'] = metaMembersStepResult.value; const kanbanState: KanbanState = kanbanStateStepResult.value; @@ -1000,8 +823,7 @@ export class TeamDataService { config, metaMembers, inboxNames, - tasksWithKanban, - messages + tasksWithKanban ); mark('resolveMembers'); @@ -1036,30 +858,13 @@ export class TeamDataService { const totalMs = Date.now() - startedAt; if (totalMs >= 1500) { - const counts = `counts=tasks:${tasks.length},messages:${messages.length},inboxNames:${inboxNames.length},leadTexts:${leadTexts.length},sent:${sentMessages.length},members:${members.length},processes:${processes.length}`; + const counts = `counts=tasks:${tasks.length},inboxNames:${inboxNames.length},members:${members.length},processes:${processes.length}`; logger.warn( `getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince( 'inboxNames' - )} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince( - 'sentMessages' )} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince( 'kanbanGc' - )} post=${msBetween( - 'postStart', - 'mergeMessages' - )}/dedupLead=${msBetween('mergeMessages', 'dedupLeadTexts')}/dedupIds=${msBetween( - 'dedupLeadTexts', - 'dedupMessageIds' - )}/attachLeadSession=${msBetween( - 'dedupMessageIds', - 'attachLeadSessionIds' - )}/normalizeMessages=${msBetween( - 'attachLeadSessionIds', - 'normalizeMessages' - )}/attachKanban=${msBetween( - 'normalizeMessages', - 'attachKanban' - )}/loadPresenceIndex=${msBetween( + )} post=${msBetween('postStart', 'attachKanban')}/loadPresenceIndex=${msBetween( 'attachKanban', 'loadPresenceIndex' )}/changePresence=${msBetween( @@ -1088,21 +893,14 @@ export class TeamDataService { this.processHealthTeams.delete(teamName); } - // Cap messages to keep IPC payloads small. Full history is available - // via the paginated getMessagesPage() API. We still include a small - // batch here for backward compatibility (notifications, dedup, etc.). - const MAX_RETURN_MESSAGES = 50; - const cappedMessages = - messages.length > MAX_RETURN_MESSAGES ? messages.slice(0, MAX_RETURN_MESSAGES) : messages; - return { teamName, config, tasks: tasksWithKanban, members, - messages: cappedMessages, kanbanState, processes, + isAlive: hasAlive, warnings: warnings.length > 0 ? warnings : undefined, }; } @@ -1113,106 +911,103 @@ export class TeamDataService { */ async getMessagesPage( teamName: string, - options: { beforeTimestamp?: string; limit: number } + options: { cursor?: string | null; limit: number; liveMessages?: InboxMessage[] } ): Promise { - const config = await this.configReader.getConfig(teamName); - if (!config) { - return { messages: [], nextCursor: null, hasMore: false }; - } + const feed = await this.messageFeedService.getFeed(teamName); + const newestDurableMessages = feed.messages; + const durableMessageIndexByKey = new Map( + newestDurableMessages.map((message, index) => [getLiveLeadProcessMessageKey(message), index]) + ); + let messages = newestDurableMessages; - // Collect all messages from the same sources as getTeamData - let messages: InboxMessage[] = []; - - const [inboxMessages, leadTexts, sentMessages] = await Promise.all([ - this.inboxReader.getMessages(teamName).catch(() => [] as InboxMessage[]), - this.extractLeadSessionTexts(config).catch(() => [] as InboxMessage[]), - this.sentMessagesStore.readMessages(teamName).catch(() => [] as InboxMessage[]), - ]); - - messages = [...inboxMessages, ...leadTexts, ...sentMessages]; - - // Dedup lead_session vs lead_process (same logic as getTeamData) - if (leadTexts.length > 0) { - const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); - const getFingerprint = (msg: Pick) => - `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`; - const leadSessionFingerprints = new Set(); - for (const msg of leadTexts) { - if (msg.source === 'lead_session') leadSessionFingerprints.add(getFingerprint(msg)); - } - messages = messages.filter((m) => { - if (m.source !== 'lead_process') return true; - if (m.to) return true; - return !leadSessionFingerprints.has(getFingerprint(m)); - }); - } - - // Enrich: propagate leadSessionId to messages missing it (same as getTeamData) - if (config.leadSessionId || messages.some((m) => m.leadSessionId)) { - messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); - const anchors: { time: number; sessionId: string }[] = []; - for (const msg of messages) { - if (msg.leadSessionId) { - anchors.push({ time: Date.parse(msg.timestamp), sessionId: msg.leadSessionId }); - } - } - if (anchors.length > 0) { - for (const msg of messages) { - if (msg.leadSessionId) continue; - const msgTime = Date.parse(msg.timestamp); - let best = anchors[0]; - let bestDist = Math.abs(msgTime - best.time); - for (const a of anchors) { - const dist = Math.abs(msgTime - a.time); - if (dist < bestDist) { - bestDist = dist; - best = a; - } else if (dist > bestDist && a.time > msgTime) { - break; - } - } - msg.leadSessionId = best.sessionId; - } - } else if (config.leadSessionId) { - for (const msg of messages) { - msg.leadSessionId = config.leadSessionId; - } - } - } - - // Enrich: annotate slash command responses - this.annotateSlashCommandResponses(messages); - - // Sort newest-first, with stable tie-breaker by messageId - messages.sort((a, b) => { - const diff = Date.parse(b.timestamp) - Date.parse(a.timestamp); - if (diff !== 0) return diff; - return (a.messageId ?? '').localeCompare(b.messageId ?? ''); - }); - - // Apply cursor filter. Cursor format: "timestamp|messageId" (compound) - // to handle multiple messages sharing the same timestamp. - if (options.beforeTimestamp) { - const [cursorTs, cursorId] = options.beforeTimestamp.split('|'); + if (options.cursor) { + const [cursorTs, cursorId] = options.cursor.split('|'); const cursorMs = Date.parse(cursorTs); messages = messages.filter((m) => { const ms = Date.parse(m.timestamp); if (ms < cursorMs) return true; if (ms > cursorMs) return false; - // Same timestamp — use messageId tie-breaker if (!cursorId) return false; - return (m.messageId ?? '').localeCompare(cursorId) > 0; + return requireCanonicalMessageId(m).localeCompare(cursorId) > 0; }); } - // Paginate const hasMore = messages.length > options.limit; const page = messages.slice(0, options.limit); const lastMsg = page[page.length - 1]; const nextCursor = - hasMore && lastMsg ? `${lastMsg.timestamp}|${lastMsg.messageId ?? ''}` : null; + hasMore && lastMsg ? `${lastMsg.timestamp}|${requireCanonicalMessageId(lastMsg)}` : null; - return { messages: page, nextCursor, hasMore }; + if (options.cursor || !options.liveMessages?.length) { + return { messages: page, nextCursor, hasMore, feedRevision: feed.feedRevision }; + } + + // Merge live lead thoughts against the full durable newest-page history so we do not + // re-introduce persisted thoughts that have simply paged off the first durable page. + const displayMessages = mergeLiveLeadProcessMessages( + newestDurableMessages, + options.liveMessages + ).slice(0, options.limit); + + if (displayMessages.length === 0) { + return { + messages: displayMessages, + nextCursor: null, + hasMore: false, + feedRevision: feed.feedRevision, + }; + } + + let lastDurableDisplayed: InboxMessage | null = null; + for (let index = displayMessages.length - 1; index >= 0; index -= 1) { + const candidate = displayMessages[index]; + if (durableMessageIndexByKey.has(getLiveLeadProcessMessageKey(candidate))) { + lastDurableDisplayed = candidate; + break; + } + } + + if (!lastDurableDisplayed) { + const boundary = displayMessages[displayMessages.length - 1]; + return { + messages: displayMessages, + nextCursor: + newestDurableMessages.length > 0 + ? `${boundary.timestamp}|${boundary.messageId ?? ''}` + : null, + hasMore: newestDurableMessages.length > 0, + feedRevision: feed.feedRevision, + }; + } + + const durableIndex = + durableMessageIndexByKey.get(getLiveLeadProcessMessageKey(lastDurableDisplayed)) ?? + Number.POSITIVE_INFINITY; + const durableHasMore = durableIndex < newestDurableMessages.length - 1; + + return { + messages: displayMessages, + nextCursor: durableHasMore + ? `${lastDurableDisplayed.timestamp}|${lastDurableDisplayed.messageId ?? ''}` + : null, + hasMore: durableHasMore, + feedRevision: feed.feedRevision, + }; + } + + async getMessageFeed( + teamName: string + ): Promise<{ teamName: string; feedRevision: string; messages: InboxMessage[] }> { + return this.messageFeedService.getFeed(teamName); + } + + async getMemberActivityMeta(teamName: string): Promise { + return this.memberActivityMetaService.getMeta(teamName); + } + + invalidateMessageFeed(teamName: string): void { + this.messageFeedService.invalidate(teamName); + this.memberActivityMetaService.invalidate(teamName); } /** @@ -1220,7 +1015,7 @@ export class TeamDataService { * Mutates members in-place for efficiency (called right after resolveMembers). */ private async enrichMemberBranches( - members: ResolvedTeamMember[], + members: TeamViewSnapshot['members'], config: TeamConfig ): Promise { const leadEntry = config.members?.find((member) => isLeadMember(member)); @@ -1892,7 +1687,7 @@ export class TeamDataService { slashCommand: slashCommandMeta, }; } - return this.getController(teamName).messages.sendMessage({ + const result = this.getController(teamName).messages.sendMessage({ member: enrichedRequest.member, from: enrichedRequest.from, text: enrichedRequest.text, @@ -1913,6 +1708,8 @@ export class TeamDataService { leadSessionId: enrichedRequest.leadSessionId, attachments: enrichedRequest.attachments, }) as SendMessageResult; + this.invalidateMessageFeed(teamName); + return result; } private resolveLeadNameFromConfig(config: TeamConfig | null): string { @@ -2469,6 +2266,23 @@ export class TeamDataService { } } + async getTeamNotificationContext(teamName: string): Promise<{ + displayName: string; + projectPath?: string; + }> { + try { + const config = await this.configReader.getConfig(teamName); + const displayName = config?.name?.trim() || teamName; + const projectPath = + typeof config?.projectPath === 'string' && config.projectPath.trim().length > 0 + ? config.projectPath + : undefined; + return { displayName, projectPath }; + } catch { + return { displayName: teamName }; + } + } + async requestReview(teamName: string, taskId: string): Promise { const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName); this.getController(teamName).review.requestReview(taskId, { @@ -2614,37 +2428,20 @@ export class TeamDataService { } } - private getLeadProjectDirCandidates(projectPath: string): string[] { - const projectId = encodePath(projectPath); - const baseDir = extractBaseDir(projectId); - const candidateDirs = [ - path.join(getProjectsBasePath(), baseDir), - // Claude Code encodes underscores as hyphens in project directory names; - // our encodePath only handles slashes. Try the underscore-to-hyphen variant. - ...(baseDir.includes('_') - ? [path.join(getProjectsBasePath(), baseDir.replace(/_/g, '-'))] - : []), - ]; - - return [...new Set(candidateDirs)]; - } - - private async getLeadSessionJsonlPaths(projectPath: string): Promise> { + private async getLeadSessionJsonlPaths(projectDir: string): Promise> { const jsonlPaths = new Map(); - for (const dirPath of this.getLeadProjectDirCandidates(projectPath)) { - let entries: fs.Dirent[]; - try { - entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); - } catch { - continue; - } + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(projectDir, { withFileTypes: true }); + } catch { + return jsonlPaths; + } - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue; - const sessionId = entry.name.slice(0, -'.jsonl'.length).trim(); - if (!sessionId || jsonlPaths.has(sessionId)) continue; - jsonlPaths.set(sessionId, path.join(dirPath, entry.name)); - } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue; + const sessionId = entry.name.slice(0, -'.jsonl'.length).trim(); + if (!sessionId || jsonlPaths.has(sessionId)) continue; + jsonlPaths.set(sessionId, path.join(projectDir, entry.name)); } return jsonlPaths; @@ -2890,17 +2687,25 @@ export class TeamDataService { } } - private async extractLeadSessionTexts(config: TeamConfig): Promise { - if (!config.projectPath) { + private async extractLeadSessionTexts( + teamName: string, + config: TeamConfig + ): Promise { + const transcriptContext = await this.projectResolver.getContext(teamName); + if (!transcriptContext) { return []; } - - const leadName = config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead'; - const sessionIds = this.getRecentLeadSessionIds(config); + const leadName = + transcriptContext.config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead'; + const knownLeadSessionIds = this.getRecentLeadSessionIds(config); + if (knownLeadSessionIds.length === 0) { + return []; + } + const sessionIds = knownLeadSessionIds; if (sessionIds.length === 0) { return []; } - const availableJsonlPaths = await this.getLeadSessionJsonlPaths(config.projectPath); + const availableJsonlPaths = await this.getLeadSessionJsonlPaths(transcriptContext.projectDir); if (availableJsonlPaths.size === 0) { return []; } diff --git a/src/main/services/team/TeamDataWorkerClient.ts b/src/main/services/team/TeamDataWorkerClient.ts index 89d60c73..9b69cc90 100644 --- a/src/main/services/team/TeamDataWorkerClient.ts +++ b/src/main/services/team/TeamDataWorkerClient.ts @@ -14,7 +14,12 @@ import { Worker } from 'node:worker_threads'; import { createLogger } from '@shared/utils/logger'; import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWorkerTypes'; -import type { MemberLogSummary, TeamData } from '@shared/types'; +import type { + MemberLogSummary, + MessagesPage, + TeamMemberActivityMeta, + TeamViewSnapshot, +} from '@shared/types'; const logger = createLogger('Service:TeamDataWorkerClient'); const WORKER_CALL_TIMEOUT_MS = 30_000; @@ -25,16 +30,20 @@ function makeId(): string { return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`; } -function resolveWorkerPath(): string | null { +function getWorkerPathCandidates(): string[] { const baseDir = typeof __dirname === 'string' && __dirname.length > 0 ? __dirname : path.dirname(fileURLToPath(import.meta.url)); - const candidates = [ + return [ path.join(baseDir, 'team-data-worker.cjs'), path.join(process.cwd(), 'dist-electron', 'main', 'team-data-worker.cjs'), ]; +} + +function resolveWorkerPath(): string | null { + const candidates = getWorkerPathCandidates(); for (const candidate of candidates) { try { @@ -75,7 +84,9 @@ export class TeamDataWorkerClient { isAvailable(): boolean { if (!this.workerPath && !this.warnedUnavailable) { this.warnedUnavailable = true; - logger.debug('team-data-worker not found; falling back to main-thread execution'); + logger.warn( + `team-data-worker not found; heavy team data paths may fall back to main-thread execution. expectedOneOf=${getWorkerPathCandidates().join(',')}` + ); } return this.workerPath !== null; } @@ -144,9 +155,22 @@ export class TeamDataWorkerClient { }); } - async getTeamData(teamName: string): Promise { + async getTeamData(teamName: string): Promise { if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName'); - return this.call('getTeamData', { teamName }) as Promise; + return this.call('getTeamData', { teamName }) as Promise; + } + + async getMessagesPage( + teamName: string, + options: { cursor?: string | null; limit: number } + ): Promise { + if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName'); + return this.call('getMessagesPage', { teamName, options }) as Promise; + } + + async getMemberActivityMeta(teamName: string): Promise { + if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName'); + return this.call('getMemberActivityMeta', { teamName }) as Promise; } async findLogsForTask( diff --git a/src/main/services/team/TeamLaunchStateEvaluator.ts b/src/main/services/team/TeamLaunchStateEvaluator.ts index c3ca6bb5..331d7f79 100644 --- a/src/main/services/team/TeamLaunchStateEvaluator.ts +++ b/src/main/services/team/TeamLaunchStateEvaluator.ts @@ -223,8 +223,7 @@ export function createPersistedLaunchSnapshot(params: { for (const name of expectedMembers) { const member = members[name]; if ( - member && - member.launchState === 'starting' && + member?.launchState === 'starting' && !member.agentToolAccepted && !member.runtimeAlive && !member.bootstrapConfirmed && diff --git a/src/main/services/team/TeamLogSourceTracker.ts b/src/main/services/team/TeamLogSourceTracker.ts index 1c5ee933..045cc007 100644 --- a/src/main/services/team/TeamLogSourceTracker.ts +++ b/src/main/services/team/TeamLogSourceTracker.ts @@ -14,13 +14,15 @@ import type { TeamChangeEvent } from '@shared/types'; import type { FSWatcher } from 'chokidar'; const logger = createLogger('Service:TeamLogSourceTracker'); +const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness'; +const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json'; interface TeamLogSourceSnapshot { projectFingerprint: string | null; logSourceGeneration: string | null; } -export type TeamLogSourceTrackingConsumer = 'change_presence' | 'tool_activity'; +export type TeamLogSourceTrackingConsumer = 'change_presence' | 'tool_activity' | 'task_log_stream'; interface TrackingState { watcher: FSWatcher | null; @@ -31,7 +33,7 @@ interface TrackingState { recomputePromise: Promise | null; recomputeVersion: number | null; snapshot: TeamLogSourceSnapshot; - consumers: Set; + consumerCounts: Map; lifecycleVersion: number; } @@ -67,19 +69,29 @@ export class TeamLogSourceTracker { consumer: TeamLogSourceTrackingConsumer ): Promise { const state = this.getOrCreateState(teamName); - if (!state.consumers.has(consumer)) { - state.consumers.add(consumer); + const activeConsumerCountBefore = this.getActiveConsumerCount(state); + state.consumerCounts.set(consumer, (state.consumerCounts.get(consumer) ?? 0) + 1); + if (activeConsumerCountBefore === 0) { state.lifecycleVersion += 1; } if ( state.initializePromise && state.initializeVersion === state.lifecycleVersion && - state.consumers.size > 0 + this.getActiveConsumerCount(state) > 0 ) { return state.initializePromise; } + if ( + activeConsumerCountBefore > 0 && + (state.watcher !== null || + state.projectDir !== null || + state.snapshot.logSourceGeneration !== null) + ) { + return { ...state.snapshot }; + } + const initializeVersion = state.lifecycleVersion; const initializePromise = this.initializeTeam(teamName, initializeVersion) .catch((error) => { @@ -118,13 +130,21 @@ export class TeamLogSourceTracker { recomputePromise: null, recomputeVersion: null, snapshot: { projectFingerprint: null, logSourceGeneration: null }, - consumers: new Set(), + consumerCounts: new Map(), lifecycleVersion: 0, }; this.stateByTeam.set(teamName, created); return created; } + private getActiveConsumerCount(state: TrackingState): number { + let count = 0; + for (const value of state.consumerCounts.values()) { + count += value; + } + return count; + } + async stopTracking(teamName: string): Promise { await this.disableTracking(teamName, 'change_presence'); } @@ -138,15 +158,24 @@ export class TeamLogSourceTracker { return { projectFingerprint: null, logSourceGeneration: null }; } - if (state.consumers.has(consumer)) { - state.consumers.delete(consumer); - state.lifecycleVersion += 1; + const currentConsumerCount = state.consumerCounts.get(consumer) ?? 0; + if (currentConsumerCount > 1) { + state.consumerCounts.set(consumer, currentConsumerCount - 1); + return { ...state.snapshot }; } - if (state.consumers.size > 0) { + if (currentConsumerCount === 1) { + state.consumerCounts.delete(consumer); + } + + if (this.getActiveConsumerCount(state) > 0) { return { ...state.snapshot }; } + if (currentConsumerCount > 0) { + state.lifecycleVersion += 1; + } + if (state.refreshTimer) { clearTimeout(state.refreshTimer); state.refreshTimer = null; @@ -164,7 +193,11 @@ export class TeamLogSourceTracker { private isTrackingCurrent(teamName: string, expectedVersion: number): boolean { const state = this.stateByTeam.get(teamName); - return !!state && state.consumers.size > 0 && state.lifecycleVersion === expectedVersion; + return ( + !!state && + this.getActiveConsumerCount(state) > 0 && + state.lifecycleVersion === expectedVersion + ); } private async initializeTeam( @@ -207,7 +240,11 @@ export class TeamLogSourceTracker { expectedVersion: number ): Promise { const state = this.stateByTeam.get(teamName); - if (!state || state.consumers.size === 0 || state.lifecycleVersion !== expectedVersion) { + if ( + !state || + this.getActiveConsumerCount(state) === 0 || + state.lifecycleVersion !== expectedVersion + ) { return; } if (state.projectDir === projectDir && state.watcher) { @@ -240,9 +277,15 @@ export class TeamLogSourceTracker { }, }); - const scheduleRecompute = (): void => { + const scheduleRecompute = (changedPath?: string): void => { const current = this.stateByTeam.get(teamName); - if (!current || current.consumers.size === 0) { + if (!current || this.getActiveConsumerCount(current) === 0 || !current.projectDir) { + return; + } + if ( + changedPath && + this.handleTaskLogFreshnessSignalChange(teamName, current.projectDir, changedPath) + ) { return; } if (current.refreshTimer) { @@ -264,15 +307,65 @@ export class TeamLogSourceTracker { }); } + private handleTaskLogFreshnessSignalChange( + teamName: string, + projectDir: string, + changedPath: string + ): boolean { + const signalDir = path.join(projectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME); + const relativePath = path.relative(signalDir, changedPath); + if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return path.normalize(changedPath) === path.normalize(signalDir); + } + + if (relativePath === '.') { + return true; + } + + if (relativePath.includes(path.sep)) { + return true; + } + + const taskId = this.decodeTaskLogFreshnessTaskId(relativePath); + if (!taskId) { + return true; + } + + this.emitter?.({ + type: 'task-log-change', + teamName, + taskId, + }); + return true; + } + + private decodeTaskLogFreshnessTaskId(fileName: string): string | null { + if (!fileName.endsWith(BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX)) { + return null; + } + + const encodedTaskId = fileName.slice(0, -BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX.length); + if (!encodedTaskId) { + return null; + } + + try { + const taskId = decodeURIComponent(encodedTaskId); + return taskId.trim().length > 0 ? taskId : null; + } catch { + return null; + } + } + private async recompute(teamName: string): Promise { const state = this.getOrCreateState(teamName); - if (state.consumers.size === 0) { + if (this.getActiveConsumerCount(state) === 0) { return state.snapshot; } if ( state.recomputePromise && state.recomputeVersion === state.lifecycleVersion && - state.consumers.size > 0 + this.getActiveConsumerCount(state) > 0 ) { return state.recomputePromise; } diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index 0cafac54..ec65957e 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -1,17 +1,11 @@ +import { getMemberColorByName } from '@shared/constants/memberColors'; import { createCliAutoSuffixNameGuard, createCliProvisionerNameGuard, } from '@shared/utils/teamMemberName'; import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; -import type { - InboxMessage, - MemberStatus, - ResolvedTeamMember, - TeamConfig, - TeamMember, - TeamTaskWithKanban, -} from '@shared/types'; +import type { TeamConfig, TeamMember, TeamMemberSnapshot, TeamTaskWithKanban } from '@shared/types'; const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([ @@ -63,9 +57,8 @@ export class TeamMemberResolver { config: TeamConfig, metaMembers: TeamConfig['members'], inboxNames: string[], - tasks: TeamTaskWithKanban[], - messages: InboxMessage[] - ): ResolvedTeamMember[] { + tasks: TeamTaskWithKanban[] + ): TeamMemberSnapshot[] { const names = new Set(); const explicitNames = new Set(); const seenNames = new Set(); @@ -216,7 +209,7 @@ export class TeamMemberResolver { } } - const members: ResolvedTeamMember[] = []; + const members: TeamMemberSnapshot[] = []; for (const name of names) { const ownedTasks = tasks.filter((task) => task.owner === name); const currentTask = @@ -226,21 +219,15 @@ export class TeamMemberResolver { task.reviewState !== 'approved' && task.kanbanColumn !== 'approved' ) ?? null; - const memberMessages = messages.filter((message) => message.from === name); - const latestMessage = memberMessages[0] ?? null; - const status = this.resolveStatus(latestMessage, currentTask !== null); const configMember = configMemberMap.get(name); const metaMember = metaMemberMap.get(name); const agentId = configMember?.agentId ?? metaMember?.agentId; members.push({ name, agentId, - status, currentTaskId: currentTask?.id ?? null, taskCount: ownedTasks.length, - messageCount: memberMessages.length, - lastActiveAt: latestMessage?.timestamp ?? null, - color: latestMessage?.color ?? configMember?.color ?? metaMember?.color, + color: configMember?.color ?? metaMember?.color ?? getMemberColorByName(name), agentType: configMember?.agentType ?? metaMember?.agentType, role: configMember?.role ?? metaMember?.role, workflow: configMember?.workflow ?? metaMember?.workflow, @@ -277,45 +264,4 @@ export class TeamMemberResolver { }); return members; } - - private resolveStatus(message: InboxMessage | null, hasActiveTask: boolean): MemberStatus { - if (!message) { - // Member exists in config but has no messages yet — - // if they own an in_progress task they're clearly active, otherwise idle - return hasActiveTask ? 'active' : 'idle'; - } - - const structured = this.parseStructuredMessage(message.text); - if (structured) { - const typed = structured as { type?: string; approve?: boolean; approved?: boolean }; - if ( - (typed.type === 'shutdown_response' && - (typed.approve === true || typed.approved === true)) || - typed.type === 'shutdown_approved' - ) { - return 'terminated'; - } - } - - const ageMs = Date.now() - Date.parse(message.timestamp); - if (Number.isNaN(ageMs)) { - return 'unknown'; - } - if (ageMs < 5 * 60 * 1000) { - return 'active'; - } - return 'idle'; - } - - private parseStructuredMessage(text: string): Record | null { - try { - const parsed = JSON.parse(text) as unknown; - if (parsed && typeof parsed === 'object') { - return parsed as Record; - } - } catch { - // Ignore plain text. - } - return null; - } } diff --git a/src/main/services/team/TeamMessageFeedService.ts b/src/main/services/team/TeamMessageFeedService.ts new file mode 100644 index 00000000..b40d01f8 --- /dev/null +++ b/src/main/services/team/TeamMessageFeedService.ts @@ -0,0 +1,408 @@ +import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics'; +import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; +import { createHash } from 'crypto'; + +import { getEffectiveInboxMessageId } from './inboxMessageIdentity'; + +import type { InboxMessage, TeamConfig } from '@shared/types'; + +const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000; + +interface TeamMessageFeedDeps { + getConfig: (teamName: string) => Promise; + getInboxMessages: (teamName: string) => Promise; + getLeadSessionMessages: (teamName: string, config: TeamConfig) => Promise; + getSentMessages: (teamName: string) => Promise; +} + +interface TeamMessageFeedCacheEntry { + feedRevision: string; + messages: InboxMessage[]; +} + +export interface TeamNormalizedMessageFeed { + teamName: string; + feedRevision: string; + messages: InboxMessage[]; +} + +function requireCanonicalMessageId(message: InboxMessage): string { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (messageId.length > 0) { + return messageId; + } + throw new Error('Normalized team message is missing effective messageId'); +} + +function normalizePassiveUserReplyLinkText(value: string | undefined): string { + if (typeof value !== 'string') return ''; + return value + .trim() + .toLowerCase() + .replace(/\s+/g, ' ') + .replace(/[.!?…]+$/g, '') + .trim(); +} + +function extractPassiveUserPeerSummaryBody(text: string): string | null { + const classified = classifyIdleNotificationText(text); + if (classified?.primaryKind !== 'heartbeat' || !classified.peerSummary) { + return null; + } + + const match = /^\[to\s+user\]\s*(.*)$/i.exec(classified.peerSummary); + if (!match) { + return null; + } + + const body = match[1]?.trim() ?? ''; + return body.length > 0 ? body : null; +} + +function isLeadThoughtCandidateForSlashResult(message: InboxMessage): boolean { + if (typeof message.to === 'string' && message.to.trim().length > 0) return false; + if (message.from === 'system') return false; + return message.source === 'lead_session' || message.source === 'lead_process'; +} + +function annotateSlashCommandResponses(messages: InboxMessage[]): void { + let pendingSlash = null as InboxMessage['slashCommand'] | null; + + for (const message of messages) { + const slashCommand = + message.source === 'user_sent' + ? (message.slashCommand ?? buildStandaloneSlashCommandMeta(message.text)) + : null; + + if (slashCommand) { + pendingSlash = slashCommand; + continue; + } + + if (!pendingSlash) { + continue; + } + + if (message.messageKind === 'slash_command_result') { + continue; + } + + if (isLeadThoughtCandidateForSlashResult(message)) { + message.messageKind = 'slash_command_result'; + message.commandOutput = { + stream: 'stdout', + commandLabel: pendingSlash.command, + }; + continue; + } + + pendingSlash = null; + } +} + +function linkPassiveUserReplySummaries(messages: InboxMessage[]): InboxMessage[] { + const canonicalReplies = messages + .map((message) => { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (!messageId || message.to !== 'user') { + return null; + } + if (classifyIdleNotificationText(message.text)) { + return null; + } + + const time = Date.parse(message.timestamp); + if (!Number.isFinite(time)) { + return null; + } + + return { + messageId, + from: message.from, + time, + normalizedSummary: normalizePassiveUserReplyLinkText(message.summary), + normalizedText: normalizePassiveUserReplyLinkText(message.text), + }; + }) + .filter((value): value is NonNullable => value !== null); + + if (canonicalReplies.length === 0) { + return messages; + } + + let didLink = false; + const linkedMessages = messages.map((message) => { + if ( + typeof message.relayOfMessageId === 'string' && + message.relayOfMessageId.trim().length > 0 + ) { + return message; + } + + const body = extractPassiveUserPeerSummaryBody(message.text); + if (!body) { + return message; + } + + const passiveTime = Date.parse(message.timestamp); + if (!Number.isFinite(passiveTime)) { + return message; + } + + const normalizedBody = normalizePassiveUserReplyLinkText(body); + if (!normalizedBody) { + return message; + } + + const matches = canonicalReplies.filter((candidate) => { + if (candidate.from !== message.from) { + return false; + } + const deltaMs = passiveTime - candidate.time; + if (deltaMs < 0 || deltaMs > PASSIVE_USER_REPLY_LINK_WINDOW_MS) { + return false; + } + if (candidate.normalizedSummary === normalizedBody) { + return true; + } + return normalizedBody.length >= 6 && candidate.normalizedText.includes(normalizedBody); + }); + + if (matches.length !== 1) { + return message; + } + + didLink = true; + return { + ...message, + relayOfMessageId: matches[0].messageId, + }; + }); + + return didLink ? linkedMessages : messages; +} + +function dedupeLeadProcessCopies( + messages: InboxMessage[], + leadTexts: readonly InboxMessage[] +): InboxMessage[] { + if (leadTexts.length === 0) { + return messages; + } + + const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); + const getFingerprint = (msg: Pick) => + `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`; + + const leadSessionFingerprints = new Set(); + for (const msg of leadTexts) { + if (msg.source === 'lead_session') { + leadSessionFingerprints.add(getFingerprint(msg)); + } + } + + return messages.filter((message) => { + if (message.source !== 'lead_process') return true; + if (message.to) return true; + return !leadSessionFingerprints.has(getFingerprint(message)); + }); +} + +function choosePreferredMessage(current: InboxMessage, candidate: InboxMessage): InboxMessage { + const score = (msg: InboxMessage): number => { + let value = 0; + if (msg.source !== 'lead_process') value += 4; + if (msg.read === false) value += 2; + if (msg.relayOfMessageId) value += 1; + if (msg.summary) value += 1; + if (msg.to) value += 1; + return value; + }; + + const currentScore = score(current); + const candidateScore = score(candidate); + if (candidateScore !== currentScore) { + return candidateScore > currentScore ? candidate : current; + } + + const currentTs = Date.parse(current.timestamp); + const candidateTs = Date.parse(candidate.timestamp); + if (Number.isFinite(currentTs) && Number.isFinite(candidateTs) && candidateTs !== currentTs) { + return candidateTs > currentTs ? candidate : current; + } + + return current; +} + +function dedupeByMessageId(messages: InboxMessage[]): InboxMessage[] { + const dedupedById = new Map(); + const dedupedWithoutId: InboxMessage[] = []; + + for (const message of messages) { + const id = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (!id) { + dedupedWithoutId.push(message); + continue; + } + const existing = dedupedById.get(id); + if (!existing) { + dedupedById.set(id, message); + continue; + } + dedupedById.set(id, choosePreferredMessage(existing, message)); + } + + return [...dedupedWithoutId, ...dedupedById.values()]; +} + +function ensureEffectiveMessageIds(messages: InboxMessage[]): InboxMessage[] { + let changed = false; + const normalized = messages.map((message) => { + const effectiveMessageId = getEffectiveInboxMessageId(message); + if (!effectiveMessageId || effectiveMessageId === message.messageId) { + return message; + } + changed = true; + return { + ...message, + messageId: effectiveMessageId, + }; + }); + + return changed ? normalized : messages; +} + +function attachLeadSessionIds(config: TeamConfig, messages: InboxMessage[]): void { + if (!config.leadSessionId && !messages.some((message) => message.leadSessionId)) { + return; + } + + messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + const anchors: { time: number; sessionId: string }[] = []; + for (const message of messages) { + if (message.leadSessionId) { + anchors.push({ time: Date.parse(message.timestamp), sessionId: message.leadSessionId }); + } + } + + if (anchors.length > 0) { + for (const message of messages) { + if (message.leadSessionId) continue; + const messageTime = Date.parse(message.timestamp); + let best = anchors[0]; + let bestDistance = Math.abs(messageTime - best.time); + for (const anchor of anchors) { + const distance = Math.abs(messageTime - anchor.time); + if (distance < bestDistance) { + bestDistance = distance; + best = anchor; + } else if (distance > bestDistance && anchor.time > messageTime) { + break; + } + } + message.leadSessionId = best.sessionId; + } + return; + } + + if (!config.leadSessionId) { + return; + } + + for (const message of messages) { + message.leadSessionId = config.leadSessionId; + } +} + +function toFeedRevision(messages: readonly InboxMessage[]): string { + const stableMessages = messages.map((message) => ({ + messageId: message.messageId ?? null, + relayOfMessageId: message.relayOfMessageId ?? null, + from: message.from, + to: message.to ?? null, + text: message.text, + timestamp: message.timestamp, + read: message.read, + summary: message.summary ?? null, + color: message.color ?? null, + source: message.source ?? null, + attachments: message.attachments ?? null, + leadSessionId: message.leadSessionId ?? null, + conversationId: message.conversationId ?? null, + replyToConversationId: message.replyToConversationId ?? null, + toolSummary: message.toolSummary ?? null, + toolCalls: message.toolCalls ?? null, + messageKind: message.messageKind ?? null, + slashCommand: message.slashCommand ?? null, + commandOutput: message.commandOutput ?? null, + })); + + return createHash('sha256').update(JSON.stringify(stableMessages)).digest('hex').slice(0, 24); +} + +export class TeamMessageFeedService { + private readonly cacheByTeam = new Map(); + private readonly dirtyTeams = new Set(); + + constructor(private readonly deps: TeamMessageFeedDeps) {} + + invalidate(teamName: string): void { + this.dirtyTeams.add(teamName); + } + + async getFeed(teamName: string): Promise { + const cached = this.cacheByTeam.get(teamName); + if (cached && !this.dirtyTeams.has(teamName)) { + return { + teamName, + feedRevision: cached.feedRevision, + messages: cached.messages, + }; + } + + const config = await this.deps.getConfig(teamName); + if (!config) { + const emptyEntry = { feedRevision: toFeedRevision([]), messages: [] }; + this.cacheByTeam.set(teamName, emptyEntry); + this.dirtyTeams.delete(teamName); + return { teamName, ...emptyEntry }; + } + + const [inboxMessages, leadTexts, sentMessages] = await Promise.all([ + this.deps.getInboxMessages(teamName).catch(() => [] as InboxMessage[]), + this.deps.getLeadSessionMessages(teamName, config).catch(() => [] as InboxMessage[]), + this.deps.getSentMessages(teamName).catch(() => [] as InboxMessage[]), + ]); + + let messages = [...inboxMessages, ...leadTexts, ...sentMessages]; + messages = dedupeLeadProcessCopies(messages, leadTexts); + messages = ensureEffectiveMessageIds(messages); + messages = dedupeByMessageId(messages); + messages = linkPassiveUserReplySummaries(messages); + attachLeadSessionIds(config, messages); + annotateSlashCommandResponses(messages); + + messages.sort((left, right) => { + const diff = Date.parse(right.timestamp) - Date.parse(left.timestamp); + if (diff !== 0) return diff; + return requireCanonicalMessageId(left).localeCompare(requireCanonicalMessageId(right)); + }); + + const feedRevision = toFeedRevision(messages); + const nextEntry = + cached?.feedRevision === feedRevision + ? cached + : { + feedRevision, + messages, + }; + + this.cacheByTeam.set(teamName, nextEntry); + this.dirtyTeams.delete(teamName); + return { + teamName, + feedRevision: nextEntry.feedRevision, + messages: nextEntry.messages, + }; + } +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 2e4bbc77..9e64bbae 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1,4 +1,7 @@ -import { killTmuxPaneForCurrentPlatformSync } from '@features/tmux-installer/main'; +import { + killTmuxPaneForCurrentPlatformSync, + listTmuxPanePidsForCurrentPlatform, +} from '@features/tmux-installer/main'; import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; import { getAppIconPath } from '@main/utils/appIcon'; @@ -14,6 +17,7 @@ import { getTasksBasePath, getTeamsBasePath, } from '@main/utils/pathDecoder'; +import { isProcessAlive } from '@main/utils/processHealth'; import { killProcessByPid } from '@main/utils/processKill'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { shouldAutoAllow } from '@main/utils/toolApprovalRules'; @@ -35,6 +39,7 @@ import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import { resolveLanguageName } from '@shared/utils/agentLanguage'; import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults'; import { parseCliArgs } from '@shared/utils/cliArgsParser'; +import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics'; import { isInboxNoiseMessage, isMeaningfulBootstrapCheckInMessage, @@ -63,6 +68,7 @@ import { randomUUID } from 'crypto'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import pidusage from 'pidusage'; import { type GeminiRuntimeAuthState, @@ -82,7 +88,9 @@ import { resolveTeamProviderId } from '../runtime/providerRuntimeEnv'; import { buildActionModeProtocol } from './actionModeInstructions'; import { atomicWriteAsync } from './atomicWrite'; +import { peekAutoResumeService } from './AutoResumeService'; import { ClaudeBinaryResolver } from './ClaudeBinaryResolver'; +import { getConfiguredCliCommandLabel } from './cliFlavor'; import { withFileLock } from './fileLock'; import { type ClassifiedMainProcessIdle, @@ -90,6 +98,7 @@ import { } from './idleNotificationMainProcessSemantics'; import { withInboxLock } from './inboxLock'; import { getEffectiveInboxMessageId } from './inboxMessageIdentity'; +import { buildProgressAssistantOutput, buildProgressLogsTail } from './progressPayload'; import { resolveDesktopTeammateModeDecision } from './runtimeTeammateMode'; import { choosePreferredLaunchSnapshot, @@ -165,7 +174,11 @@ import type { MemberSpawnStatusEntry, PersistedTeamLaunchPhase, PersistedTeamLaunchSummary, + TeamAgentRuntimeBackendType, + TeamAgentRuntimeEntry, + TeamAgentRuntimeSnapshot, TeamChangeEvent, + TeamConfig, TeamCreateRequest, TeamCreateResponse, TeamLaunchAggregateState, @@ -194,7 +207,13 @@ const VERIFY_TIMEOUT_MS = 15_000; const VERIFY_POLL_MS = 500; const STDERR_RING_LIMIT = 64 * 1024; const STDOUT_RING_LIMIT = 64 * 1024; -const LOG_PROGRESS_THROTTLE_MS = 300; +// Progress emissions fan out the latest CLI tail + assistant output to the +// renderer over IPC. Under load the previous 300ms cadence combined with an +// unbounded payload (see `emitLogsProgress`) caused renderer OOM crashes +// (≈3 full-history serializations per second, each holding thousands of +// lines). The tail cap in `emitLogsProgress` bounds each payload; we also +// slow the cadence to ~1s so Zustand can keep up on large teams. +const LOG_PROGRESS_THROTTLE_MS = 1000; const UI_LOGS_TAIL_LIMIT = 128 * 1024; const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000; const PREFLIGHT_BINARY_TIMEOUT_MS = 8000; @@ -648,8 +667,11 @@ interface ProvisioningRun { authRetryInProgress: boolean; /** Tracks lead process context window usage from stream-json usage data. */ leadContextUsage: { - currentTokens: number; - contextWindow: number; + promptInputTokens: number | null; + outputTokens: number | null; + contextUsedTokens: number | null; + contextWindowTokens: number | null; + promptInputSource: LeadContextUsage['promptInputSource']; lastUsageMessageId: string | null; lastEmittedAt: number; } | null; @@ -700,6 +722,8 @@ interface ProvisioningRun { >; /** Agent tool_use_id -> teammate name for persistent teammate spawns. */ memberSpawnToolUseIds: Map; + /** Per-member latest processed lead-inbox bootstrap signal cursor for the current live run. */ + memberSpawnLeadInboxCursorByMember: Map; /** Highest accepted deterministic bootstrap event sequence for this run. */ lastDeterministicBootstrapSeq: number; /** Throttles config/inbox audit work triggered by frequent status polling. */ @@ -784,7 +808,107 @@ function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry { } interface LiveTeamAgentRuntimeMetadata { + alive: boolean; + backendType?: TeamAgentRuntimeBackendType; + agentId?: string; + pid?: number; model?: string; + tmuxPaneId?: string; +} + +function normalizeTeamAgentRuntimeBackendType( + value: string | undefined, + isLead: boolean +): TeamAgentRuntimeBackendType | undefined { + if (isLead) return 'lead'; + const normalized = value?.trim().toLowerCase(); + if (normalized === 'tmux' || normalized === 'iterm2' || normalized === 'in-process') { + return normalized; + } + return normalized ? 'process' : undefined; +} + +function matchesMemberNameOrBase(candidateName: string, memberName: string): boolean { + if (candidateName === memberName) { + return true; + } + const parsed = parseNumericSuffixName(candidateName); + return parsed !== null && parsed.suffix >= 2 && parsed.base === memberName; +} + +function matchesTeamMemberIdentity(leftName: string, rightName: string): boolean { + return ( + matchesMemberNameOrBase(leftName, rightName) || matchesMemberNameOrBase(rightName, leftName) + ); +} + +interface MemberSpawnInboxCursor { + timestamp: string; + messageId: string; +} + +type LeadInboxMemberSpawnMessage = InboxMessage & { messageId: string }; + +function compareMemberSpawnInboxCursor( + left: MemberSpawnInboxCursor, + right: MemberSpawnInboxCursor +): number { + const leftMs = Date.parse(left.timestamp); + const rightMs = Date.parse(right.timestamp); + const leftValid = Number.isFinite(leftMs); + const rightValid = Number.isFinite(rightMs); + + if (leftValid && rightValid && leftMs !== rightMs) { + return leftMs - rightMs; + } + if (leftValid !== rightValid) { + return leftValid ? -1 : 1; + } + return left.messageId.localeCompare(right.messageId); +} + +function toMemberSpawnInboxCursor( + message: Pick +): MemberSpawnInboxCursor | null { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (!messageId) { + return null; + } + return { + timestamp: message.timestamp, + messageId, + }; +} + +function maxMemberSpawnInboxCursor( + left: MemberSpawnInboxCursor | undefined, + right: MemberSpawnInboxCursor +): MemberSpawnInboxCursor { + if (!left) { + return right; + } + return compareMemberSpawnInboxCursor(left, right) >= 0 ? left : right; +} + +function isMemberSpawnHeartbeatTimestampNewer( + previous: string | undefined, + incoming: string | undefined +): boolean { + const normalizedIncoming = incoming?.trim(); + if (!normalizedIncoming) { + return false; + } + const normalizedPrevious = previous?.trim(); + if (!normalizedPrevious) { + return true; + } + + const previousMs = Date.parse(normalizedPrevious); + const incomingMs = Date.parse(normalizedIncoming); + if (Number.isFinite(previousMs) && Number.isFinite(incomingMs)) { + return incomingMs > previousMs; + } + return normalizedIncoming > normalizedPrevious; } function stripWrappedCliFlagValue(raw: string | undefined): string | undefined { @@ -862,6 +986,24 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +async function waitForPidsToExit( + pids: readonly number[], + opts: { timeoutMs: number; pollMs: number } +): Promise { + if (pids.length === 0) { + return; + } + + const deadline = Date.now() + opts.timeoutMs; + while (Date.now() < deadline) { + const remaining = pids.filter((pid) => isProcessAlive(pid)); + if (remaining.length === 0) { + return; + } + await sleep(opts.pollMs); + } +} + async function tryReadRegularFileUtf8( filePath: string, opts: { timeoutMs: number; maxBytes: number } @@ -1269,6 +1411,8 @@ ${buildCanonicalSendMessageExample({ to: leadName, summary: 'short update', mess After member_briefing succeeds: - Do NOT send a "ready", "online", "status accepted", or other acknowledgement-only message just to confirm you started successfully. - If bootstrap succeeded and you have no task yet, stay silent and wait for task assignments. +- If bootstrap succeeded and you have no task, produce ZERO assistant text for that turn and end it immediately after the successful tool result. +- Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after bootstrap. - Only SendMessage the lead after bootstrap when there is a real blocker, a failed bootstrap, an explicit question, an urgent coordination need, or a completed task result to report. - Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence. - When you later receive work or reconnect after a restart, use task_briefing as your compact queue view. Use task_get when you need the full task context before starting a pending/needsFix task or when the in_progress briefing details are not enough. @@ -1339,6 +1483,8 @@ ${actionModeProtocol} After member_briefing succeeds: - Do NOT send a "ready", "online", "status accepted", or other acknowledgement-only message just to confirm you reconnected successfully. - If reconnect bootstrap succeeded and you have no immediate blocker or question, stay silent and continue with your queue. + - If reconnect bootstrap succeeded and you have no immediate blocker, question, or task, produce ZERO assistant text for that turn and end it immediately. + - Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after reconnect bootstrap. - Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence. - Use task_briefing as your compact queue view. - If task_briefing shows any in_progress task, resume/finish those first. Call task_get only if you need more context than task_briefing already gave you. @@ -1403,6 +1549,52 @@ export function buildAddMemberSpawnMessage( ); } +export function buildRestartMemberSpawnMessage( + teamName: string, + displayName: string, + leadName: string, + member: Pick< + TeamCreateRequest['members'][number], + 'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort' + > +): string { + const roleHint = + typeof member.role === 'string' && member.role.trim() + ? ` with role "${member.role.trim()}"` + : ''; + const workflowHint = + typeof member.workflow === 'string' && member.workflow.trim() + ? ` Their workflow: ${member.workflow.trim()}` + : ''; + + const prompt = buildMemberSpawnPrompt( + { + name: member.name, + ...(member.role ? { role: member.role } : {}), + ...(member.workflow ? { workflow: member.workflow } : {}), + ...(member.providerId ? { providerId: member.providerId } : {}), + ...(member.model ? { model: member.model } : {}), + ...(member.effort ? { effort: member.effort } : {}), + }, + displayName, + teamName, + leadName + ); + const providerPart = + member.providerId && member.providerId !== 'anthropic' + ? `, provider="${member.providerId}"` + : ''; + const modelPart = member.model?.trim() ? `, model="${member.model.trim()}"` : ''; + const effortPart = member.effort ? `, effort="${member.effort}"` : ''; + + return ( + `Teammate "${member.name}"${roleHint} was restarted from the UI. ` + + `Please respawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose"${providerPart}${modelPart}${effortPart}, and the exact prompt below. ` + + `This is a restart of an existing persistent teammate, not a new teammate.${workflowHint ? workflowHint : ''}\n\n` + + indentMultiline(prompt, ' ') + ); +} + interface RuntimeBootstrapMemberSpec { name: string; prompt?: string; @@ -1905,6 +2097,7 @@ function buildDeterministicLaunchHydrationPrompt( const userPromptBlock = request.prompt?.trim() ? `\nOriginal user instructions to apply after reconnect is stable:\n${request.prompt.trim()}\n` : ''; + const hasOriginalUserPrompt = Boolean(request.prompt?.trim()); const taskBoardSnapshot = buildTaskBoardSnapshot(tasks); const persistentContext = buildPersistentLeadContext({ teamName: request.teamName, @@ -1918,13 +2111,21 @@ Do NOT call TeamCreate. Do NOT use Agent to spawn or restore teammates. Do NOT start implementation in this turn. Use this turn only to refresh context, review the current board snapshot, and confirm you are ready. -If the user instructions imply new substantial work that is not on the board yet, you MAY create or update board tasks for yourself, but do not begin executing them yet.` +${ + hasOriginalUserPrompt + ? 'Do NOT create or update any new task in this turn - wait for the next normal operating turn before translating those instructions into board work.' + : 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' +}` : `This reconnect/bootstrap step has already been completed deterministically by the runtime. Do NOT call TeamCreate. Do NOT use Agent to spawn or restore teammates. Do NOT repeat the launch summary. -Use this turn only to refresh context, review the current board snapshot, and prepare the next delegation step. -If the user instructions imply new substantial work that is not on the board yet, you MAY create or update team-board tasks and assign owners now, but do NOT start implementation work in this turn. +Use this turn only to refresh context and review the current board snapshot. +${ + hasOriginalUserPrompt + ? 'Do NOT create or assign any new task in this turn - wait for the next normal operating turn before translating those instructions into board work.' + : 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' +} Treat teammates whose bootstrap is still pending as not-yet-available for blocking assignments.`; return `${startLabel} [Deterministic reconnect | Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"] @@ -1951,6 +2152,7 @@ function buildGeminiPostLaunchHydrationPrompt( const userPromptBlock = run.request.prompt?.trim() ? `\nOriginal user instructions to apply now:\n${run.request.prompt.trim()}\n` : ''; + const hasOriginalUserPrompt = Boolean(run.request.prompt?.trim()); const taskBoardSnapshot = buildTaskBoardSnapshot(tasks); const teammateBootstrapSnapshot = members.length ? `Current teammate launch status:\n${members @@ -1979,8 +2181,12 @@ function buildGeminiPostLaunchHydrationPrompt( members, }); const nextStepInstruction = isSolo - ? 'From this point on, use the full operating rules below for all future turns. If the original user instructions describe substantial work that should be tracked, you MAY now create board tasks for yourself, but do not start implementation in this context-refresh turn.' - : 'From this point on, use the full team operating rules below for all future turns. If the original user instructions describe substantial work that should be tracked, you MAY now translate them into board tasks and prepare delegation, but do not start implementation work in this context-refresh turn. Do NOT assume bootstrap-pending or failed teammates are ready; only treat teammates with confirmed bootstrap as immediately available for blocking assignments.'; + ? hasOriginalUserPrompt + ? 'From this point on, use the full operating rules below for all future turns. Do NOT create or update any new task in this context-refresh turn - wait for the next normal operating turn before translating those instructions into board work.' + : 'From this point on, use the full operating rules below for all future turns. Do NOT create, assign, or delegate any new task in this context-refresh turn. If the board is empty, stay silent and wait for a fresh user instruction.' + : hasOriginalUserPrompt + ? 'From this point on, use the full team operating rules below for all future turns. Do NOT create or assign any new task in this context-refresh turn - wait for the next normal operating turn before translating those instructions into board work. Do NOT assume bootstrap-pending or failed teammates are ready; only treat teammates with confirmed bootstrap as immediately available for blocking assignments.' + : 'From this point on, use the full team operating rules below for all future turns. Do NOT create, assign, or delegate any new task in this context-refresh turn. If the board is empty, stay silent and wait for a fresh user instruction. Do NOT assume bootstrap-pending or failed teammates are ready; only treat teammates with confirmed bootstrap as immediately available for blocking assignments.'; return `Gemini launch phase 2 — operating context for team "${run.teamName}". @@ -2024,10 +2230,12 @@ function updateProgress( 'pid' | 'error' | 'warnings' | 'cliLogsTail' | 'configReady' | 'messageSeverity' > ): TeamProvisioningProgress { + // Cap assistant output on every progress tick. `updateProgress` is invoked + // from ~20 event-driven sites (auth retries, stall warnings, spawn events), + // and an unbounded `provisioningOutputParts.join` was part of the same OOM + // class that `emitLogsProgress` already guards against. const assistantOutput = - run.provisioningOutputParts.length > 0 - ? run.provisioningOutputParts.join('\n\n') - : run.progress.assistantOutput; + buildProgressAssistantOutput(run.provisioningOutputParts) ?? run.progress.assistantOutput; run.progress = { ...run.progress, state, @@ -2060,6 +2268,72 @@ function buildCombinedLogs(stdoutBuffer: string, stderrBuffer: string): string { return [`[stdout]`, stdoutTrimmed, '', `[stderr]`, stderrTrimmed].join('\n'); } +interface AgentTeamsMcpConfigEntry { + command?: unknown; + args?: unknown; + env?: unknown; + cwd?: unknown; +} + +interface AgentTeamsMcpConfigFile { + mcpServers?: Record; +} + +interface AgentTeamsMcpLaunchSpec { + command: string; + args: string[]; + cwd?: string; + env: Record; +} + +interface McpJsonRpcErrorPayload { + code?: number; + message?: string; +} + +interface McpJsonRpcResponse { + id?: number; + result?: TResult; + error?: McpJsonRpcErrorPayload; +} + +interface McpToolsListResult { + tools?: { + name?: string; + _meta?: Record; + }[]; +} + +interface McpToolCallResult { + content?: { + type?: string; + text?: string; + }[]; + isError?: boolean; +} + +interface AgentTeamsMcpValidationFixture { + claudeDir: string; + teamName: string; + memberName: string; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === 'string'); +} + +function normalizeRecordStringValues(value: unknown): Record { + if (!value || typeof value !== 'object') { + return {}; + } + + return Object.fromEntries( + Object.entries(value).flatMap(([key, entry]) => + typeof entry === 'string' ? [[key, entry]] : [] + ) + ); +} + function extractLogsTail(stdoutBuffer: string, stderrBuffer: string): string | undefined { const trimmed = buildCombinedLogs(stdoutBuffer, stderrBuffer).trim(); if (trimmed.length === 0) { @@ -2092,10 +2366,22 @@ function extractCliLogsFromRun(run: ProvisioningRun): string | undefined { return extractLogsTail(run.stdoutBuffer, run.stderrBuffer); } +/** + * Emit a throttled progress update for the renderer. Payloads are capped to a + * tail window so that the hot emission path (called every LOG_PROGRESS_THROTTLE_MS + * under streaming output) cannot accumulate into multi-megabyte IPC messages + * that would OOM the renderer's Zustand state. The full history stays in + * `run.claudeLogLines` / `run.provisioningOutputParts` for diagnostics and + * one-shot completion emissions that intentionally use `extractCliLogsFromRun`. + */ function emitLogsProgress(run: ProvisioningRun): void { - const logsTail = extractCliLogsFromRun(run); - const assistantOutput = - run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('\n\n') : undefined; + // Prefer the line-buffered history (already chronological with [stdout]/[stderr] + // markers) and fall back to the legacy ring-buffer tail only when no lines + // have been captured yet (early in provisioning). + const logsTail = + buildProgressLogsTail(run.claudeLogLines) ?? + extractLogsTail(run.stdoutBuffer, run.stderrBuffer); + const assistantOutput = buildProgressAssistantOutput(run.provisioningOutputParts); if (!logsTail && !assistantOutput) { return; @@ -2205,6 +2491,7 @@ export class TeamProvisioningService { private static readonly SAME_TEAM_MATCH_WINDOW_MS = 30_000; private static readonly SAME_TEAM_RUN_START_SKEW_MS = 1_000; private static readonly SAME_TEAM_PERSIST_RETRY_MS = 2_000; + private static readonly AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS = 2_000; private readonly runs = new Map(); private readonly provisioningRunByTeam = new Map(); @@ -2221,6 +2508,14 @@ export class TeamProvisioningService { string, NativeSameTeamFingerprint[] >(); + private readonly agentRuntimeSnapshotCache = new Map< + string, + { expiresAtMs: number; snapshot: TeamAgentRuntimeSnapshot } + >(); + private readonly liveTeamAgentRuntimeMetadataCache = new Map< + string, + { expiresAtMs: number; metadata: Map } + >(); private readonly launchStateStore = new TeamLaunchStateStore(); private readonly memberLogsFinder: TeamMemberLogsFinder; private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null; @@ -2342,6 +2637,40 @@ export class TeamProvisioningService { return this.getProvisioningRunId(teamName) ?? this.getAliveRunId(teamName); } + private clearSameTeamRetryTimers(teamName: string): void { + for (const suffix of ['deferred', 'persist']) { + const key = `same-team-${suffix}:${teamName}`; + const timer = this.pendingTimeouts.get(key); + if (timer) { + clearTimeout(timer); + this.pendingTimeouts.delete(key); + } + } + } + + private resetTeamScopedTransientStateForNewRun(teamName: string): void { + peekAutoResumeService()?.cancelPendingAutoResume(teamName); + this.leadInboxRelayInFlight.delete(teamName); + this.relayedLeadInboxMessageIds.delete(teamName); + this.pendingCrossTeamFirstReplies.delete(teamName); + this.recentCrossTeamLeadDeliveryMessageIds.delete(teamName); + this.recentSameTeamNativeFingerprints.delete(teamName); + this.clearSameTeamRetryTimers(teamName); + + for (const key of Array.from(this.memberInboxRelayInFlight.keys())) { + if (key.startsWith(`${teamName}:`)) { + this.memberInboxRelayInFlight.delete(key); + } + } + for (const key of Array.from(this.relayedMemberInboxMessageIds.keys())) { + if (key.startsWith(`${teamName}:`)) { + this.relayedMemberInboxMessageIds.delete(key); + } + } + + this.liveLeadProcessMessages.delete(teamName); + } + private appendCliLogs(run: ProvisioningRun, stream: 'stdout' | 'stderr', text: string): void { const nowMs = Date.now(); run.claudeLogsUpdatedAt = new Date(nowMs).toISOString(); @@ -2841,12 +3170,15 @@ export class TeamProvisioningService { } const runStartedAtMs = Date.parse(run.startedAt); - const expectedMembers = Array.isArray(run.expectedMembers) ? run.expectedMembers : []; + const expectedMembers = new Set(Array.isArray(run.expectedMembers) ? run.expectedMembers : []); const teammateMessages = leadInboxMessages - .filter((message) => { + .filter((message): message is LeadInboxMemberSpawnMessage => { const from = typeof message.from === 'string' ? message.from.trim() : ''; if (!from || from === leadName || from === 'user' || from === 'system') return false; - if (!expectedMembers.includes(from)) return false; + if (!expectedMembers.has(from)) return false; + if (typeof message.messageId !== 'string' || message.messageId.trim().length === 0) { + return false; + } const messageTs = Date.parse(message.timestamp); if ( Number.isFinite(messageTs) && @@ -2857,24 +3189,67 @@ export class TeamProvisioningService { } return typeof message.text === 'string' && message.text.trim().length > 0; }) - .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); - - for (const message of teammateMessages) { - const from = message.from.trim(); - const reason = extractBootstrapFailureReason(message.text); - if (reason) { - this.setMemberSpawnStatus(run, from, 'error', reason); - continue; - } - this.setMemberSpawnStatus( - run, - from, - 'online', - undefined, - 'heartbeat', - extractHeartbeatTimestamp(message.text, message.timestamp) + .sort((left, right) => + compareMemberSpawnInboxCursor( + { timestamp: left.timestamp, messageId: left.messageId }, + { timestamp: right.timestamp, messageId: right.messageId } + ) ); + + const messagesByMember = new Map(); + for (const message of teammateMessages) { + const memberName = message.from.trim(); + const bucket = messagesByMember.get(memberName) ?? []; + bucket.push(message); + messagesByMember.set(memberName, bucket); } + + for (const [memberName, messages] of messagesByMember.entries()) { + const currentCursor = run.memberSpawnLeadInboxCursorByMember.get(memberName); + let nextCursor = currentCursor; + + for (const message of messages) { + const messageCursor = toMemberSpawnInboxCursor(message); + const effectiveCursor = nextCursor ?? currentCursor; + if (messageCursor && effectiveCursor) { + if (compareMemberSpawnInboxCursor(messageCursor, effectiveCursor) <= 0) { + continue; + } + } + + this.applyLeadInboxSpawnSignal(run, memberName, message); + if (messageCursor) { + nextCursor = maxMemberSpawnInboxCursor(nextCursor, messageCursor); + } + } + + if ( + nextCursor && + (currentCursor == null || compareMemberSpawnInboxCursor(nextCursor, currentCursor) > 0) + ) { + run.memberSpawnLeadInboxCursorByMember.set(memberName, nextCursor); + } + } + } + + private applyLeadInboxSpawnSignal( + run: ProvisioningRun, + memberName: string, + message: LeadInboxMemberSpawnMessage + ): void { + const reason = extractBootstrapFailureReason(message.text); + if (reason) { + this.setMemberSpawnStatus(run, memberName, 'error', reason); + return; + } + this.setMemberSpawnStatus( + run, + memberName, + 'online', + undefined, + 'heartbeat', + extractHeartbeatTimestamp(message.text, message.timestamp) + ); } private persistSentMessage(teamName: string, message: InboxMessage): void { @@ -3074,7 +3449,58 @@ export class TeamProvisioningService { } getLiveLeadProcessMessages(teamName: string): InboxMessage[] { - return [...(this.liveLeadProcessMessages.get(teamName) ?? [])]; + const runId = this.getTrackedRunId(teamName); + const detectedSessionId = runId ? (this.runs.get(runId)?.detectedSessionId ?? null) : null; + + return (this.liveLeadProcessMessages.get(teamName) ?? []).map((message) => + !message.leadSessionId && detectedSessionId + ? { ...message, leadSessionId: detectedSessionId } + : { ...message } + ); + } + + private pruneLiveLeadMessagesForCleanedRun(run: ProvisioningRun): void { + const list = this.liveLeadProcessMessages.get(run.teamName); + if (!list || list.length === 0) { + return; + } + + const runMessageIdPrefixes = [ + `lead-turn-${run.runId}-`, + `lead-sendmsg-${run.runId}-`, + `lead-process-${run.runId}-`, + `compact-${run.runId}-`, + ]; + + const filtered = list.filter((message) => { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (messageId && runMessageIdPrefixes.some((prefix) => messageId.startsWith(prefix))) { + return false; + } + + if (run.detectedSessionId && message.leadSessionId === run.detectedSessionId) { + return false; + } + + return true; + }); + + if (filtered.length === 0) { + this.liveLeadProcessMessages.delete(run.teamName); + return; + } + + this.liveLeadProcessMessages.set(run.teamName, filtered); + } + + getCurrentLeadSessionId(teamName: string): string | null { + const runId = this.getTrackedRunId(teamName); + if (!runId) return null; + return this.runs.get(runId)?.detectedSessionId ?? null; + } + + getCurrentRunId(teamName: string): string | null { + return this.getAliveRunId(teamName); } getLeadActivityState(teamName: string): { @@ -3095,15 +3521,95 @@ export class TeamProvisioningService { if (!run?.leadContextUsage || run.processKilled || run.cancelRequested) { return { usage: null, runId: null }; } - const { currentTokens, contextWindow } = run.leadContextUsage; - const percentRaw = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0; - const percent = Math.max(0, Math.min(100, percentRaw)); return { - usage: { currentTokens, contextWindow, percent, updatedAt: new Date().toISOString() }, + usage: this.buildLeadContextUsagePayload(run), runId, }; } + private getInitialLeadContextWindowTokens(run: ProvisioningRun): number | null { + const providerId = normalizeOptionalTeamProviderId(run.request.providerId); + const modelName = + typeof run.request.model === 'string' && run.request.model.trim().length > 0 + ? run.request.model.trim() + : providerId === 'anthropic' + ? getAnthropicDefaultTeamModel(run.request.limitContext === true) + : undefined; + + return inferContextWindowTokens({ + providerId, + modelName, + limitContext: run.request.limitContext === true, + }); + } + + private buildLeadContextUsagePayload(run: ProvisioningRun): LeadContextUsage { + const usage = run.leadContextUsage; + if (!usage) { + return { + promptInputTokens: null, + outputTokens: null, + contextUsedTokens: null, + contextWindowTokens: null, + contextUsedPercent: null, + promptInputSource: 'unavailable', + updatedAt: new Date().toISOString(), + }; + } + + const { contextUsedTokens, contextWindowTokens } = usage; + const percentRaw = + contextUsedTokens !== null && contextWindowTokens !== null && contextWindowTokens > 0 + ? Math.round((contextUsedTokens / contextWindowTokens) * 100) + : null; + + return { + promptInputTokens: usage.promptInputTokens, + outputTokens: usage.outputTokens, + contextUsedTokens: usage.contextUsedTokens, + contextWindowTokens: usage.contextWindowTokens, + contextUsedPercent: percentRaw === null ? null : Math.max(0, Math.min(100, percentRaw)), + promptInputSource: usage.promptInputSource, + updatedAt: new Date().toISOString(), + }; + } + + private updateLeadContextUsageFromUsage( + run: ProvisioningRun, + usage: Record, + modelName: string | undefined + ): void { + const existingContextWindowTokens = + run.leadContextUsage?.contextWindowTokens ?? this.getInitialLeadContextWindowTokens(run); + const metrics = deriveContextMetrics({ + usage, + providerId: normalizeOptionalTeamProviderId(run.request.providerId), + modelName, + contextWindowTokens: existingContextWindowTokens, + limitContext: run.request.limitContext === true, + }); + + if (!run.leadContextUsage) { + run.leadContextUsage = { + promptInputTokens: metrics.promptInputTokens, + outputTokens: metrics.outputTokens, + contextUsedTokens: metrics.contextUsedTokens, + contextWindowTokens: metrics.contextWindowTokens, + promptInputSource: metrics.promptInputSource, + lastUsageMessageId: null, + lastEmittedAt: 0, + }; + return; + } + + run.leadContextUsage.promptInputTokens = metrics.promptInputTokens; + run.leadContextUsage.outputTokens = metrics.outputTokens; + run.leadContextUsage.contextUsedTokens = metrics.contextUsedTokens; + run.leadContextUsage.contextWindowTokens = + metrics.contextWindowTokens ?? run.leadContextUsage.contextWindowTokens; + run.leadContextUsage.promptInputSource = metrics.promptInputSource; + } + private isCurrentTrackedRun(run: ProvisioningRun): boolean { return this.getTrackedRunId(run.teamName) === run.runId; } @@ -3341,8 +3847,14 @@ export class TeamProvisioningService { next.livenessSource = livenessSource; next.firstSpawnAcceptedAt = prev.firstSpawnAcceptedAt ?? updatedAt; if (livenessSource === 'heartbeat') { + const incomingHeartbeatAt = heartbeatAt?.trim() || updatedAt; next.bootstrapConfirmed = true; - next.lastHeartbeatAt = heartbeatAt?.trim() || prev.lastHeartbeatAt || updatedAt; + next.lastHeartbeatAt = isMemberSpawnHeartbeatTimestampNewer( + prev.lastHeartbeatAt, + incomingHeartbeatAt + ) + ? incomingHeartbeatAt + : prev.lastHeartbeatAt; } next.hardFailure = false; next.error = undefined; @@ -3433,17 +3945,18 @@ export class TeamProvisioningService { const runId = this.getTrackedRunId(teamName); if (!runId) { return this.reconcilePersistedLaunchState(teamName).then(({ snapshot, statuses }) => { - this.attachLiveRuntimeMetadataToStatuses(teamName, statuses); - return { - statuses, - runId: null, - teamLaunchState: snapshot?.teamLaunchState, - launchPhase: snapshot?.launchPhase, - expectedMembers: snapshot?.expectedMembers, - updatedAt: snapshot?.updatedAt, - summary: snapshot?.summary, - source: snapshot ? 'persisted' : 'persisted', - }; + return this.attachLiveRuntimeMetadataToStatuses(teamName, statuses).then( + (nextStatuses) => ({ + statuses: nextStatuses, + runId: null, + teamLaunchState: snapshot?.teamLaunchState, + launchPhase: snapshot?.launchPhase, + expectedMembers: snapshot?.expectedMembers, + updatedAt: snapshot?.updatedAt, + summary: snapshot?.summary, + source: snapshot ? 'persisted' : 'persisted', + }) + ); }); } const run = this.runs.get(runId); @@ -3464,8 +3977,10 @@ export class TeamProvisioningService { statuses: this.buildRuntimeSpawnStatusRecord(run), }); const snapshot = persisted ?? liveSnapshot; - const statuses = snapshotToMemberSpawnStatuses(snapshot); - this.attachLiveRuntimeMetadataToStatuses(teamName, statuses); + const statuses = await this.attachLiveRuntimeMetadataToStatuses( + teamName, + snapshotToMemberSpawnStatuses(snapshot) + ); return { statuses, runId, @@ -3478,6 +3993,266 @@ export class TeamProvisioningService { }; } + async getTeamAgentRuntimeSnapshot(teamName: string): Promise { + const cached = this.agentRuntimeSnapshotCache.get(teamName); + if (cached && cached.expiresAtMs > Date.now()) { + return cached.snapshot; + } + + const updatedAt = nowIso(); + const runId = this.getTrackedRunId(teamName); + const run = runId ? (this.runs.get(runId) ?? null) : null; + + let configuredMembers: TeamConfig['members'] = []; + try { + configuredMembers = (await this.configReader.getConfig(teamName))?.members ?? []; + } catch { + configuredMembers = []; + } + + const liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName); + const runtimePids = new Set(); + const leadPid = run?.child?.pid; + if (typeof leadPid === 'number' && Number.isFinite(leadPid) && leadPid > 0) { + runtimePids.add(leadPid); + } + for (const metadata of liveRuntimeByMember.values()) { + const memberPid = metadata.pid; + if (typeof memberPid === 'number' && Number.isFinite(memberPid) && memberPid > 0) { + runtimePids.add(memberPid); + } + } + const rssBytesByPid = await this.readProcessRssBytesByPid([...runtimePids]); + const persistedRuntimeMembers = this.readPersistedRuntimeMembers(teamName); + const snapshotMembers: Record = {}; + + const getPersistedRuntimeMember = ( + memberName: string + ): PersistedRuntimeMemberLike | undefined => { + return persistedRuntimeMembers.find((member) => { + const candidateName = typeof member.name === 'string' ? member.name.trim() : ''; + return candidateName.length > 0 && matchesMemberNameOrBase(candidateName, memberName); + }); + }; + + const getLiveRuntimeMember = (memberName: string): LiveTeamAgentRuntimeMetadata | undefined => { + let fallback: LiveTeamAgentRuntimeMetadata | undefined; + for (const [candidateName, metadata] of liveRuntimeByMember.entries()) { + if (candidateName === memberName) { + return metadata; + } + if (matchesMemberNameOrBase(candidateName, memberName)) { + fallback = metadata; + } + } + return fallback; + }; + + for (const member of configuredMembers) { + const memberName = typeof member?.name === 'string' ? member.name.trim() : ''; + if (!memberName) continue; + + const isLead = isLeadMember({ name: memberName, agentType: member.agentType }); + if (isLead) { + const pid = run?.child?.pid; + const rssBytes = pid ? rssBytesByPid.get(pid) : undefined; + const runtimeModel = + run?.request.model?.trim() || + (run?.spawnContext + ? extractCliFlagValue(run.spawnContext.args.join(' '), '--model') + : undefined) || + member.model?.trim() || + undefined; + snapshotMembers[memberName] = { + memberName, + alive: Boolean(pid && !run?.processKilled && !run?.cancelRequested), + restartable: false, + backendType: 'lead', + ...(pid ? { pid } : {}), + ...(runtimeModel ? { runtimeModel } : {}), + ...(rssBytes != null ? { rssBytes } : {}), + updatedAt, + }; + continue; + } + + const persistedRuntimeMember = getPersistedRuntimeMember(memberName); + const liveRuntimeMember = getLiveRuntimeMember(memberName); + const backendType = normalizeTeamAgentRuntimeBackendType( + persistedRuntimeMember?.backendType, + false + ); + const restartable = backendType !== 'in-process'; + const runtimeModel = liveRuntimeMember?.model ?? member.model?.trim() ?? undefined; + + snapshotMembers[memberName] = { + memberName, + alive: liveRuntimeMember?.alive ?? false, + restartable, + ...(backendType ? { backendType } : {}), + ...(liveRuntimeMember?.pid ? { pid: liveRuntimeMember.pid } : {}), + ...(runtimeModel ? { runtimeModel } : {}), + ...(liveRuntimeMember?.pid && rssBytesByPid.has(liveRuntimeMember.pid) + ? { rssBytes: rssBytesByPid.get(liveRuntimeMember.pid) } + : {}), + updatedAt, + }; + } + + const snapshot: TeamAgentRuntimeSnapshot = { + teamName, + updatedAt, + runId: run?.runId ?? null, + members: snapshotMembers, + }; + + this.agentRuntimeSnapshotCache.set(teamName, { + expiresAtMs: Date.now() + TeamProvisioningService.AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS, + snapshot, + }); + return snapshot; + } + + async restartMember(teamName: string, memberName: string): Promise { + const runId = this.getAliveRunId(teamName); + if (!runId) { + throw new Error(`Team "${teamName}" is not currently running`); + } + const run = this.runs.get(runId); + if (!run || run.processKilled || run.cancelRequested) { + throw new Error(`Team "${teamName}" is not currently running`); + } + + const config = await this.configReader.getConfig(teamName); + const configuredMembers = config?.members ?? []; + const configuredMember = configuredMembers.find( + (member) => member?.name?.trim() === memberName + ); + if (!configuredMember) { + throw new Error(`Member "${memberName}" is not configured in team "${teamName}"`); + } + if (configuredMember.removedAt) { + throw new Error(`Member "${memberName}" has been removed`); + } + if (isLeadMember({ name: memberName, agentType: configuredMember.agentType })) { + throw new Error('Lead restart is not supported from member controls'); + } + + const persistedRuntimeMembers = this.readPersistedRuntimeMembers(teamName).filter((member) => { + const candidateName = typeof member.name === 'string' ? member.name.trim() : ''; + return candidateName.length > 0 && matchesMemberNameOrBase(candidateName, memberName); + }); + + const backendTypes = new Set( + persistedRuntimeMembers + .map((member) => member.backendType?.trim().toLowerCase()) + .filter((value): value is string => Boolean(value)) + ); + if (backendTypes.has('in-process')) { + throw new Error( + `Member "${memberName}" uses an in-process runtime and cannot be restarted here` + ); + } + + const liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName); + const livePids = new Set(); + let hasAliveRuntimeWithoutPid = false; + for (const [candidateName, metadata] of liveRuntimeByMember.entries()) { + if (!matchesMemberNameOrBase(candidateName, memberName)) { + continue; + } + if (metadata.pid) { + livePids.add(metadata.pid); + continue; + } + if (metadata.alive && metadata.backendType !== 'in-process') { + hasAliveRuntimeWithoutPid = true; + } + } + + if (hasAliveRuntimeWithoutPid) { + throw new Error( + `Member "${memberName}" is running, but its backend does not expose a restartable pid yet` + ); + } + + for (const persistedRuntimeMember of persistedRuntimeMembers) { + const paneId = + typeof persistedRuntimeMember.tmuxPaneId === 'string' + ? persistedRuntimeMember.tmuxPaneId.trim() + : ''; + const backendType = persistedRuntimeMember.backendType?.trim().toLowerCase(); + if (!paneId || backendType !== 'tmux') { + continue; + } + try { + killTmuxPaneForCurrentPlatformSync(paneId); + logger.info( + `[${teamName}] Killed teammate pane ${memberName} (${paneId}) for manual restart` + ); + } catch (error) { + logger.debug( + `[${teamName}] Failed to kill teammate pane ${memberName} (${paneId}) for manual restart: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + for (const pid of livePids) { + try { + killProcessByPid(pid); + } catch (error) { + logger.debug( + `[${teamName}] Failed to kill teammate process ${memberName} pid=${pid} for manual restart: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + if (livePids.size > 0) { + await waitForPidsToExit(Array.from(livePids), { + timeoutMs: 1_500, + pollMs: 100, + }); + } + + this.agentRuntimeSnapshotCache.delete(teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.setMemberSpawnStatus(run, memberName, 'offline'); + this.setMemberSpawnStatus(run, memberName, 'spawning'); + this.appendMemberBootstrapDiagnostic(run, memberName, 'manual restart requested from UI'); + + const leadName = + configuredMembers.find((member) => isLeadMember(member))?.name?.trim() || 'team-lead'; + const restartMessage = buildRestartMemberSpawnMessage( + teamName, + config?.name?.trim() || teamName, + leadName, + { + name: memberName, + role: configuredMember.role, + workflow: configuredMember.workflow, + providerId: configuredMember.providerId, + model: configuredMember.model, + effort: configuredMember.effort, + } + ); + + try { + await this.sendMessageToRun(run, restartMessage); + } catch (error) { + this.setMemberSpawnStatus( + run, + memberName, + 'error', + error instanceof Error ? error.message : String(error) + ); + throw error; + } + } + private getMemberLaunchGraceKey(run: ProvisioningRun, memberName: string): string { return `member-launch-grace:${run.runId}:${memberName}`; } @@ -3625,15 +4400,7 @@ export class TeamProvisioningService { return; } run.leadContextUsage.lastEmittedAt = now; - const { currentTokens, contextWindow } = run.leadContextUsage; - const percentRaw = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0; - const percent = Math.max(0, Math.min(100, percentRaw)); - const payload: LeadContextUsage = { - currentTokens, - contextWindow, - percent, - updatedAt: new Date().toISOString(), - }; + const payload = this.buildLeadContextUsagePayload(run); this.teamChangeEmitter?.({ type: 'lead-context', teamName: run.teamName, @@ -4367,7 +5134,9 @@ export class TeamProvisioningService { message: this.buildStallProgressMessage(silenceSec, elapsed), messageSeverity: 'warning' as const, }), - assistantOutput: run.provisioningOutputParts.join('\n\n'), + assistantOutput: + buildProgressAssistantOutput(run.provisioningOutputParts) ?? + run.progress.assistantOutput, }; run.onProgress(run.progress); } catch (err) { @@ -4971,6 +5740,7 @@ export class TeamProvisioningService { request.members.map((m) => [m.name, createInitialMemberSpawnStatusEntry()]) ), memberSpawnToolUseIds: new Map(), + memberSpawnLeadInboxCursorByMember: new Map(), lastDeterministicBootstrapSeq: 0, lastMemberSpawnAuditAt: 0, lastMemberSpawnAuditConfigReadWarningAt: 0, @@ -4986,6 +5756,7 @@ export class TeamProvisioningService { }, }; + this.resetTeamScopedTransientStateForNewRun(request.teamName); this.runs.set(runId, run); this.provisioningRunByTeam.set(request.teamName, runId); run.onProgress(run.progress); @@ -5531,7 +6302,7 @@ export class TeamProvisioningService { pendingInboxRelayCandidates: [], provisioningOutputParts: [], provisioningOutputIndexByMessageId: new Map(), - detectedSessionId: null, + detectedSessionId: previousSessionId ?? null, leadActivityState: 'active', leadContextUsage: null, authFailureRetried: false, @@ -5550,6 +6321,7 @@ export class TeamProvisioningService { expectedMembers.map((name) => [name, createInitialMemberSpawnStatusEntry()]) ), memberSpawnToolUseIds: new Map(), + memberSpawnLeadInboxCursorByMember: new Map(), lastDeterministicBootstrapSeq: 0, lastMemberSpawnAuditAt: 0, lastMemberSpawnAuditConfigReadWarningAt: 0, @@ -5571,6 +6343,7 @@ export class TeamProvisioningService { }, }; + this.resetTeamScopedTransientStateForNewRun(request.teamName); this.runs.set(runId, run); this.provisioningRunByTeam.set(request.teamName, runId); run.onProgress(run.progress); @@ -5829,6 +6602,21 @@ export class TeamProvisioningService { throw new Error(`Team "${teamName}" process stdin is not writable`); } + await this.sendMessageToRun(run, message, attachments); + } + + private async sendMessageToRun( + run: ProvisioningRun, + message: string, + attachments?: { data: string; mimeType: string; filename?: string }[] + ): Promise { + if (!this.isCurrentTrackedRun(run)) { + throw new Error(`Team "${run.teamName}" run "${run.runId}" is no longer current`); + } + if (run.processKilled || run.cancelRequested || !run.child?.stdin?.writable) { + throw new Error(`Team "${run.teamName}" process stdin is not writable`); + } + const contentBlocks: Record[] = [{ type: 'text', text: message }]; if (attachments?.length) { for (const att of attachments) { @@ -5948,7 +6736,7 @@ export class TeamProvisioningService { userText, ].join('\n'); - await this.sendMessageToTeam(teamName, message); + await this.sendMessageToRun(run, message); } async relayMemberInboxMessages(teamName: string, memberName: string): Promise { @@ -5970,6 +6758,8 @@ export class TeamProvisioningService { const run = this.runs.get(runId); if (!run?.child || run.processKilled || run.cancelRequested) return 0; if (!run.provisioningComplete) return 0; + const isStaleRelayRun = (): boolean => + !this.isCurrentTrackedRun(run) || !run.child || run.processKilled || run.cancelRequested; const relayedIds = this.relayedMemberInboxMessageIds.get(relayKey) ?? new Set(); @@ -5979,6 +6769,7 @@ export class TeamProvisioningService { } catch { return 0; } + if (isStaleRelayRun()) return 0; const unread = memberInboxMessages .filter((m): m is InboxMessage & { messageId: string } => { @@ -6009,6 +6800,7 @@ export class TeamProvisioningService { .map(({ message }) => message); const readOnlyIgnoredUnread = [...silentNoiseUnread, ...passiveIdleUnread]; + if (isStaleRelayRun()) return 0; if (readOnlyIgnoredUnread.length > 0) { try { @@ -6082,7 +6874,7 @@ export class TeamProvisioningService { ].join('\n'); try { - await this.sendMessageToTeam(teamName, message); + await this.sendMessageToRun(run, message); } catch { this.forgetPendingInboxRelayCandidates(run, memberName, rememberedRelayIds); return 0; @@ -6138,6 +6930,8 @@ export class TeamProvisioningService { if (!runId) return 0; const run = this.runs.get(runId); if (!run?.child || run.processKilled || run.cancelRequested) return 0; + const isStaleRelayRun = (): boolean => + !this.isCurrentTrackedRun(run) || !run.child || run.processKilled || run.cancelRequested; // Permission request scan runs even during provisioning — teammates may need // tool approval before the lead's first turn completes. CLI marks inbox messages @@ -6148,10 +6942,12 @@ export class TeamProvisioningService { } catch { // config not ready yet during early provisioning — skip scan } + if (isStaleRelayRun()) return 0; if (config) { const leadName = config.members?.find((m) => isLeadMember(m))?.name?.trim() || 'team-lead'; try { const leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName); + if (isStaleRelayRun()) return 0; const permMsgsToMarkRead: { messageId: string }[] = []; const runStartedAtMs = Date.parse(run.startedAt); for (const msg of leadInboxMessages) { @@ -6196,6 +6992,7 @@ export class TeamProvisioningService { return 0; } } + if (isStaleRelayRun()) return 0; if (!config) return 0; const leadName = config.members?.find((m) => isLeadMember(m))?.name?.trim() || 'team-lead'; @@ -6205,8 +7002,10 @@ export class TeamProvisioningService { } catch { return 0; } + if (isStaleRelayRun()) return 0; await this.refreshMemberSpawnStatusesFromLeadInbox(run); + if (isStaleRelayRun()) return 0; const unread = leadInboxMessages .filter((m): m is InboxMessage & { messageId: string } => { @@ -6344,6 +7143,7 @@ export class TeamProvisioningService { ...passiveIdleUnread.map((m) => m.messageId), ]); const remainingUnread = unread.filter((m) => !readOnlyIgnoredIds.has(m.messageId)); + if (isStaleRelayRun()) return 0; // Category 2: same-team native delivery confirmation (one-to-one pairing). const { nativeMatchedMessageIds, persisted: sameTeamPersisted } = @@ -6506,7 +7306,7 @@ export class TeamProvisioningService { }); try { - await this.sendMessageToTeam(teamName, message); + await this.sendMessageToRun(run, message); } catch { if (run.leadRelayCapture) { clearTimeout(run.leadRelayCapture.timeoutHandle); @@ -6870,7 +7670,7 @@ export class TeamProvisioningService { return; } - const liveAgentNames = this.getLiveTeamAgentNames(run.teamName); + const liveAgentNames = await this.getLiveTeamAgentNames(run.teamName); // Flag any expected member not found in config.json (excluding the lead) for (const expected of run.expectedMembers) { @@ -6980,63 +7780,310 @@ export class TeamProvisioningService { } } - private hasLiveTeamAgentProcess(teamName: string, memberName: string): boolean { - return this.getLiveTeamAgentRuntimeMetadata(teamName).has(memberName); - } - - private attachLiveRuntimeMetadataToStatuses( + private async attachLiveRuntimeMetadataToStatuses( teamName: string, statuses: Record - ): void { - for (const [memberName, metadata] of this.getLiveTeamAgentRuntimeMetadata(teamName).entries()) { - const current = statuses[memberName]; + ): Promise> { + const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName); + const nextStatuses = { ...statuses }; + for (const [memberName, metadata] of runtimeByMember.entries()) { + const current = nextStatuses[memberName]; if (!current || !metadata.model) { continue; } - statuses[memberName] = { + nextStatuses[memberName] = { ...current, runtimeModel: metadata.model, }; } + return nextStatuses; } - private getLiveTeamAgentNames(teamName: string): Set { - return new Set(this.getLiveTeamAgentRuntimeMetadata(teamName).keys()); + private async getLiveTeamAgentNames(teamName: string): Promise> { + const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName); + return new Set( + [...runtimeByMember.entries()] + .filter(([, metadata]) => metadata.alive) + .map(([memberName]) => memberName) + ); } - private getLiveTeamAgentRuntimeMetadata( + private findConfiguredMemberModel( + configuredMembers: TeamConfig['members'] | undefined, + memberName: string + ): string | undefined { + for (const member of configuredMembers ?? []) { + const candidateName = typeof member?.name === 'string' ? member.name.trim() : ''; + if (!candidateName || !matchesTeamMemberIdentity(candidateName, memberName)) { + continue; + } + const model = member.model?.trim(); + if (model) { + return model; + } + } + return undefined; + } + + private findMetaMemberModel( + metaMembers: Awaited>, + memberName: string + ): string | undefined { + for (const member of metaMembers) { + const candidateName = member.name?.trim() ?? ''; + if (!candidateName || !matchesTeamMemberIdentity(candidateName, memberName)) { + continue; + } + const model = member.model?.trim(); + if (model) { + return model; + } + } + return undefined; + } + + private findEffectiveRunMemberModel( + run: ProvisioningRun | null, + memberName: string + ): string | undefined { + if (!run) { + return undefined; + } + for (const member of run.effectiveMembers ?? []) { + const candidateName = member.name?.trim() ?? ''; + if (!candidateName || !matchesTeamMemberIdentity(candidateName, memberName)) { + continue; + } + const model = member.model?.trim(); + if (model) { + return model; + } + } + return undefined; + } + + private findTrackedMemberSpawnStatus( + run: ProvisioningRun | null, + memberName: string + ): MemberSpawnStatusEntry | undefined { + if (!run) { + return undefined; + } + const statusMap = run.memberSpawnStatuses instanceof Map ? run.memberSpawnStatuses : undefined; + if (!statusMap) { + return undefined; + } + const direct = statusMap.get(memberName); + if (direct) { + return direct; + } + for (const [candidateName, entry] of statusMap.entries()) { + if (matchesTeamMemberIdentity(candidateName, memberName)) { + return entry; + } + } + return undefined; + } + + private async getLiveTeamAgentRuntimeMetadata( teamName: string - ): Map { + ): Promise> { + const cached = this.liveTeamAgentRuntimeMetadataCache.get(teamName); + if (cached && cached.expiresAtMs > Date.now()) { + return cached.metadata; + } + + const runId = this.getTrackedRunId(teamName); + const run = runId ? (this.runs.get(runId) ?? null) : null; + + let configuredMembers: TeamConfig['members'] = []; + try { + configuredMembers = (await this.configReader.getConfig(teamName))?.members ?? []; + } catch { + configuredMembers = []; + } + + let metaMembers: Awaited> = []; + try { + metaMembers = await this.membersMetaStore.getMembers(teamName); + } catch { + metaMembers = []; + } + + const persistedRuntimeMembers = this.readPersistedRuntimeMembers(teamName); + const metadataByMember = new Map(); + const upsertMetadata = ( + memberName: string, + patch: Partial + ): void => { + const current = metadataByMember.get(memberName) ?? { alive: false }; + metadataByMember.set(memberName, { + ...current, + ...patch, + alive: patch.alive ?? current.alive, + }); + }; + + for (const member of persistedRuntimeMembers) { + const memberName = typeof member.name === 'string' ? member.name.trim() : ''; + if (!memberName || isLeadMember({ name: memberName })) { + continue; + } + const runtimeModel = + this.findConfiguredMemberModel(configuredMembers, memberName) ?? + this.findEffectiveRunMemberModel(run, memberName) ?? + this.findMetaMemberModel(metaMembers, memberName); + upsertMetadata(memberName, { + backendType: normalizeTeamAgentRuntimeBackendType(member.backendType, false), + agentId: + typeof member.agentId === 'string' ? member.agentId.trim() || undefined : undefined, + tmuxPaneId: + typeof member.tmuxPaneId === 'string' ? member.tmuxPaneId.trim() || undefined : undefined, + ...(runtimeModel ? { model: runtimeModel } : {}), + }); + } + + for (const member of configuredMembers) { + const memberName = typeof member?.name === 'string' ? member.name.trim() : ''; + if (!memberName || isLeadMember({ name: memberName, agentType: member.agentType })) { + continue; + } + const runtimeModel = + member.model?.trim() || + this.findEffectiveRunMemberModel(run, memberName) || + this.findMetaMemberModel(metaMembers, memberName); + upsertMetadata(memberName, { + ...(runtimeModel ? { model: runtimeModel } : {}), + }); + } + + for (const member of run?.effectiveMembers ?? []) { + const memberName = member.name?.trim() ?? ''; + if (!memberName || isLeadMember(member) || memberName.toLowerCase() === 'user') { + continue; + } + upsertMetadata(memberName, { + ...(member.model?.trim() ? { model: member.model.trim() } : {}), + }); + } + + const paneIds = [...metadataByMember.values()] + .map((metadata) => metadata.tmuxPaneId?.trim() ?? '') + .filter((paneId) => paneId.length > 0); + let panePidById = new Map(); + if (paneIds.length > 0) { + try { + panePidById = await listTmuxPanePidsForCurrentPlatform(paneIds); + } catch (error) { + logger.debug( + `[${teamName}] Failed to read tmux pane pids for runtime snapshot: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + for (const [memberName, metadata] of metadataByMember.entries()) { + const paneId = metadata.tmuxPaneId?.trim() ?? ''; + const backendType = metadata.backendType; + const panePid = paneId ? panePidById.get(paneId) : undefined; + const status = this.findTrackedMemberSpawnStatus(run, memberName); + const alive = + typeof panePid === 'number' && panePid > 0 + ? true + : backendType === 'tmux' + ? false + : Boolean(status?.runtimeAlive || status?.bootstrapConfirmed); + metadataByMember.set(memberName, { + ...metadata, + alive, + ...(typeof panePid === 'number' && panePid > 0 ? { pid: panePid } : {}), + }); + } + + this.liveTeamAgentRuntimeMetadataCache.set(teamName, { + expiresAtMs: Date.now() + TeamProvisioningService.AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS, + metadata: metadataByMember, + }); + return metadataByMember; + } + + private readUnixProcessTableRows(): { + pid: number; + command: string; + }[] { if (process.platform === 'win32') { - return new Map(); + return []; } let output = ''; try { - output = execFileSync('ps', ['-ax', '-o', 'command='], { + output = execFileSync('ps', ['-ax', '-o', 'pid=,command='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }); } catch { + return []; + } + + const rows: { pid: number; command: string }[] = []; + for (const line of output.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + const match = /^(\d+)\s+(.*)$/.exec(trimmed); + if (!match) continue; + const pid = Number.parseInt(match[1], 10); + const command = match[2]?.trim() ?? ''; + if (!Number.isFinite(pid) || pid <= 0 || command.length === 0) { + continue; + } + rows.push({ + pid, + command, + }); + } + return rows; + } + + private async readProcessRssBytesByPid(pids: readonly number[]): Promise> { + const uniquePids = [...new Set(pids.filter((pid) => Number.isFinite(pid) && pid > 0))]; + if (uniquePids.length === 0) { return new Map(); } - const teamMarker = `--team-name ${teamName}`; - const metadataByAgent = new Map(); - for (const line of output.split('\n')) { - const trimmed = line.trim(); - if (!trimmed.includes(teamMarker)) continue; - const match = /--agent-id\s+([^\s@]+)@/.exec(trimmed); - if (!match) continue; - const agentName = match[1]?.trim(); - if (agentName) { - const model = extractCliFlagValue(trimmed, '--model'); - metadataByAgent.set(agentName, { - ...(model ? { model } : {}), - }); + const rssBytesByPid = new Map(); + const options = { maxage: 0 }; + try { + const statsByPid = await pidusage(uniquePids, options); + for (const [rawPid, stat] of Object.entries(statsByPid)) { + const pid = Number.parseInt(rawPid, 10); + const rssBytes = stat?.memory; + if (Number.isFinite(pid) && pid > 0 && Number.isFinite(rssBytes) && rssBytes >= 0) { + rssBytesByPid.set(pid, rssBytes); + } } + return rssBytesByPid; + } catch (error) { + logger.debug( + `pidusage batch runtime snapshot failed; falling back to per-pid reads: ${ + error instanceof Error ? error.message : String(error) + }` + ); } - return metadataByAgent; + + await Promise.all( + uniquePids.map(async (pid) => { + try { + const stat = await pidusage(pid, options); + if (Number.isFinite(stat.memory) && stat.memory >= 0) { + rssBytesByPid.set(pid, stat.memory); + } + } catch { + // Process likely exited between discovery and sampling. + } + }) + ); + return rssBytesByPid; } private async clearPersistedLaunchState(teamName: string): Promise { @@ -7202,7 +8249,7 @@ export class TeamProvisioningService { // best-effort } - const liveAgentNames = this.getLiveTeamAgentNames(teamName); + const liveAgentNames = await this.getLiveTeamAgentNames(teamName); const nextMembers = { ...persisted.members }; const now = nowIso(); for (const expected of persisted.expectedMembers) { @@ -7552,6 +8599,12 @@ export class TeamProvisioningService { if (result.deduplicated) { return; } + if (this.getTrackedRunId(run.teamName) !== run.runId) { + logger.debug( + `[${run.teamName}] Skipping stale cross-team send result for old run ${run.runId}` + ); + return; + } const msg: InboxMessage = { from: leadName, to: recipient.startsWith('cross-team:') @@ -7779,11 +8832,18 @@ export class TeamProvisioningService { private pushLiveLeadTextMessage( run: ProvisioningRun, cleanText: string, - stableMessageId?: string + stableMessageId?: string, + messageTimestamp?: string ): void { run.leadMsgSeq += 1; const leadName = this.getRunLeadName(run); const messageId = stableMessageId || `lead-turn-${run.runId}-${run.leadMsgSeq}`; + const timestamp = + typeof messageTimestamp === 'string' && + messageTimestamp.trim().length > 0 && + Number.isFinite(Date.parse(messageTimestamp)) + ? messageTimestamp + : nowIso(); // Attach accumulated tool call details from preceding tool_use messages, then reset. const toolCalls = run.pendingToolCalls.length > 0 ? [...run.pendingToolCalls] : undefined; const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined; @@ -7791,7 +8851,7 @@ export class TeamProvisioningService { const leadMsg: InboxMessage = { from: leadName, text: cleanText, - timestamp: nowIso(), + timestamp, read: true, summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText, messageId, @@ -7819,6 +8879,8 @@ export class TeamProvisioningService { * Always uses SIGKILL via killTeamProcess() to prevent CLI cleanup. */ stopTeam(teamName: string): void { + this.agentRuntimeSnapshotCache.delete(teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(teamName); this.stopPersistentTeamMembers(teamName); const runId = this.getTrackedRunId(teamName); @@ -8139,6 +9201,18 @@ export class TeamProvisioningService { // stream-json output has various message types: // {"type":"assistant","content":[{"type":"text","text":"..."},...]} // {"type":"result","subtype":"success",...} + // Capture session_id as early as possible so live messages emitted during this + // handler already carry the session identity used by merge/dedup paths. + if (!run.detectedSessionId) { + const sid = typeof msg.session_id === 'string' ? msg.session_id : undefined; + if (sid && sid.trim().length > 0) { + run.detectedSessionId = sid.trim(); + logger.info( + `[${run.teamName}] Detected session ID from stream-json: ${run.detectedSessionId}` + ); + } + } + if (msg.type === 'user') { // Check for permission_request in raw user message text BEFORE teammate-message parsing. // The permission_request may arrive as plain JSON without wrapper, @@ -8181,6 +9255,12 @@ export class TeamProvisioningService { .map((part) => part.text as string); if (textParts.length > 0) { const text = textParts.join('\n'); + const messageTimestamp = + typeof msg.timestamp === 'string' && + msg.timestamp.trim().length > 0 && + Number.isFinite(Date.parse(msg.timestamp)) + ? msg.timestamp + : undefined; // Auth failures sometimes show up as assistant text (e.g. "401", "Please run /login") // rather than stderr or a result.subtype=error. Detect early to avoid false "ready". this.handleAuthFailureInOutput(run, text, 'assistant'); @@ -8223,7 +9303,8 @@ export class TeamProvisioningService { this.pushLiveLeadTextMessage( run, cleanText, - this.getStableLeadThoughtMessageId(msg) ?? undefined + this.getStableLeadThoughtMessageId(msg) ?? undefined, + messageTimestamp ); } } @@ -8236,7 +9317,8 @@ export class TeamProvisioningService { this.pushLiveLeadTextMessage( run, cleanText, - this.getStableLeadThoughtMessageId(msg) ?? undefined + this.getStableLeadThoughtMessageId(msg) ?? undefined, + messageTimestamp ); } } @@ -8282,36 +9364,12 @@ export class TeamProvisioningService { if (usage && typeof usage === 'object') { // Dedup: skip if same message.id (SDK bug: multi-block = same usage repeated) if (!msgId || run.leadContextUsage?.lastUsageMessageId !== msgId) { - const inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0; - const cacheCreation = - typeof usage.cache_creation_input_tokens === 'number' - ? usage.cache_creation_input_tokens - : 0; - const cacheRead = - typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0; - // Total context window usage = all three token categories - // input_tokens = tokens AFTER last cache breakpoint (small) - // cache_creation = tokens written to cache (first request) - // cache_read = tokens read from cache (subsequent requests) — these ARE in context window - const currentTokens = inputTokens + cacheCreation + cacheRead; - - if (!run.leadContextUsage) { - // Determine initial context window from model selection - // computeEffectiveTeamModel() defaults to 'opus[1m]' when no model selected - const modelStr = (run.request.model ?? '').toLowerCase(); - const isHaiku = modelStr.includes('haiku'); - const isLimitedContext = run.request.limitContext === true; - // limitContext=true → 200K, haiku → 200K, [1m] → 1M, default → 1M (opus[1m]) - const initialContextWindow = isLimitedContext || isHaiku ? 200_000 : 1_000_000; - - run.leadContextUsage = { - currentTokens, - contextWindow: initialContextWindow, - lastUsageMessageId: msgId, - lastEmittedAt: 0, - }; - } else { - run.leadContextUsage.currentTokens = currentTokens; + this.updateLeadContextUsageFromUsage( + run, + usage, + typeof messageObj.model === 'string' ? messageObj.model : undefined + ); + if (run.leadContextUsage) { run.leadContextUsage.lastUsageMessageId = msgId; } this.emitLeadContextUsage(run); @@ -8320,17 +9378,6 @@ export class TeamProvisioningService { } } - // Capture session_id from any message type (first occurrence wins) - if (!run.detectedSessionId) { - const sid = typeof msg.session_id === 'string' ? msg.session_id : undefined; - if (sid && sid.trim().length > 0) { - run.detectedSessionId = sid.trim(); - logger.info( - `[${run.teamName}] Detected session ID from stream-json: ${run.detectedSessionId}` - ); - } - } - if (this.handleDeterministicBootstrapEvent(run, msg)) { return; } @@ -8369,13 +9416,16 @@ export class TeamProvisioningService { ) { if (!run.leadContextUsage) { run.leadContextUsage = { - currentTokens: 0, - contextWindow: modelData.contextWindow, + promptInputTokens: null, + outputTokens: null, + contextUsedTokens: null, + contextWindowTokens: modelData.contextWindow, + promptInputSource: 'unavailable', lastUsageMessageId: null, lastEmittedAt: 0, }; } else { - run.leadContextUsage.contextWindow = modelData.contextWindow; + run.leadContextUsage.contextWindowTokens = modelData.contextWindow; run.leadContextUsage.lastEmittedAt = 0; // force re-emit } this.emitLeadContextUsage(run); @@ -8390,30 +9440,17 @@ export class TeamProvisioningService { | Record | undefined; if (resultUsage && typeof resultUsage === 'object') { - const inp = typeof resultUsage.input_tokens === 'number' ? resultUsage.input_tokens : 0; - const cc = - typeof resultUsage.cache_creation_input_tokens === 'number' - ? resultUsage.cache_creation_input_tokens - : 0; - const cr = - typeof resultUsage.cache_read_input_tokens === 'number' - ? resultUsage.cache_read_input_tokens - : 0; - const total = inp + cc + cr; - if (total > 0) { - if (!run.leadContextUsage) { - run.leadContextUsage = { - currentTokens: total, - contextWindow: 0, - lastUsageMessageId: null, - lastEmittedAt: 0, - }; - } else { - run.leadContextUsage.currentTokens = total; - run.leadContextUsage.lastEmittedAt = 0; - } - this.emitLeadContextUsage(run); + this.updateLeadContextUsageFromUsage( + run, + resultUsage, + typeof (msg.result as Record | undefined)?.model === 'string' + ? ((msg.result as Record).model as string) + : undefined + ); + if (run.leadContextUsage) { + run.leadContextUsage.lastEmittedAt = 0; } + this.emitLeadContextUsage(run); } if (run.provisioningComplete) { @@ -8635,7 +9672,9 @@ export class TeamProvisioningService { updatedAt: nowIso(), message: retryText, messageSeverity: 'error' as const, - assistantOutput: run.provisioningOutputParts.join('\n\n'), + assistantOutput: + buildProgressAssistantOutput(run.provisioningOutputParts) ?? + run.progress.assistantOutput, }; run.onProgress(run.progress); } @@ -9916,7 +10955,7 @@ export class TeamProvisioningService { `Не стартовали тиммейты: ${failedSpawnMembers.map((member) => `@${member.name}`).join(', ')}.`, `Не считай их доступными, пока их запуск не будет повторён успешно.`, ].join(' '); - await this.sendMessageToTeam(run.teamName, failureNotice).catch((error: unknown) => + await this.sendMessageToRun(run, failureNotice).catch((error: unknown) => logger.warn( `[${run.teamName}] failed to send teammate-start failure notice to lead: ${ error instanceof Error ? error.message : String(error) @@ -9964,7 +11003,7 @@ export class TeamProvisioningService { .filter(Boolean) .join('\n\n'); - await this.sendMessageToTeam(run.teamName, message); + await this.sendMessageToRun(run, message); } catch (error) { logger.warn( `[${run.teamName}] Failed to kick off solo task resumption: ${ @@ -10084,7 +11123,7 @@ export class TeamProvisioningService { `Не стартовали тиммейты: ${failedSpawnMembers.map((member) => `@${member.name}`).join(', ')}.`, `Не считай их доступными, пока их запуск не будет повторён успешно.`, ].join(' '); - await this.sendMessageToTeam(run.teamName, failureNotice).catch((error: unknown) => + await this.sendMessageToRun(run, failureNotice).catch((error: unknown) => logger.warn( `[${run.teamName}] failed to send teammate-start failure notice to lead: ${ error instanceof Error ? error.message : String(error) @@ -10404,7 +11443,14 @@ export class TeamProvisioningService { * Remove a run from tracking maps. */ private cleanupRun(run: ProvisioningRun): void { - if (run.isLaunch && !run.provisioningComplete) { + const currentTrackedRunId = this.getTrackedRunId(run.teamName); + const hasNewerTrackedRun = currentTrackedRunId !== null && currentTrackedRunId !== run.runId; + + if (!hasNewerTrackedRun) { + peekAutoResumeService()?.cancelPendingAutoResume(run.teamName); + } + + if (!hasNewerTrackedRun && run.isLaunch && !run.provisioningComplete) { void this.persistLaunchStateSnapshot(run, 'finished'); } this.resetRuntimeToolActivity(run); @@ -10433,19 +11479,15 @@ export class TeamProvisioningService { if (this.aliveRunByTeam.get(run.teamName) === run.runId) { this.aliveRunByTeam.delete(run.teamName); } - this.leadInboxRelayInFlight.delete(run.teamName); - this.relayedLeadInboxMessageIds.delete(run.teamName); - this.pendingCrossTeamFirstReplies.delete(run.teamName); - this.recentCrossTeamLeadDeliveryMessageIds.delete(run.teamName); - this.recentSameTeamNativeFingerprints.delete(run.teamName); - // Clear same-team retry timers - for (const suffix of ['deferred', 'persist']) { - const key = `same-team-${suffix}:${run.teamName}`; - const timer = this.pendingTimeouts.get(key); - if (timer) { - clearTimeout(timer); - this.pendingTimeouts.delete(key); - } + if (!hasNewerTrackedRun) { + this.agentRuntimeSnapshotCache.delete(run.teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); + this.leadInboxRelayInFlight.delete(run.teamName); + this.relayedLeadInboxMessageIds.delete(run.teamName); + this.pendingCrossTeamFirstReplies.delete(run.teamName); + this.recentCrossTeamLeadDeliveryMessageIds.delete(run.teamName); + this.recentSameTeamNativeFingerprints.delete(run.teamName); + this.clearSameTeamRetryTimers(run.teamName); } for (const memberName of run.memberSpawnStatuses.keys()) { const key = this.getMemberLaunchGraceKey(run, memberName); @@ -10457,17 +11499,21 @@ export class TeamProvisioningService { } run.activeCrossTeamReplyHints = []; run.pendingInboxRelayCandidates = []; - for (const key of Array.from(this.memberInboxRelayInFlight.keys())) { - if (key.startsWith(`${run.teamName}:`)) { - this.memberInboxRelayInFlight.delete(key); + if (!hasNewerTrackedRun) { + for (const key of Array.from(this.memberInboxRelayInFlight.keys())) { + if (key.startsWith(`${run.teamName}:`)) { + this.memberInboxRelayInFlight.delete(key); + } } - } - for (const key of Array.from(this.relayedMemberInboxMessageIds.keys())) { - if (key.startsWith(`${run.teamName}:`)) { - this.relayedMemberInboxMessageIds.delete(key); + for (const key of Array.from(this.relayedMemberInboxMessageIds.keys())) { + if (key.startsWith(`${run.teamName}:`)) { + this.relayedMemberInboxMessageIds.delete(key); + } } + this.liveLeadProcessMessages.delete(run.teamName); + } else { + this.pruneLiveLeadMessagesForCleanedRun(run); } - this.liveLeadProcessMessages.delete(run.teamName); // Dismiss any pending tool approvals for this run if (run.pendingApprovals.size > 0) { for (const requestId of run.pendingApprovals.keys()) { @@ -12052,6 +13098,7 @@ export class TeamProvisioningService { providerId: TeamProviderId | undefined = 'anthropic' ): Promise<{ warning?: string }> { const resolvedProviderId = resolveTeamProviderId(providerId); + const cliCommandLabel = getConfiguredCliCommandLabel(); try { const versionProbe = await this.spawnProbe( claudePath, @@ -12063,9 +13110,9 @@ export class TeamProvisioningService { if (versionProbe.exitCode !== 0) { const errorText = buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) || - `Claude CLI exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`; + `${cliCommandLabel} exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`; return { - warning: `Claude CLI binary failed to start correctly. Details: ${errorText}`, + warning: `${cliCommandLabel} binary failed to start correctly. Details: ${errorText}`, }; } } catch (error) { @@ -12076,7 +13123,7 @@ export class TeamProvisioningService { }; } return { - warning: `Claude CLI binary failed to start. Details: ${message}`, + warning: `${cliCommandLabel} binary failed to start. Details: ${message}`, }; } @@ -12138,7 +13185,7 @@ export class TeamProvisioningService { } return { warning: - 'Preflight check for `claude -p` did not complete. ' + + `Preflight check for \`${cliCommandLabel} -p\` did not complete. ` + `Proceeding anyway. Details: ${message}`, }; } @@ -12159,13 +13206,15 @@ export class TeamProvisioningService { const hint = isAuthFailure ? resolvedProviderId === 'codex' ? 'Codex provider is not authenticated for `-p` mode. ' + - 'Run `claude-multimodel auth login --provider codex` and retry.' + + `Authenticate Codex in ${cliCommandLabel} and retry.` + (attempt > 1 ? ` (failed after ${attempt} attempts)` : '') - : 'Claude CLI `-p` mode is not authenticated. ' + - 'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' + + : `${cliCommandLabel} \`-p\` mode is not authenticated. ` + + (cliCommandLabel === 'claude' + ? 'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' + : `Authenticate Anthropic in ${cliCommandLabel} and retry. `) + 'For automation/headless use, set ANTHROPIC_API_KEY.' + (attempt > 1 ? ` (failed after ${attempt} attempts)` : '') - : `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`; + : `${cliCommandLabel} preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`; return { warning: hint }; } @@ -12206,7 +13255,7 @@ export class TeamProvisioningService { const targetCwd = cwd ?? process.cwd(); const probeResult = await this.getCachedOrProbeResult(targetCwd, 'anthropic'); if (!probeResult?.claudePath) { - throw new Error('Claude CLI not found'); + throw new Error(`${getConfiguredCliCommandLabel()} not found`); } const { env } = await this.buildProvisioningEnv(); const result = await this.spawnProbe( @@ -12219,7 +13268,7 @@ export class TeamProvisioningService { const output = (result.stdout + '\n' + result.stderr).trim(); if (!output) { throw new Error( - `claude --help returned empty output (exit code: ${String(result.exitCode)})` + `${getConfiguredCliCommandLabel()} --help returned empty output (exit code: ${String(result.exitCode)})` ); } this.helpOutputCache = output; @@ -12230,50 +13279,322 @@ export class TeamProvisioningService { private buildAgentTeamsMcpValidationError(output: string): string { const detail = this.normalizeApiRetryErrorMessage(output) || output.trim(); if (!detail) { - return ( - 'agent-teams MCP loaded config but did not expose member_briefing. ' + - 'The leader would start without required team MCP tools.' + return 'agent-teams MCP preflight failed before team launch.'; + } + return `agent-teams MCP preflight failed before team launch. Details: ${detail}`; + } + + private async readAgentTeamsMcpLaunchSpec( + mcpConfigPath: string + ): Promise { + let parsed: AgentTeamsMcpConfigFile; + try { + const raw = await fs.promises.readFile(mcpConfigPath, 'utf8'); + parsed = JSON.parse(raw) as AgentTeamsMcpConfigFile; + } catch (error) { + throw new Error( + this.buildAgentTeamsMcpValidationError( + `Failed to read generated MCP config ${mcpConfigPath}: ${ + error instanceof Error ? error.message : String(error) + }` + ) ); } - return ( - 'agent-teams MCP loaded config but did not expose member_briefing. ' + `Details: ${detail}` + + const server = parsed.mcpServers?.['agent-teams']; + if (!server) { + throw new Error( + this.buildAgentTeamsMcpValidationError( + `Generated MCP config ${mcpConfigPath} does not contain an "agent-teams" server entry.` + ) + ); + } + + if (typeof server.command !== 'string' || server.command.trim().length === 0) { + throw new Error( + this.buildAgentTeamsMcpValidationError( + 'Generated agent-teams MCP config is missing a valid launch command.' + ) + ); + } + + if (server.args !== undefined && !isStringArray(server.args)) { + throw new Error( + this.buildAgentTeamsMcpValidationError( + 'Generated agent-teams MCP config has invalid args; expected a string array.' + ) + ); + } + + if (server.cwd !== undefined && typeof server.cwd !== 'string') { + throw new Error( + this.buildAgentTeamsMcpValidationError( + 'Generated agent-teams MCP config has invalid cwd; expected a string path.' + ) + ); + } + + return { + command: server.command, + args: server.args ?? [], + cwd: typeof server.cwd === 'string' ? server.cwd : undefined, + env: normalizeRecordStringValues(server.env), + }; + } + + private async createAgentTeamsMcpValidationFixture( + projectPath: string + ): Promise { + const claudeDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'agent-teams-mcp-validate-') ); + const teamName = 'mcp-validation-team'; + const memberName = 'mcp-validation-member'; + const teamDir = path.join(claudeDir, 'teams', teamName); + + await fs.promises.mkdir(teamDir, { recursive: true }); + await fs.promises.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify( + { + name: teamName, + projectPath, + members: [ + { name: 'team-lead', agentType: 'team-lead', role: 'lead' }, + { name: memberName, agentType: 'teammate', role: 'developer' }, + ], + }, + null, + 2 + ), + 'utf8' + ); + + return { + claudeDir, + teamName, + memberName, + }; } private async validateAgentTeamsMcpRuntime( - claudePath: string, + _claudePath: string, cwd: string, env: NodeJS.ProcessEnv, mcpConfigPath: string ): Promise { - const result = await this.spawnProbe( - claudePath, - [ - '--setting-sources', - 'user,project,local', - '--mcp-config', - mcpConfigPath, - '--', - 'mcp', - 'get', - 'agent-teams', - ], - cwd, - env, - VERIFY_TIMEOUT_MS - ); + const launchSpec = await this.readAgentTeamsMcpLaunchSpec(mcpConfigPath); + const fixture = await this.createAgentTeamsMcpValidationFixture(cwd); + let child: ReturnType | null = null; + let stdoutBuffer = ''; + let stderrBuffer = ''; + let nextRequestId = 1; + const pending = new Map< + number, + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timeoutHandle: ReturnType; + } + >(); - const combinedOutput = buildCombinedLogs(result.stdout, result.stderr).trim(); - if (result.exitCode !== 0) { - throw new Error(this.buildAgentTeamsMcpValidationError(combinedOutput)); - } + const rejectAll = (error: Error): void => { + for (const [id, entry] of pending) { + clearTimeout(entry.timeoutHandle); + entry.reject(error); + pending.delete(id); + } + }; - const normalizedOutput = combinedOutput.toLowerCase(); - if ( - !normalizedOutput.includes('status: ✓ connected') && - !normalizedOutput.includes('status: connected') - ) { - throw new Error(this.buildAgentTeamsMcpValidationError(combinedOutput)); + try { + child = spawnCli(launchSpec.command, launchSpec.args, { + cwd: launchSpec.cwd ?? cwd, + env: { ...env, ...launchSpec.env }, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + + const parseStdoutLine = (line: string): void => { + let message: McpJsonRpcResponse; + try { + message = JSON.parse(line) as McpJsonRpcResponse; + } catch (error) { + logger.warn( + `agent-teams MCP preflight emitted non-JSON stdout line: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return; + } + + if (typeof message.id !== 'number') { + return; + } + + const entry = pending.get(message.id); + if (!entry) { + return; + } + + clearTimeout(entry.timeoutHandle); + pending.delete(message.id); + + if (message.error) { + entry.reject(new Error(message.error.message ?? 'Unknown MCP JSON-RPC error')); + return; + } + + entry.resolve(message.result); + }; + + child.stdout?.setEncoding('utf8'); + child.stdout?.on('data', (chunk: string | Buffer) => { + stdoutBuffer += chunk.toString(); + + while (true) { + const newlineIndex = stdoutBuffer.indexOf('\n'); + if (newlineIndex === -1) { + break; + } + + const line = stdoutBuffer.slice(0, newlineIndex).trim(); + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); + if (!line) { + continue; + } + parseStdoutLine(line); + } + }); + + child.stderr?.setEncoding('utf8'); + child.stderr?.on('data', (chunk: string | Buffer) => { + stderrBuffer += chunk.toString(); + }); + + child.once('error', (error) => { + rejectAll(error instanceof Error ? error : new Error(String(error))); + }); + + child.once('close', (code, signal) => { + if (pending.size === 0) { + return; + } + rejectAll( + new Error( + `agent-teams MCP process exited unexpectedly during preflight (code=${ + code ?? 'null' + } signal=${signal ?? 'null'})` + ) + ); + }); + + const request = ( + method: string, + params: Record, + timeoutMs: number = VERIFY_TIMEOUT_MS + ): Promise => + new Promise((resolve, reject) => { + if (!child?.stdin) { + reject(new Error('agent-teams MCP stdin is not available')); + return; + } + + const id = nextRequestId++; + const timeoutHandle = setTimeout(() => { + pending.delete(id); + reject(new Error(`agent-teams MCP request timed out: ${method}`)); + }, timeoutMs); + + pending.set(id, { + resolve: resolve as (value: unknown) => void, + reject, + timeoutHandle, + }); + + child.stdin.write( + `${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`, + (error) => { + if (!error) { + return; + } + clearTimeout(timeoutHandle); + pending.delete(id); + reject(error instanceof Error ? error : new Error(String(error))); + } + ); + }); + + const notify = async (method: string, params?: Record): Promise => { + if (!child?.stdin) { + throw new Error('agent-teams MCP stdin is not available'); + } + const stdin = child.stdin; + + await new Promise((resolve, reject) => { + stdin.write( + `${JSON.stringify({ jsonrpc: '2.0', method, ...(params ? { params } : {}) })}\n`, + (error) => { + if (error) { + reject(error instanceof Error ? error : new Error(String(error))); + return; + } + resolve(); + } + ); + }); + }; + + await request('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'claude-agent-teams-ui', version: '1.0.0' }, + }); + await notify('notifications/initialized'); + + const toolsList = await request('tools/list', {}); + const memberBriefingTool = (toolsList.tools ?? []).find( + (tool) => tool.name === 'member_briefing' + ); + if (!memberBriefingTool) { + throw new Error('agent-teams MCP started but tools/list did not include member_briefing'); + } + + const memberBriefing = await request('tools/call', { + name: 'member_briefing', + arguments: { + claudeDir: fixture.claudeDir, + teamName: fixture.teamName, + memberName: fixture.memberName, + }, + }); + + if (memberBriefing.isError) { + throw new Error( + memberBriefing.content?.[0]?.text ?? + 'agent-teams MCP returned an unspecified error for member_briefing' + ); + } + + const briefingText = memberBriefing.content?.find((item) => item.type === 'text')?.text ?? ''; + if (briefingText.trim().length === 0) { + throw new Error('agent-teams MCP returned empty content for member_briefing'); + } + } catch (error) { + const detail = buildCombinedLogs('', stderrBuffer).trim(); + const errorText = + error instanceof Error && detail.length > 0 + ? `${error.message}\n${detail}` + : detail || String(error); + throw new Error(this.buildAgentTeamsMcpValidationError(errorText)); + } finally { + rejectAll(new Error('agent-teams MCP preflight session closed')); + if (child?.stdin && !child.stdin.destroyed) { + child.stdin.end(); + } + if (child) { + killProcessTree(child); + } + await fs.promises.rm(fixture.claudeDir, { recursive: true, force: true }).catch(() => {}); } } @@ -12305,7 +13626,7 @@ export class TeamProvisioningService { const timeoutHandle = setTimeout(() => { settled = true; killProcessTree(child); - reject(new Error(`Timeout running: claude ${args.join(' ')}`)); + reject(new Error(`Timeout running: ${getConfiguredCliCommandLabel()} ${args.join(' ')}`)); }, timeoutMs); const maybeResolveEarly = (): void => { diff --git a/src/main/services/team/TeamTranscriptProjectResolver.ts b/src/main/services/team/TeamTranscriptProjectResolver.ts index 4099ce1d..63c1de58 100644 --- a/src/main/services/team/TeamTranscriptProjectResolver.ts +++ b/src/main/services/team/TeamTranscriptProjectResolver.ts @@ -1,4 +1,12 @@ -import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder'; +import { atomicWriteAsync } from '@main/utils/atomicWrite'; +import { extractCwd } from '@main/utils/jsonl'; +import { + encodePath, + extractBaseDir, + getProjectsBasePath, + getTeamsBasePath, +} from '@main/utils/pathDecoder'; +import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { createReadStream, type Dirent } from 'fs'; import * as fs from 'fs/promises'; @@ -15,6 +23,33 @@ const SESSION_DISCOVERY_CACHE_TTL = 30_000; const TEAM_AFFINITY_SCAN_LINES = 40; const ROOT_DISCOVERY_CONCURRENCY = 12; +type ProjectEvidenceSource = + | 'projectPath' + | 'projectPathHistory' + | 'leadCwd' + | 'memberCwd' + | 'projectsScan'; + +interface ProjectPathCandidate { + projectPath: string; + source: Exclude; +} + +interface ProjectDirCandidate { + projectPath: string; + projectDir: string; + projectId: string; + source: ProjectEvidenceSource; +} + +interface SessionProjectMatch extends ProjectDirCandidate { + matchedSessionId: string; +} + +type ScannedSessionProjectMatch = Omit & { + projectPath?: string; +}; + function trimTrailingSlashes(value: string): string { let end = value.length; while (end > 0) { @@ -32,6 +67,17 @@ function isSessionDirectoryName(name: string): boolean { return name !== 'memory' && !name.startsWith('.'); } +function normalizeProjectPathCandidate(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + return trimTrailingSlashes(trimmed); +} + function extractTextContent(entry: Record): string | null { if (typeof entry.content === 'string') { return entry.content; @@ -71,6 +117,9 @@ function lineMentionsTeam(text: string, teamName: string): boolean { return false; } return ( + normalizedText.includes(`team name: ${normalizedTeam}`) || + normalizedText.includes(`team name "${normalizedTeam}"`) || + normalizedText.includes(`team name '${normalizedTeam}'`) || normalizedText.includes(`on team "${normalizedTeam}"`) || normalizedText.includes(`on team '${normalizedTeam}'`) || normalizedText.includes(`team "${normalizedTeam}"`) || @@ -79,6 +128,28 @@ function lineMentionsTeam(text: string, teamName: string): boolean { ); } +function entryContainsNestedTeamName(value: unknown, teamName: string, depth: number = 0): boolean { + if (!value || depth > 8 || typeof value !== 'object') { + return false; + } + + if (Array.isArray(value)) { + return value.some((item) => entryContainsNestedTeamName(item, teamName, depth + 1)); + } + + const entry = value as Record; + if (typeof entry.teamName === 'string' && entry.teamName.trim().toLowerCase() === teamName) { + return true; + } + + return Object.entries(entry).some(([key, nested]) => { + if (key === 'teamName') { + return false; + } + return entryContainsNestedTeamName(nested, teamName, depth + 1); + }); +} + function collectKnownSessionIds(config: TeamConfig): string[] { const knownSessionIds = new Set(); const push = (value: unknown): void => { @@ -93,7 +164,8 @@ function collectKnownSessionIds(config: TeamConfig): string[] { push(config.leadSessionId); if (Array.isArray(config.sessionHistory)) { - for (const sessionId of config.sessionHistory) { + for (let index = config.sessionHistory.length - 1; index >= 0; index -= 1) { + const sessionId = config.sessionHistory[index]; push(sessionId); } } @@ -130,13 +202,40 @@ export class TeamTranscriptProjectResolver { } const config = await this.configReader.getConfig(teamName); - if (!config?.projectPath) { + if (!config) { return null; } - const { projectDir, projectId } = await this.resolveProjectDirectory(config); - const sessionIds = await this.discoverSessionIds(teamName, projectDir, config); - const value = { projectDir, projectId, config, sessionIds }; + const resolution = await this.resolveProjectDirectory(teamName, config); + if (!resolution) { + return null; + } + + const resolvedConfig = + resolution.effectiveProjectPath && + trimTrailingSlashes(resolution.effectiveProjectPath) !== + trimTrailingSlashes(config.projectPath ?? '') + ? { + ...config, + projectPath: resolution.effectiveProjectPath, + projectPathHistory: this.buildRepairedProjectPathHistory( + config.projectPath, + config.projectPathHistory, + resolution.effectiveProjectPath + ), + } + : config; + const sessionIds = await this.discoverSessionIds( + teamName, + resolution.projectDir, + resolvedConfig + ); + const value = { + projectDir: resolution.projectDir, + projectId: resolution.projectId, + config: resolvedConfig, + sessionIds, + }; this.contextCache.set(teamName, { value, expiresAt: Date.now() + SESSION_DISCOVERY_CACHE_TTL, @@ -145,47 +244,377 @@ export class TeamTranscriptProjectResolver { } private async resolveProjectDirectory( + teamName: string, config: TeamConfig - ): Promise<{ projectDir: string; projectId: string }> { - const normalizedProjectPath = trimTrailingSlashes(config.projectPath ?? ''); - let projectId = encodePath(normalizedProjectPath); - let projectDir = path.join(getProjectsBasePath(), extractBaseDir(projectId)); + ): Promise<{ projectDir: string; projectId: string; effectiveProjectPath?: string } | null> { + const sessionIds = collectKnownSessionIds(config); + const pathCandidates = this.collectProjectPathCandidates(config); + const currentCandidate = pathCandidates[0] ?? null; + if (sessionIds.length === 0) { + return this.buildFallbackResolution(teamName, pathCandidates); + } - try { - const stat = await fs.stat(projectDir); - if (!stat.isDirectory()) { - throw new Error('not a directory'); - } - return { projectDir, projectId }; - } catch { - const leadSessionId = - typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 - ? config.leadSessionId.trim() - : null; - if (!leadSessionId) { - return { projectDir, projectId }; - } + const rankBySessionId = new Map(sessionIds.map((sessionId, index) => [sessionId, index])); + const getMatchRank = (match: { matchedSessionId: string } | null): number => + match + ? (rankBySessionId.get(match.matchedSessionId) ?? Number.POSITIVE_INFINITY) + : Number.POSITIVE_INFINITY; - try { - const projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true }); - for (const entry of projectEntries) { - if (!entry.isDirectory()) continue; - const candidateDir = path.join(getProjectsBasePath(), entry.name); - try { - await fs.access(path.join(candidateDir, `${leadSessionId}.jsonl`)); - projectDir = candidateDir; - projectId = entry.name; - break; - } catch { - // not this project - } - } - } catch { - // best-effort fallback + const toResolution = ( + match: Pick & { projectPath?: string } + ): { projectDir: string; projectId: string; effectiveProjectPath?: string } => ({ + projectDir: match.projectDir, + projectId: match.projectId, + ...(match.projectPath ? { effectiveProjectPath: match.projectPath } : {}), + }); + + let currentMatch: SessionProjectMatch | null = null; + if (currentCandidate) { + const resolvedCurrentMatch = await this.findMatchInProjectPathCandidate( + currentCandidate, + sessionIds + ); + if (resolvedCurrentMatch && getMatchRank(resolvedCurrentMatch) === 0) { + return toResolution(resolvedCurrentMatch); + } + if (resolvedCurrentMatch) { + currentMatch = resolvedCurrentMatch; } } - return { projectDir, projectId }; + const configuredMatches = + pathCandidates.length > 1 + ? await this.findMatchesInProjectPathCandidates(pathCandidates.slice(1), sessionIds) + : []; + const scannedMatches = await this.findMatchesByScanningProjects(sessionIds); + + const candidateMatchesByProjectDir = new Map< + string, + SessionProjectMatch | ScannedSessionProjectMatch + >(); + for (const match of configuredMatches) { + if (match.projectDir === currentMatch?.projectDir) { + continue; + } + candidateMatchesByProjectDir.set(match.projectDir, match); + } + for (const match of scannedMatches) { + if (match.projectDir === currentMatch?.projectDir) { + continue; + } + if (!candidateMatchesByProjectDir.has(match.projectDir)) { + candidateMatchesByProjectDir.set(match.projectDir, match); + } + } + + const alternateMatches = [...candidateMatchesByProjectDir.values()]; + const bestAlternateRank = alternateMatches.reduce( + (best, match) => Math.min(best, getMatchRank(match)), + Number.POSITIVE_INFINITY + ); + const currentRank = getMatchRank(currentMatch); + + if (currentMatch && currentRank <= bestAlternateRank) { + return toResolution(currentMatch); + } + + if (bestAlternateRank !== Number.POSITIVE_INFINITY) { + const bestAlternates = alternateMatches.filter( + (match) => getMatchRank(match) === bestAlternateRank + ); + if (bestAlternates.length === 1) { + const winner = bestAlternates[0]; + if (winner.projectPath) { + await this.persistResolvedProjectPath(teamName, config, winner.projectPath); + } + return toResolution(winner); + } + logger.warn( + `[${teamName}] Transcript project resolution ambiguous across exact-session candidates; keeping current path` + ); + return currentMatch + ? toResolution(currentMatch) + : this.buildFallbackResolution(teamName, pathCandidates); + } + + if (currentMatch) { + return toResolution(currentMatch); + } + + return this.buildFallbackResolution(teamName, pathCandidates); + } + + private async buildFallbackResolution( + teamName: string, + candidates: readonly ProjectPathCandidate[] + ): Promise<{ projectDir: string; projectId: string; effectiveProjectPath?: string } | null> { + let firstResolution: { + projectDir: string; + projectId: string; + effectiveProjectPath?: string; + } | null = null; + let firstExistingResolution: { + projectDir: string; + projectId: string; + effectiveProjectPath?: string; + } | null = null; + + for (const candidate of candidates) { + for (const dirCandidate of this.buildProjectDirCandidates(candidate.projectPath)) { + const resolution = { + projectDir: dirCandidate.projectDir, + projectId: dirCandidate.projectId, + effectiveProjectPath: candidate.projectPath, + }; + if (!firstResolution) { + firstResolution = resolution; + } + if (!(await this.projectDirExists(dirCandidate.projectDir))) { + continue; + } + if (!firstExistingResolution) { + firstExistingResolution = resolution; + } + const teamRootSessionIds = await this.listTeamRootSessionIds( + dirCandidate.projectDir, + teamName + ); + if (teamRootSessionIds.length > 0) { + return resolution; + } + } + } + + return firstExistingResolution ?? firstResolution; + } + + private collectProjectPathCandidates(config: TeamConfig): ProjectPathCandidate[] { + const candidates: ProjectPathCandidate[] = []; + const seen = new Set(); + const push = (value: unknown, source: Exclude): void => { + const normalized = normalizeProjectPathCandidate(value); + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + candidates.push({ projectPath: normalized, source }); + }; + + push(config.projectPath, 'projectPath'); + + if (Array.isArray(config.projectPathHistory)) { + for (let index = config.projectPathHistory.length - 1; index >= 0; index -= 1) { + push(config.projectPathHistory[index], 'projectPathHistory'); + } + } + + const leadCwd = (config.members ?? []).find((member) => isLeadMember(member))?.cwd; + push(leadCwd, 'leadCwd'); + + const distinctMemberCwds = Array.from( + new Set( + (config.members ?? []) + .map((member) => normalizeProjectPathCandidate(member.cwd)) + .filter((cwd): cwd is string => Boolean(cwd)) + ) + ); + if (distinctMemberCwds.length === 1) { + push(distinctMemberCwds[0], 'memberCwd'); + } + + return candidates; + } + + private buildProjectDirCandidates(projectPath: string): ProjectDirCandidate[] { + const normalizedProjectPath = trimTrailingSlashes(projectPath); + const projectId = extractBaseDir(encodePath(normalizedProjectPath)); + const baseCandidates = [ + { projectDir: path.join(getProjectsBasePath(), projectId), projectId }, + ...(projectId.includes('_') + ? [ + { + projectDir: path.join(getProjectsBasePath(), projectId.replace(/_/g, '-')), + projectId: projectId.replace(/_/g, '-'), + }, + ] + : []), + ]; + + const seen = new Set(); + return baseCandidates + .filter((candidate) => { + if (seen.has(candidate.projectDir)) { + return false; + } + seen.add(candidate.projectDir); + return true; + }) + .map((candidate) => ({ + projectPath: normalizedProjectPath, + projectDir: candidate.projectDir, + projectId: candidate.projectId, + source: 'projectPath' as const, + })); + } + + private async findMatchInProjectPathCandidate( + candidate: ProjectPathCandidate, + sessionIds: string[] + ): Promise { + const rankBySessionId = new Map(sessionIds.map((sessionId, index) => [sessionId, index])); + let bestMatch: SessionProjectMatch | null = null; + + for (const projectCandidate of this.buildProjectDirCandidates(candidate.projectPath)) { + const matchedSessionId = await this.findMatchingSessionId( + projectCandidate.projectDir, + sessionIds + ); + if (!matchedSessionId) { + continue; + } + const match = { + ...projectCandidate, + source: candidate.source, + matchedSessionId, + }; + const matchRank = rankBySessionId.get(match.matchedSessionId) ?? Number.POSITIVE_INFINITY; + const bestRank = bestMatch + ? (rankBySessionId.get(bestMatch.matchedSessionId) ?? Number.POSITIVE_INFINITY) + : Number.POSITIVE_INFINITY; + if (!bestMatch || matchRank < bestRank) { + bestMatch = match; + } + if (matchRank === 0) { + break; + } + } + return bestMatch; + } + + private async findMatchesInProjectPathCandidates( + candidates: ProjectPathCandidate[], + sessionIds: string[] + ): Promise { + const matches: SessionProjectMatch[] = []; + const seenProjectDirs = new Set(); + for (const candidate of candidates) { + const match = await this.findMatchInProjectPathCandidate(candidate, sessionIds); + if (!match || seenProjectDirs.has(match.projectDir)) { + continue; + } + seenProjectDirs.add(match.projectDir); + matches.push(match); + } + return matches; + } + + private async findMatchingSessionId( + projectDir: string, + sessionIds: string[] + ): Promise { + for (const sessionId of sessionIds) { + try { + const stat = await fs.stat(path.join(projectDir, `${sessionId}.jsonl`)); + if (stat.isFile()) { + return sessionId; + } + } catch { + // continue + } + } + return null; + } + + private async findMatchesByScanningProjects( + sessionIds: string[] + ): Promise { + let projectEntries: Dirent[]; + try { + projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true }); + } catch { + return []; + } + + const directories = projectEntries.filter((entry) => entry.isDirectory()); + const matches: ScannedSessionProjectMatch[] = []; + let nextIndex = 0; + + const worker = async (): Promise => { + while (nextIndex < directories.length) { + const index = nextIndex++; + const entry = directories[index]; + const projectDir = path.join(getProjectsBasePath(), entry.name); + const matchedSessionId = await this.findMatchingSessionId(projectDir, sessionIds); + if (!matchedSessionId) { + continue; + } + const jsonlPath = path.join(projectDir, `${matchedSessionId}.jsonl`); + const cwd = await extractCwd(jsonlPath); + matches.push({ + projectPath: cwd ?? undefined, + projectDir, + projectId: entry.name, + source: 'projectsScan', + matchedSessionId, + }); + } + }; + + await Promise.all( + Array.from({ length: Math.min(ROOT_DISCOVERY_CONCURRENCY, directories.length) }, () => + worker() + ) + ); + + const deduped = new Map(); + for (const match of matches) { + if (!deduped.has(match.projectDir)) { + deduped.set(match.projectDir, match); + } + } + return [...deduped.values()]; + } + + private async persistResolvedProjectPath( + teamName: string, + config: TeamConfig, + nextProjectPath: string + ): Promise { + const normalizedNextPath = normalizeProjectPathCandidate(nextProjectPath); + if (!normalizedNextPath) { + return; + } + + const currentProjectPath = normalizeProjectPathCandidate(config.projectPath); + if (currentProjectPath === normalizedNextPath) { + return; + } + + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + try { + const raw = await fs.readFile(configPath, 'utf8'); + const parsed = JSON.parse(raw) as Record; + const rawProjectPath = + normalizeProjectPathCandidate(parsed.projectPath) ?? currentProjectPath ?? null; + + parsed.projectPath = normalizedNextPath; + + parsed.projectPathHistory = this.buildRepairedProjectPathHistory( + rawProjectPath, + parsed.projectPathHistory, + normalizedNextPath + ); + await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2)); + logger.info( + `[${teamName}] Repaired transcript projectPath via exact session match: ${normalizedNextPath}` + ); + } catch (error) { + logger.warn( + `[${teamName}] Failed to persist repaired transcript projectPath: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } } private async discoverSessionIds( @@ -199,42 +628,95 @@ export class TeamTranscriptProjectResolver { this.listSessionDirIds(projectDir), ]); - return Array.from(new Set([...knownSessionIds, ...teamRootSessionIds, ...sessionDirIds])).sort( - (left, right) => left.localeCompare(right) - ); + const orderedSessionIds: string[] = []; + const seen = new Set(); + const push = (sessionId: string): void => { + if (seen.has(sessionId)) { + return; + } + seen.add(sessionId); + orderedSessionIds.push(sessionId); + }; + + for (const sessionId of knownSessionIds) { + push(sessionId); + } + for (const sessionId of [...teamRootSessionIds, ...sessionDirIds].sort((left, right) => + left.localeCompare(right) + )) { + push(sessionId); + } + + return orderedSessionIds; + } + + private buildRepairedProjectPathHistory( + currentProjectPath: unknown, + rawProjectPathHistory: unknown, + nextProjectPath: string + ): string[] { + const normalizedNextPath = normalizeProjectPathCandidate(nextProjectPath); + const history: string[] = []; + const seen = new Set(); + const pushHistory = (value: unknown): void => { + const normalized = normalizeProjectPathCandidate(value); + if (!normalized || normalized === normalizedNextPath || seen.has(normalized)) { + return; + } + seen.add(normalized); + history.push(normalized); + }; + + if (Array.isArray(rawProjectPathHistory)) { + for (const value of rawProjectPathHistory) { + pushHistory(value); + } + } + pushHistory(currentProjectPath); + + return history.slice(-500); + } + + private async projectDirExists(projectDir: string): Promise { + try { + const stat = await fs.stat(projectDir); + return stat.isDirectory(); + } catch { + return false; + } + } + + private async readProjectDirEntries(projectDir: string): Promise { + try { + return await fs.readdir(projectDir, { withFileTypes: true }); + } catch { + logger.debug(`Cannot read transcript project dir: ${projectDir}`); + return null; + } } private async listSessionDirIds(projectDir: string): Promise { - try { - const dirEntries = await fs.readdir(projectDir, { withFileTypes: true }); - return dirEntries - .filter((entry) => entry.isDirectory() && isSessionDirectoryName(entry.name)) - .map((entry) => entry.name); - } catch { - logger.debug(`Cannot read transcript project dir: ${projectDir}`); + const dirEntries = await this.readProjectDirEntries(projectDir); + if (!dirEntries) { return []; } + + return dirEntries + .filter((entry) => entry.isDirectory() && isSessionDirectoryName(entry.name)) + .map((entry) => entry.name); } - private async listTeamRootSessionIds(projectDir: string, teamName: string): Promise { - let dirEntries: Dirent[]; - try { - dirEntries = await fs.readdir(projectDir, { withFileTypes: true }); - } catch { - logger.debug(`Cannot read transcript project dir: ${projectDir}`); - return []; - } - - const rootJsonlEntries = dirEntries.filter( - (entry) => entry.isFile() && entry.name.endsWith('.jsonl') - ); + private async collectRootJsonlSessionIds( + rootJsonlEntries: Dirent[], + projectDir: string, + teamName: string + ): Promise { const discovered = new Set(); let nextIndex = 0; - const worker = async (): Promise => { + const scanNextRootEntry = async (): Promise => { while (nextIndex < rootJsonlEntries.length) { - const index = nextIndex++; - const entry = rootJsonlEntries[index]; + const entry = rootJsonlEntries[nextIndex++]; const filePath = path.join(projectDir, entry.name); if (!(await this.fileBelongsToTeam(filePath, teamName))) { continue; @@ -245,13 +727,25 @@ export class TeamTranscriptProjectResolver { await Promise.all( Array.from({ length: Math.min(ROOT_DISCOVERY_CONCURRENCY, rootJsonlEntries.length) }, () => - worker() + scanNextRootEntry() ) ); return [...discovered]; } + private async listTeamRootSessionIds(projectDir: string, teamName: string): Promise { + const dirEntries = await this.readProjectDirEntries(projectDir); + if (!dirEntries) { + return []; + } + + const rootJsonlEntries = dirEntries.filter( + (entry) => entry.isFile() && entry.name.endsWith('.jsonl') + ); + return this.collectRootJsonlSessionIds(rootJsonlEntries, projectDir, teamName); + } + private async fileBelongsToTeam(filePath: string, teamName: string): Promise { const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); @@ -272,6 +766,9 @@ export class TeamTranscriptProjectResolver { if (directTeamName === normalizedTeam) { return true; } + if (entryContainsNestedTeamName(entry, normalizedTeam)) { + return true; + } const textContent = extractTextContent(entry); if (textContent && lineMentionsTeam(textContent, normalizedTeam)) { diff --git a/src/main/services/team/cliFlavor.ts b/src/main/services/team/cliFlavor.ts index 787bcec1..5936f527 100644 --- a/src/main/services/team/cliFlavor.ts +++ b/src/main/services/team/cliFlavor.ts @@ -41,3 +41,17 @@ export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions { }; } } + +export function getCliFlavorCommandLabel(flavor: CliFlavor): string { + switch (flavor) { + case 'agent_teams_orchestrator': + return 'orchestrator-cli'; + case 'claude': + default: + return 'claude'; + } +} + +export function getConfiguredCliCommandLabel(): string { + return getCliFlavorCommandLabel(getConfiguredCliFlavor()); +} diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index 3223ab4a..f6290a2f 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -1,3 +1,9 @@ +export { + AutoResumeService, + clearAutoResumeService, + getAutoResumeService, + initializeAutoResumeService, +} from './AutoResumeService'; export { BranchStatusService } from './BranchStatusService'; export { CascadeGuard } from './CascadeGuard'; export { ChangeExtractorService } from './ChangeExtractorService'; diff --git a/src/main/services/team/mergeLiveLeadProcessMessages.ts b/src/main/services/team/mergeLiveLeadProcessMessages.ts new file mode 100644 index 00000000..5b613caf --- /dev/null +++ b/src/main/services/team/mergeLiveLeadProcessMessages.ts @@ -0,0 +1,73 @@ +import type { InboxMessage } from '@shared/types'; + +export function getLiveLeadProcessMessageKey(message: { + messageId?: string; + timestamp: string; + from: string; + text: string; +}): string { + if (typeof message.messageId === 'string' && message.messageId.trim().length > 0) { + return message.messageId; + } + return `${message.timestamp}\0${message.from}\0${(message.text ?? '').slice(0, 80)}`; +} + +export function mergeLiveLeadProcessMessages( + durableMessages: InboxMessage[], + liveMessages: InboxMessage[] +): InboxMessage[] { + if (liveMessages.length === 0) { + return durableMessages; + } + + const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); + const isLeadThoughtLike = (msg: { source?: unknown; to?: string }): boolean => + !msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session'); + const getLeadThoughtFingerprint = (msg: { + from: string; + text: string; + leadSessionId?: string; + }): string => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text)}`; + + const existingTextFingerprints = new Set(); + for (const msg of durableMessages) { + if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue; + if (!isLeadThoughtLike(msg)) continue; + existingTextFingerprints.add(getLeadThoughtFingerprint(msg)); + } + + const leadProcessTextFingerprints = new Set(); + const contentSeen = new Map(); + const merged: InboxMessage[] = []; + const seen = new Set(); + + for (const msg of [...durableMessages, ...liveMessages]) { + if (msg.source === 'lead_process' && !msg.to) { + const fp = getLeadThoughtFingerprint(msg); + if (existingTextFingerprints.has(fp) || leadProcessTextFingerprints.has(fp)) { + continue; + } + leadProcessTextFingerprints.add(fp); + } + + if (typeof msg.to === 'string' && msg.to.trim().length > 0) { + const contentFp = `${msg.from}\0${msg.to}\0${(msg.text ?? '').replace(/\s+/g, ' ').slice(0, 100)}`; + const msgMs = Date.parse(msg.timestamp); + const existingMs = contentSeen.get(contentFp); + if (existingMs !== undefined && Math.abs(msgMs - existingMs) <= 5000) { + continue; + } + contentSeen.set(contentFp, msgMs); + } + + const key = getLiveLeadProcessMessageKey(msg); + if (seen.has(key)) { + continue; + } + seen.add(key); + merged.push(msg); + } + + merged.sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp)); + return merged; +} diff --git a/src/main/services/team/progressPayload.ts b/src/main/services/team/progressPayload.ts new file mode 100644 index 00000000..c2f4fce7 --- /dev/null +++ b/src/main/services/team/progressPayload.ts @@ -0,0 +1,52 @@ +/** + * Helpers that shape provisioning progress payloads before they are emitted + * to the renderer over IPC. + * + * Rationale: the renderer only renders a small "tail" preview of CLI logs + * and assistant output in ProvisioningProgressBlock / CliLogsRichView. Sending + * the full accumulated history on every throttled progress tick (≈ every + * second under load) serialized a multi-megabyte string over IPC and forced + * Zustand to produce a new immutable state object — which triggered renderer + * V8 OOM crashes for users with long-running teams. These helpers keep the + * hot emission path bounded while leaving the full history in-process for + * diagnostics and completion-time reports. + */ + +export const PROGRESS_LOG_TAIL_LINES = 200; +export const PROGRESS_OUTPUT_TAIL_PARTS = 20; + +/** + * Return the trailing `maxLines` of a line-buffered CLI log, joined with "\n" + * and trimmed. Returns `undefined` when the tail is empty so callers can + * skip emitting a noop update. + */ +export function buildProgressLogsTail( + lines: readonly string[], + maxLines: number = PROGRESS_LOG_TAIL_LINES +): string | undefined { + if (lines.length === 0) { + return undefined; + } + const effectiveMax = Math.max(1, maxLines); + const tail = lines.length > effectiveMax ? lines.slice(-effectiveMax) : lines; + const joined = tail.join('\n').trim(); + return joined.length === 0 ? undefined : joined; +} + +/** + * Return the trailing `maxParts` of assistant output parts joined with a + * blank line, matching the renderer's rendering contract. Returns `undefined` + * when no parts are available. + */ +export function buildProgressAssistantOutput( + parts: readonly string[], + maxParts: number = PROGRESS_OUTPUT_TAIL_PARTS +): string | undefined { + if (parts.length === 0) { + return undefined; + } + const effectiveMax = Math.max(1, maxParts); + const tail = parts.length > effectiveMax ? parts.slice(-effectiveMax) : parts; + const joined = tail.join('\n\n'); + return joined.trim().length === 0 ? undefined : joined; +} diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts index 2a47c4fb..3e0a3cec 100644 --- a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -1,6 +1,9 @@ import { extractToolCalls, extractToolResults } from '@main/utils/toolExtraction'; +import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection'; +import { TeamTaskReader } from '../../TeamTaskReader'; import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource'; +import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator'; import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder'; import { BoardTaskExactLogDetailSelector } from '../exact/BoardTaskExactLogDetailSelector'; import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser'; @@ -16,12 +19,15 @@ import type { BoardTaskLogParticipant, BoardTaskLogSegment, BoardTaskLogStreamResponse, + BoardTaskLogStreamSummary, + TeamTask, } from '@shared/types'; interface StreamSlice { id: string; timestamp: string; filePath: string; + sortOrder?: number; participantKey: string; actor: BoardTaskLogActor; actionCategory?: BoardTaskActivityCategory; @@ -37,6 +43,22 @@ interface MergedMessageAccumulator { toolUseResults: ToolUseResultData[]; } +interface TimeWindow { + startMs: number; + endMs: number | null; +} + +interface StreamLayout { + participants: BoardTaskLogParticipant[]; + visibleSlices: StreamSlice[]; +} + +const BOARD_MCP_TOOL_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const; +const INFERRED_WINDOW_GRACE_BEFORE_MS = 30_000; +const INFERRED_WINDOW_GRACE_AFTER_MS = 15_000; +const INFERRED_RECORD_RANGE_BEFORE_MS = 5 * 60_000; +const INFERRED_RECORD_RANGE_AFTER_MS = 60_000; + function emptyResponse(): BoardTaskLogStreamResponse { return { participants: [], @@ -45,10 +67,22 @@ function emptyResponse(): BoardTaskLogStreamResponse { }; } +function emptySummary(): BoardTaskLogStreamSummary { + return { + segmentCount: 0, + }; +} + function normalizeMemberName(value: string): string { return value.trim().toLowerCase(); } +function isBoardMcpToolName(toolName: string | undefined): boolean { + if (!toolName) return false; + const normalized = toolName.trim().toLowerCase(); + return BOARD_MCP_TOOL_PREFIXES.some((prefix) => normalized.startsWith(prefix)); +} + function toStreamActor(detail: BoardTaskExactLogDetailCandidate['actor']): BoardTaskLogActor { return { ...(detail.memberName ? { memberName: detail.memberName } : {}), @@ -139,14 +173,47 @@ function extractBoardToolOutputText( return null; } + const normalizedToolName = toolName.trim().toLowerCase(); const payload = parsedPayload as Record; - if (toolName === 'task_add_comment' || toolName === 'task_get_comment') { + if (normalizedToolName === 'task_add_comment' || normalizedToolName === 'task_get_comment') { const comment = payload.comment as Record | undefined; if (typeof comment?.text === 'string' && comment.text.trim().length > 0) { return comment.text; } } + if (normalizedToolName === 'sendmessage') { + const routing = payload.routing as Record | undefined; + const deliveryMessage = + typeof payload.message === 'string' && payload.message.trim().length > 0 + ? payload.message.trim() + : null; + const summary = + typeof routing?.summary === 'string' && routing.summary.trim().length > 0 + ? routing.summary.trim() + : null; + const target = + typeof routing?.target === 'string' && routing.target.trim().length > 0 + ? routing.target.trim() + : null; + + if (deliveryMessage && summary) { + return `${deliveryMessage} - ${summary}`; + } + if (summary && target) { + return `Message sent to ${target} - ${summary}`; + } + if (summary) { + return summary; + } + if (deliveryMessage) { + return deliveryMessage; + } + if (target) { + return `Message sent to ${target}`; + } + } + return null; } @@ -267,12 +334,67 @@ function sanitizeToolResultContent( }; } +function sanitizeToolResultPayloadValue( + value: string | unknown[], + canonicalToolName?: string +): string | unknown[] { + if (typeof value === 'string') { + const parsedPayload = parseJsonLikeString(value); + const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); + if (typeof extractedText === 'string') { + return extractedText; + } + return parsedPayload ? '' : value; + } + + const jsonText = collectTextBlockText(value); + const parsedPayload = parseJsonLikeString(jsonText); + const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); + if (typeof extractedText === 'string') { + return extractedText; + } + + const sanitizedChildren = value + .map((child) => { + if ( + typeof child === 'object' && + child !== null && + 'type' in child && + child.type === 'text' && + 'text' in child && + typeof child.text === 'string' + ) { + return looksLikeJsonPayload(child.text) ? null : { ...child }; + } + return child; + }) + .filter((child) => child !== null); + + if (parsedPayload && sanitizedChildren.length === value.length) { + return ''; + } + + return sanitizedChildren.length > 0 ? sanitizedChildren : ''; +} + function sanitizeJsonLikeToolResultPayloads( messages: ParsedMessage[], canonicalToolName?: string ): ParsedMessage[] { return messages.map((message) => { let nextMessage = message; + let toolResultsChanged = false; + const nextToolResults = message.toolResults.map((toolResult) => { + const nextContent = sanitizeToolResultPayloadValue(toolResult.content, canonicalToolName); + if (JSON.stringify(nextContent) !== JSON.stringify(toolResult.content)) { + toolResultsChanged = true; + return { + ...toolResult, + content: nextContent, + }; + } + return toolResult; + }); const rawToolUseResult = message.toolUseResult as unknown; if ( @@ -366,12 +488,20 @@ function sanitizeJsonLikeToolResultPayloads( }); if (!changed) { - return nextMessage; + if (!toolResultsChanged) { + return nextMessage; + } + + return { + ...nextMessage, + toolResults: nextToolResults, + }; } return { ...nextMessage, content: nextContent, + toolResults: toolResultsChanged ? nextToolResults : nextMessage.toolResults, }; }); } @@ -691,23 +821,382 @@ function buildSegmentId(participantKey: string, slices: StreamSlice[]): string { return `${participantKey}:${first?.id ?? 'start'}:${last?.id ?? 'end'}`; } +function buildToolNameByUseId( + parsedMessagesByFile: Map +): Map { + const toolNameByUseId = new Map(); + + for (const messages of parsedMessagesByFile.values()) { + for (const message of messages) { + for (const toolCall of message.toolCalls) { + toolNameByUseId.set(toolCall.id, toolCall.name); + } + } + } + + return toolNameByUseId; +} + +function buildTaskTimeWindows(task: TeamTask, recordTimestamps: number[]): TimeWindow[] { + const windowsFromIntervals = (Array.isArray(task.workIntervals) ? task.workIntervals : []) + .map((interval) => { + const startedAt = Date.parse(interval.startedAt); + if (!Number.isFinite(startedAt)) { + return null; + } + const completedAt = + typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN; + return { + startMs: startedAt - INFERRED_WINDOW_GRACE_BEFORE_MS, + endMs: Number.isFinite(completedAt) ? completedAt + INFERRED_WINDOW_GRACE_AFTER_MS : null, + }; + }) + .filter((window): window is TimeWindow => window !== null); + + if (windowsFromIntervals.length > 0) { + return windowsFromIntervals; + } + + const createdAtMs = typeof task.createdAt === 'string' ? Date.parse(task.createdAt) : Number.NaN; + const updatedAtMs = typeof task.updatedAt === 'string' ? Date.parse(task.updatedAt) : Number.NaN; + if (Number.isFinite(createdAtMs) || Number.isFinite(updatedAtMs)) { + const startMs = Number.isFinite(createdAtMs) ? createdAtMs : updatedAtMs; + return [ + { + startMs: startMs - INFERRED_WINDOW_GRACE_BEFORE_MS, + endMs: Number.isFinite(updatedAtMs) ? updatedAtMs + INFERRED_WINDOW_GRACE_AFTER_MS : null, + }, + ]; + } + + const finiteRecordTimestamps = recordTimestamps.filter((timestamp) => Number.isFinite(timestamp)); + if (finiteRecordTimestamps.length === 0) { + return []; + } + + return [ + { + startMs: Math.min(...finiteRecordTimestamps) - INFERRED_RECORD_RANGE_BEFORE_MS, + endMs: Math.max(...finiteRecordTimestamps) + INFERRED_RECORD_RANGE_AFTER_MS, + }, + ]; +} + +function isWithinTimeWindows(timestamp: Date, windows: TimeWindow[]): boolean { + const messageTime = timestamp.getTime(); + if (!Number.isFinite(messageTime)) { + return false; + } + if (windows.length === 0) { + return true; + } + + const now = Date.now(); + return windows.some((window) => { + const endMs = window.endMs ?? now; + return messageTime >= window.startMs && messageTime <= endMs; + }); +} + +function collectExplicitMessageIds(records: { source: { messageUuid: string } }[]): Set { + return new Set(records.map((record) => record.source.messageUuid)); +} + +function collectExplicitToolUseIds( + records: { + source: { toolUseId?: string }; + action?: { toolUseId?: string }; + }[] +): Set { + const toolUseIds = new Set(); + + for (const record of records) { + const sourceToolUseId = record.source.toolUseId?.trim(); + if (sourceToolUseId) { + toolUseIds.add(sourceToolUseId); + } + + const actionToolUseId = record.action?.toolUseId?.trim(); + if (actionToolUseId) { + toolUseIds.add(actionToolUseId); + } + } + + return toolUseIds; +} + +function collectAllowedMemberNames( + task: TeamTask, + records: { actor: { memberName?: string } }[] +): Set { + const allowedNames = new Set(); + + if (typeof task.owner === 'string' && task.owner.trim().length > 0) { + allowedNames.add(normalizeMemberName(task.owner)); + } + + for (const record of records) { + if (typeof record.actor.memberName === 'string' && record.actor.memberName.trim().length > 0) { + allowedNames.add(normalizeMemberName(record.actor.memberName)); + } + } + + return allowedNames; +} + +function extractMessageToolUseIds(message: ParsedMessage): Set { + const toolUseIds = new Set(); + + for (const toolCall of message.toolCalls) { + if (typeof toolCall.id === 'string' && toolCall.id.trim().length > 0) { + toolUseIds.add(toolCall.id.trim()); + } + } + + for (const toolResult of message.toolResults) { + if (typeof toolResult.toolUseId === 'string' && toolResult.toolUseId.trim().length > 0) { + toolUseIds.add(toolResult.toolUseId.trim()); + } + } + + if (typeof message.sourceToolUseID === 'string' && message.sourceToolUseID.trim().length > 0) { + toolUseIds.add(message.sourceToolUseID.trim()); + } + + return toolUseIds; +} + +function messageHasNonBoardToolActivity( + message: ParsedMessage, + toolNameByUseId: Map +): boolean { + for (const toolCall of message.toolCalls) { + if (!isBoardMcpToolName(toolCall.name)) { + return true; + } + } + + for (const toolResult of message.toolResults) { + if (!isBoardMcpToolName(toolNameByUseId.get(toolResult.toolUseId))) { + return true; + } + } + + if (message.sourceToolUseID) { + const sourceToolName = toolNameByUseId.get(message.sourceToolUseID); + if (sourceToolName && !isBoardMcpToolName(sourceToolName)) { + return true; + } + } + + return false; +} + +function buildInferredActor(message: ParsedMessage, leadName: string): BoardTaskLogActor | null { + const sessionId = message.sessionId?.trim(); + if (!sessionId) { + return null; + } + + const memberName = + typeof message.agentName === 'string' && message.agentName.trim().length > 0 + ? message.agentName.trim() + : undefined; + + const isLead = + memberName != null && normalizeMemberName(memberName) === normalizeMemberName(leadName); + + return { + ...(memberName ? { memberName } : {}), + role: isLead ? 'lead' : memberName ? 'member' : message.isSidechain ? 'member' : 'unknown', + sessionId, + ...(message.agentId ? { agentId: message.agentId } : {}), + isSidechain: message.isSidechain, + }; +} + +function compareSlices(left: StreamSlice, right: StreamSlice): number { + const leftTs = Date.parse(left.timestamp); + const rightTs = Date.parse(right.timestamp); + if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) { + return leftTs - rightTs; + } + if (left.filePath !== right.filePath) { + return left.filePath.localeCompare(right.filePath); + } + if ((left.sortOrder ?? 0) !== (right.sortOrder ?? 0)) { + return (left.sortOrder ?? 0) - (right.sortOrder ?? 0); + } + return left.id.localeCompare(right.id); +} + +function buildOrderedParticipants(visibleSlices: StreamSlice[]): BoardTaskLogParticipant[] { + const participantsByKey = new Map(); + const participantOrder: string[] = []; + + for (const slice of visibleSlices) { + if (participantsByKey.has(slice.participantKey)) { + continue; + } + participantsByKey.set( + slice.participantKey, + buildParticipant(slice.actor, slice.participantKey) + ); + participantOrder.push(slice.participantKey); + } + + return participantOrder + .map((key) => participantsByKey.get(key)) + .filter((participant): participant is BoardTaskLogParticipant => Boolean(participant)) + .sort((left, right) => { + if (left.isLead && !right.isLead) return 1; + if (!left.isLead && right.isLead) return -1; + return participantOrder.indexOf(left.key) - participantOrder.indexOf(right.key); + }); +} + +function countSegmentsFromSlices(visibleSlices: StreamSlice[]): number { + if (visibleSlices.length === 0) { + return 0; + } + + let segmentCount = 1; + for (let index = 1; index < visibleSlices.length; index += 1) { + if (visibleSlices[index]?.participantKey !== visibleSlices[index - 1]?.participantKey) { + segmentCount += 1; + } + } + + return segmentCount; +} + export class BoardTaskLogStreamService { constructor( private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), private readonly summarySelector: BoardTaskExactLogSummarySelector = new BoardTaskExactLogSummarySelector(), private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(), private readonly detailSelector: BoardTaskExactLogDetailSelector = new BoardTaskExactLogDetailSelector(), - private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder() + private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(), + private readonly taskReader: TeamTaskReader = new TeamTaskReader(), + private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator() ) {} - async getTaskLogStream(teamName: string, taskId: string): Promise { + private async buildInferredExecutionSlices( + teamName: string, + taskId: string, + records: Awaited>, + parsedMessagesByFile: Map + ): Promise { + if (records.some((record) => record.linkKind === 'execution')) { + return []; + } + + const [activeTasks, deletedTasks, transcriptContext] = await Promise.all([ + this.taskReader.getTasks(teamName), + this.taskReader.getDeletedTasks(teamName), + this.transcriptSourceLocator.getContext(teamName), + ]); + + const task = [...activeTasks, ...deletedTasks].find((candidate) => candidate.id === taskId); + if (!task) { + return []; + } + + const transcriptFiles = transcriptContext?.transcriptFiles ?? []; + const missingFiles = transcriptFiles.filter((filePath) => !parsedMessagesByFile.has(filePath)); + let mergedParsedMessagesByFile = parsedMessagesByFile; + if (missingFiles.length > 0) { + const additionalParsedMessages = await this.strictParser.parseFiles(missingFiles); + mergedParsedMessagesByFile = new Map([ + ...parsedMessagesByFile.entries(), + ...additionalParsedMessages.entries(), + ]); + } + + const toolNameByUseId = buildToolNameByUseId(mergedParsedMessagesByFile); + const recordTimestamps = records.map((record) => Date.parse(record.timestamp)); + const taskTimeWindows = buildTaskTimeWindows(task, recordTimestamps); + if (taskTimeWindows.length === 0) { + return []; + } + + const explicitMessageIds = collectExplicitMessageIds(records); + const explicitToolUseIds = collectExplicitToolUseIds(records); + const allowedMemberNames = collectAllowedMemberNames(task, records); + const leadName = + transcriptContext?.config.members + ?.find((member) => isLeadMemberCheck(member)) + ?.name?.trim() || 'team-lead'; + + const inferredSlices: StreamSlice[] = []; + for (const [filePath, messages] of mergedParsedMessagesByFile.entries()) { + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + if (explicitMessageIds.has(message.uuid)) { + continue; + } + if (!isWithinTimeWindows(message.timestamp, taskTimeWindows)) { + continue; + } + + const actor = buildInferredActor(message, leadName); + if (!actor || !actor.memberName) { + continue; + } + + if ( + allowedMemberNames.size > 0 && + !allowedMemberNames.has(normalizeMemberName(actor.memberName)) + ) { + continue; + } + + const messageToolUseIds = extractMessageToolUseIds(message); + if ([...messageToolUseIds].some((toolUseId) => explicitToolUseIds.has(toolUseId))) { + continue; + } + if (!messageHasNonBoardToolActivity(message, toolNameByUseId)) { + continue; + } + + const inferredToolName = [...messageToolUseIds] + .map((toolUseId) => toolNameByUseId.get(toolUseId)) + .find((toolName): toolName is string => typeof toolName === 'string'); + const sanitizedMessages = sanitizeJsonLikeToolResultPayloads([message], inferredToolName); + const prunedMessages = pruneEmptyInternalToolResultMessages(sanitizedMessages); + if (prunedMessages.length === 0) { + continue; + } + + inferredSlices.push({ + id: `inferred:${filePath}:${message.uuid}`, + timestamp: message.timestamp.toISOString(), + filePath, + sortOrder: index, + participantKey: buildParticipantKey(actor), + actor, + filteredMessages: prunedMessages, + }); + } + } + + return inferredSlices.sort(compareSlices); + } + + private async buildStreamLayout(teamName: string, taskId: string): Promise { if (!isBoardTaskExactLogsReadEnabled()) { - return emptyResponse(); + return { + participants: [], + visibleSlices: [], + }; } const records = await this.recordSource.getTaskRecords(teamName, taskId); if (records.length === 0) { - return emptyResponse(); + return { + participants: [], + visibleSlices: [], + }; } const fileVersionsByPath = await getBoardTaskExactLogFileVersions( @@ -723,7 +1212,10 @@ export class BoardTaskLogStreamService { .sort(compareCandidates); if (candidates.length === 0) { - return emptyResponse(); + return { + participants: [], + visibleSlices: [], + }; } const parsedMessagesByFile = await this.strictParser.parseFiles( @@ -762,6 +1254,7 @@ export class BoardTaskLogStreamService { id: detail.id, timestamp: detail.timestamp, filePath: detail.source.filePath, + sortOrder: detail.source.sourceOrder, participantKey: buildParticipantKey(actor), actor, actionCategory: candidate.actionCategory, @@ -770,10 +1263,20 @@ export class BoardTaskLogStreamService { } if (slices.length === 0) { - return emptyResponse(); + return { + participants: [], + visibleSlices: [], + }; } - const deNoisedSlices = filterReadOnlySlices(slices); + const inferredExecutionSlices = await this.buildInferredExecutionSlices( + teamName, + taskId, + records, + parsedMessagesByFile + ); + const combinedSlices = [...slices, ...inferredExecutionSlices].sort(compareSlices); + const deNoisedSlices = filterReadOnlySlices(combinedSlices); const namedParticipantSlices = deNoisedSlices.filter((slice) => hasNamedParticipant(slice.actor) @@ -781,27 +1284,31 @@ export class BoardTaskLogStreamService { const visibleSlices = namedParticipantSlices.length > 0 ? namedParticipantSlices : deNoisedSlices; - const participantsByKey = new Map(); - const participantOrder: string[] = []; - for (const slice of visibleSlices) { - if (participantsByKey.has(slice.participantKey)) { - continue; - } - participantsByKey.set( - slice.participantKey, - buildParticipant(slice.actor, slice.participantKey) - ); - participantOrder.push(slice.participantKey); + return { + participants: buildOrderedParticipants(visibleSlices), + visibleSlices, + }; + } + + async getTaskLogStreamSummary( + teamName: string, + taskId: string + ): Promise { + const layout = await this.buildStreamLayout(teamName, taskId); + if (layout.visibleSlices.length === 0) { + return emptySummary(); } - const orderedParticipants = participantOrder - .map((key) => participantsByKey.get(key)) - .filter((participant): participant is BoardTaskLogParticipant => Boolean(participant)) - .sort((left, right) => { - if (left.isLead && !right.isLead) return 1; - if (!left.isLead && right.isLead) return -1; - return participantOrder.indexOf(left.key) - participantOrder.indexOf(right.key); - }); + return { + segmentCount: countSegmentsFromSlices(layout.visibleSlices), + }; + } + + async getTaskLogStream(teamName: string, taskId: string): Promise { + const layout = await this.buildStreamLayout(teamName, taskId); + if (layout.visibleSlices.length === 0) { + return emptyResponse(); + } const segments: BoardTaskLogSegment[] = []; let currentSegmentSlices: StreamSlice[] = []; @@ -835,7 +1342,7 @@ export class BoardTaskLogStreamService { currentSegmentSlices = []; }; - for (const slice of visibleSlices) { + for (const slice of layout.visibleSlices) { if ( currentSegmentSlices.length > 0 && currentSegmentSlices[0].participantKey !== slice.participantKey @@ -846,11 +1353,11 @@ export class BoardTaskLogStreamService { } flushSegment(); - const namedParticipants = orderedParticipants.filter((participant) => !participant.isLead); + const namedParticipants = layout.participants.filter((participant) => !participant.isLead); const defaultFilter = namedParticipants.length === 1 ? namedParticipants[0].key : 'all'; return { - participants: orderedParticipants, + participants: layout.participants, defaultFilter, segments, }; diff --git a/src/main/services/team/teamDataWorkerTypes.ts b/src/main/services/team/teamDataWorkerTypes.ts index 329e369f..ca798cc3 100644 --- a/src/main/services/team/teamDataWorkerTypes.ts +++ b/src/main/services/team/teamDataWorkerTypes.ts @@ -2,7 +2,12 @@ * Shared request/response types for the team-data-worker thread. */ -import type { MemberLogSummary, TeamData } from '@shared/types'; +import type { + MemberLogSummary, + MessagesPage, + TeamMemberActivityMeta, + TeamViewSnapshot, +} from '@shared/types'; // ── Payloads ── @@ -10,6 +15,18 @@ export interface GetTeamDataPayload { teamName: string; } +export interface GetMessagesPagePayload { + teamName: string; + options: { + cursor?: string | null; + limit: number; + }; +} + +export interface GetMemberActivityMetaPayload { + teamName: string; +} + export interface FindLogsForTaskPayload { teamName: string; taskId: string; @@ -25,8 +42,14 @@ export interface FindLogsForTaskPayload { export type TeamDataWorkerRequest = | { id: string; op: 'getTeamData'; payload: GetTeamDataPayload } + | { id: string; op: 'getMessagesPage'; payload: GetMessagesPagePayload } + | { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload } | { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload }; export type TeamDataWorkerResponse = - | { id: string; ok: true; result: TeamData | MemberLogSummary[] } + | { + id: string; + ok: true; + result: TeamViewSnapshot | MessagesPage | TeamMemberActivityMeta | MemberLogSummary[]; + } | { id: string; ok: false; error: string }; diff --git a/src/main/standalone.ts b/src/main/standalone.ts index 0a3e1083..d1186be3 100644 --- a/src/main/standalone.ts +++ b/src/main/standalone.ts @@ -1,5 +1,5 @@ /** - * Standalone (non-Electron) entry point for Claude Agent Teams UI. + * Standalone (non-Electron) entry point for Agent Teams UI. * * Runs the HTTP server + API without Electron, suitable for Docker * or any headless/remote environment. The renderer is served as diff --git a/src/main/types/chunks.ts b/src/main/types/chunks.ts index d24d3913..fcd9e54c 100644 --- a/src/main/types/chunks.ts +++ b/src/main/types/chunks.ts @@ -1,5 +1,5 @@ /** - * Chunk and visualization types for Claude Agent Teams UI. + * Chunk and visualization types for Agent Teams UI. * * This module contains: * - Chunk types (UserChunk, AIChunk, SystemChunk, CompactChunk) diff --git a/src/main/types/domain.ts b/src/main/types/domain.ts index 85a67e06..1c2595ab 100644 --- a/src/main/types/domain.ts +++ b/src/main/types/domain.ts @@ -1,5 +1,5 @@ /** - * Domain/business entity types for Claude Agent Teams UI. + * Domain/business entity types for Agent Teams UI. * * These types represent the application's domain model: * - Projects and sessions diff --git a/src/main/types/jsonl.ts b/src/main/types/jsonl.ts index 6435a707..c745db54 100644 --- a/src/main/types/jsonl.ts +++ b/src/main/types/jsonl.ts @@ -82,6 +82,21 @@ export interface UsageMetadata { output_tokens: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; + input_tokens_details?: { + cached_tokens?: number; + }; + output_tokens_details?: { + reasoning_tokens?: number; + }; + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + prompt_tokens_details?: { + cached_tokens?: number; + }; + completion_tokens_details?: { + reasoning_tokens?: number; + }; } // ============================================================================= @@ -130,6 +145,7 @@ interface ConversationalEntry extends BaseEntry { sessionId: string; version: string; gitBranch: string; + agentName?: string; slug?: string; } diff --git a/src/main/types/messages.ts b/src/main/types/messages.ts index 79f6652e..12d87a2d 100644 --- a/src/main/types/messages.ts +++ b/src/main/types/messages.ts @@ -1,5 +1,5 @@ /** - * Parsed message types and type guards for Claude Agent Teams UI. + * Parsed message types and type guards for Agent Teams UI. * * ParsedMessage is the application's internal representation after parsing * raw JSONL entries. This module also contains type guards for classifying @@ -80,10 +80,14 @@ export interface ParsedMessage { // Metadata /** Current working directory when message was created */ cwd?: string; + /** Root/session identifier from transcript */ + sessionId?: string; /** Git branch context */ gitBranch?: string; /** Agent ID for subagent messages */ agentId?: string; + /** Human-readable agent/member name from transcript */ + agentName?: string; /** Whether this is a sidechain message */ isSidechain: boolean; /** Whether this is a meta message */ diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 3a345d5d..17b249e6 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -240,8 +240,10 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { let model: string | undefined; let requestId: string | undefined; let cwd: string | undefined; + let sessionId: string | undefined; let gitBranch: string | undefined; let agentId: string | undefined; + let agentName: string | undefined; let isSidechain = false; let isMeta = false; let userType: string | undefined; @@ -255,10 +257,12 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { if (isConversationalEntry(entry)) { // Common properties from ConversationalEntry base cwd = entry.cwd; + sessionId = entry.sessionId; gitBranch = entry.gitBranch; isSidechain = entry.isSidechain ?? false; userType = entry.userType; parentUuid = entry.parentUuid ?? null; + agentName = entry.agentName; // Type-specific properties if (entry.type === 'user') { @@ -298,8 +302,10 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { model, // Metadata cwd, + sessionId, gitBranch, agentId, + agentName, isSidechain, isMeta, userType, diff --git a/src/main/workers/team-data-worker.ts b/src/main/workers/team-data-worker.ts index 99d1a0dd..871eba11 100644 --- a/src/main/workers/team-data-worker.ts +++ b/src/main/workers/team-data-worker.ts @@ -42,6 +42,19 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => { respond({ id: msg.id, ok: true, result }); break; } + case 'getMessagesPage': { + const result = await teamDataService.getMessagesPage( + msg.payload.teamName, + msg.payload.options + ); + respond({ id: msg.id, ok: true, result }); + break; + } + case 'getMemberActivityMeta': { + const result = await teamDataService.getMemberActivityMeta(msg.payload.teamName); + respond({ id: msg.id, ok: true, result }); + break; + } case 'findLogsForTask': { const { teamName, taskId, options } = msg.payload; const intervalsKey = options?.intervals diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 7bdf2e17..95de628a 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -219,6 +219,9 @@ export const TEAM_SET_CHANGE_PRESENCE_TRACKING = 'team:setChangePresenceTracking /** Enable or disable live teammate tool activity tracking for a visible team tab */ export const TEAM_SET_TOOL_ACTIVITY_TRACKING = 'team:setToolActivityTracking'; +/** Enable or disable task log stream invalidation tracking for an open task log panel */ +export const TEAM_SET_TASK_LOG_STREAM_TRACKING = 'team:setTaskLogStreamTracking'; + /** Get buffered Claude CLI logs (paged, newest-first) */ export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs'; @@ -234,6 +237,9 @@ export const TEAM_SEND_MESSAGE = 'team:sendMessage'; /** Paginated messages for timeline/messages panel */ export const TEAM_GET_MESSAGES_PAGE = 'team:getMessagesPage'; +/** Lightweight message-derived member activity facts */ +export const TEAM_GET_MEMBER_ACTIVITY_META = 'team:getMemberActivityMeta'; + /** Request review for task */ export const TEAM_REQUEST_REVIEW = 'team:requestReview'; @@ -310,6 +316,9 @@ export const TEAM_GET_TASK_ACTIVITY_DETAIL = 'team:getTaskActivityDetail'; /** Get one task-scoped log stream derived from explicit board-task activity */ export const TEAM_GET_TASK_LOG_STREAM = 'team:getTaskLogStream'; +/** Get lightweight task log stream summary for header badges/live counters */ +export const TEAM_GET_TASK_LOG_STREAM_SUMMARY = 'team:getTaskLogStreamSummary'; + /** Get exact task-log summaries derived from explicit board-task activity records */ export const TEAM_GET_TASK_EXACT_LOG_SUMMARIES = 'team:getTaskExactLogSummaries'; @@ -370,6 +379,12 @@ export const TEAM_LEAD_CONTEXT = 'team:leadContext'; /** Get per-member spawn statuses for a team */ export const TEAM_MEMBER_SPAWN_STATUSES = 'team:memberSpawnStatuses'; +/** Get live per-agent runtime stats for a team */ +export const TEAM_GET_AGENT_RUNTIME = 'team:getAgentRuntime'; + +/** Restart a specific teammate runtime */ +export const TEAM_RESTART_MEMBER = 'team:restartMember'; + /** Soft-delete a task (set status to 'deleted' with deletedAt timestamp) */ export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 772422e1..ac737d09 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -127,6 +127,7 @@ import { TEAM_GET_DATA, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, + TEAM_GET_MEMBER_ACTIVITY_META, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_GET_MESSAGES_PAGE, @@ -139,12 +140,14 @@ import { TEAM_GET_TASK_EXACT_LOG_DETAIL, TEAM_GET_TASK_EXACT_LOG_SUMMARIES, TEAM_GET_TASK_LOG_STREAM, + TEAM_GET_TASK_LOG_STREAM_SUMMARY, TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, TEAM_LEAD_CONTEXT, TEAM_LIST, TEAM_MEMBER_SPAWN_STATUSES, + TEAM_GET_AGENT_RUNTIME, TEAM_PERMANENTLY_DELETE, TEAM_PREPARE_PROVISIONING, TEAM_PROCESS_ALIVE, @@ -156,11 +159,13 @@ import { TEAM_REMOVE_TASK_RELATIONSHIP, TEAM_REPLACE_MEMBERS, TEAM_REQUEST_REVIEW, + TEAM_RESTART_MEMBER, TEAM_RESTORE, TEAM_RESTORE_TASK, TEAM_SAVE_TASK_ATTACHMENT, TEAM_SEND_MESSAGE, TEAM_SET_CHANGE_PRESENCE_TRACKING, + TEAM_SET_TASK_LOG_STREAM_TRACKING, TEAM_SET_PROJECT_BRANCH_TRACKING, TEAM_SET_TASK_CLARIFICATION, TEAM_SET_TOOL_ACTIVITY_TRACKING, @@ -240,6 +245,7 @@ import type { BoardTaskExactLogDetailResult, BoardTaskExactLogSummariesResponse, BoardTaskLogStreamResponse, + BoardTaskLogStreamSummary, ChangeStats, ClaudeRootFolderSelection, ClaudeRootInfo, @@ -264,6 +270,7 @@ import type { LeadContextUsageSnapshot, MemberFullStats, MemberLogSummary, + TeamAgentRuntimeSnapshot, MemberSpawnStatusesSnapshot, MessagesPage, NotificationTrigger, @@ -293,9 +300,9 @@ import type { TeamCreateConfigRequest, TeamCreateRequest, TeamCreateResponse, - TeamData, TeamLaunchRequest, TeamLaunchResponse, + TeamMemberActivityMeta, TeamMessageNotificationData, TeamProvisioningPrepareResult, TeamProvisioningProgress, @@ -303,6 +310,7 @@ import type { TeamTask, TeamTaskStatus, TeamUpdateConfigRequest, + TeamViewSnapshot, ToolApprovalEvent, ToolApprovalFileContent, ToolApprovalSettings, @@ -823,7 +831,7 @@ const electronAPI: ElectronAPI = { return invokeIpcWithResult(TEAM_LIST); }, getData: async (teamName: string) => { - return invokeIpcWithResult(TEAM_GET_DATA, teamName); + return invokeIpcWithResult(TEAM_GET_DATA, teamName); }, getTaskChangePresence: async (teamName: string) => { return invokeIpcWithResult>( @@ -834,6 +842,9 @@ const electronAPI: ElectronAPI = { setChangePresenceTracking: async (teamName: string, enabled: boolean) => { return invokeIpcWithResult(TEAM_SET_CHANGE_PRESENCE_TRACKING, teamName, enabled); }, + setTaskLogStreamTracking: async (teamName: string, enabled: boolean) => { + return invokeIpcWithResult(TEAM_SET_TASK_LOG_STREAM_TRACKING, teamName, enabled); + }, setToolActivityTracking: async (teamName: string, enabled: boolean) => { return invokeIpcWithResult(TEAM_SET_TOOL_ACTIVITY_TRACKING, teamName, enabled); }, @@ -888,10 +899,13 @@ const electronAPI: ElectronAPI = { }, getMessagesPage: async ( teamName: string, - options?: { beforeTimestamp?: string; limit?: number } + options?: { cursor?: string | null; limit?: number } ) => { return invokeIpcWithResult(TEAM_GET_MESSAGES_PAGE, teamName, options); }, + getMemberActivityMeta: async (teamName: string) => { + return invokeIpcWithResult(TEAM_GET_MEMBER_ACTIVITY_META, teamName); + }, createTask: async (teamName: string, request: CreateTaskRequest) => { return invokeIpcWithResult(TEAM_CREATE_TASK, teamName, request); }, @@ -986,6 +1000,13 @@ const electronAPI: ElectronAPI = { activityId ); }, + getTaskLogStreamSummary: async (teamName: string, taskId: string) => { + return invokeIpcWithResult( + TEAM_GET_TASK_LOG_STREAM_SUMMARY, + teamName, + taskId + ); + }, getTaskLogStream: async (teamName: string, taskId: string) => { return invokeIpcWithResult( TEAM_GET_TASK_LOG_STREAM, @@ -1059,6 +1080,12 @@ const electronAPI: ElectronAPI = { getMemberSpawnStatuses: async (teamName: string) => { return invokeIpcWithResult(TEAM_MEMBER_SPAWN_STATUSES, teamName); }, + getTeamAgentRuntime: async (teamName: string) => { + return invokeIpcWithResult(TEAM_GET_AGENT_RUNTIME, teamName); + }, + restartMember: async (teamName: string, memberName: string) => { + return invokeIpcWithResult(TEAM_RESTART_MEMBER, teamName, memberName); + }, softDeleteTask: async (teamName: string, taskId: string) => { return invokeIpcWithResult(TEAM_SOFT_DELETE_TASK, teamName, taskId); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 6d40e202..7037e29e 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -14,6 +14,7 @@ import type { BoardTaskExactLogDetailResult, BoardTaskExactLogSummariesResponse, BoardTaskLogStreamResponse, + BoardTaskLogStreamSummary, ClaudeMdFileInfo, ClaudeRootFolderSelection, ClaudeRootInfo, @@ -58,15 +59,16 @@ import type { TeamClaudeLogsResponse, TeamCreateRequest, TeamCreateResponse, - TeamData, TeamLaunchRequest, TeamLaunchResponse, + TeamMemberActivityMeta, TeamProvisioningPrepareResult, TeamProvisioningProgress, TeamsAPI, TeamSummary, TeamTask, TeamTaskStatus, + TeamViewSnapshot, TmuxAPI, TmuxStatus, TriggerTestResult, @@ -677,7 +679,7 @@ export class HttpAPIClient implements ElectronAPI { console.warn('[HttpAPIClient] teams API is not available in browser mode'); return []; }, - getData: async (_teamName: string): Promise => { + getData: async (_teamName: string): Promise => { throw new Error('Teams detail is not available in browser mode'); }, getTaskChangePresence: async (): Promise< @@ -688,6 +690,9 @@ export class HttpAPIClient implements ElectronAPI { setChangePresenceTracking: async (): Promise => { // Not available in browser mode — no-op. }, + setTaskLogStreamTracking: async (): Promise => { + // Not available in browser mode — no-op. + }, setToolActivityTracking: async (): Promise => { // Not available in browser mode — no-op. }, @@ -742,7 +747,15 @@ export class HttpAPIClient implements ElectronAPI { throw new Error('Team messaging is not available in browser mode'); }, getMessagesPage: async () => { - return { messages: [], nextCursor: null, hasMore: false }; + return { messages: [], nextCursor: null, hasMore: false, feedRevision: 'empty' }; + }, + getMemberActivityMeta: async (_teamName: string): Promise => { + return { + teamName: _teamName, + computedAt: new Date(0).toISOString(), + members: {}, + feedRevision: 'empty', + }; }, createTask: async (_teamName: string, _request: CreateTaskRequest): Promise => { throw new Error('Team task creation is not available in browser mode'); @@ -824,6 +837,10 @@ export class HttpAPIClient implements ElectronAPI { console.warn('[HttpAPIClient] getTaskActivityDetail is not available in browser mode'); return { status: 'missing' }; }, + getTaskLogStreamSummary: async (): Promise => { + console.warn('[HttpAPIClient] getTaskLogStreamSummary is not available in browser mode'); + return { segmentCount: 0 }; + }, getTaskLogStream: async (): Promise => { console.warn('[HttpAPIClient] getTaskLogStream is not available in browser mode'); return { @@ -905,6 +922,17 @@ export class HttpAPIClient implements ElectronAPI { getMemberSpawnStatuses: async () => { return { statuses: {}, runId: null }; }, + getTeamAgentRuntime: async (teamName: string) => { + return { + teamName, + updatedAt: new Date().toISOString(), + runId: null, + members: {}, + }; + }, + restartMember: async (): Promise => { + throw new Error('Member restart is not available in browser mode'); + }, softDeleteTask: async (_teamName: string, _taskId: string): Promise => { // Not available via HTTP client — no-op }, diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index ea523dbf..ea7d0e5e 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -14,17 +14,15 @@ import { SessionContextPanel } from './SessionContextPanel/index'; /** Pixels from bottom considered "near bottom" for scroll-button visibility and auto-scroll. */ const SCROLL_THRESHOLD = 300; -import { - computeRemainingContext, - formatPercentOfTotal, - sumContextInjectionTokens, -} from '@renderer/utils/contextMath'; +import { computeRemainingContext, sumContextInjectionTokens } from '@renderer/utils/contextMath'; +import { deriveContextMetrics } from '@shared/utils/contextMetrics'; import { ChatHistoryEmptyState } from './ChatHistoryEmptyState'; import { ChatHistoryItem } from './ChatHistoryItem'; import { ChatHistoryLoadingState } from './ChatHistoryLoadingState'; import type { ContextInjection } from '@renderer/types/contextInjection'; +import type { ContextUsageLike } from '@shared/utils/contextMetrics'; /** * Waits for two requestAnimationFrame cycles, allowing the virtualizer to render. @@ -129,6 +127,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { const pendingNavigation = thisTab?.pendingNavigation; const teamBySessionId = useStore(useShallow((s) => s.teamBySessionId)); + const leadContextByTeam = useStore(useShallow((s) => s.leadContextByTeam)); // Look up whether this session belongs to a team const sessionTeam = useMemo(() => { @@ -138,9 +137,13 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { }, [teamBySessionId, sessionDetail?.session?.id]); // Compute all accumulated context injections (phase-aware) - const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => { + const { allContextInjections, lastAssistantUsage, lastAssistantModelName } = useMemo(() => { if (!sessionContextStats || !conversation?.items.length) { - return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined }; + return { + allContextInjections: [] as ContextInjection[], + lastAssistantUsage: null as ContextUsageLike | null, + lastAssistantModelName: undefined as string | undefined, + }; } // Determine which phase to show @@ -161,7 +164,8 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { if (lastAiItem?.type !== 'ai') { return { allContextInjections: [] as ContextInjection[], - lastAiGroupTotalTokens: undefined, + lastAssistantUsage: null, + lastAssistantModelName: undefined, }; } targetAiGroupId = lastAiItem.group.id; @@ -170,9 +174,8 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { const stats = sessionContextStats.get(targetAiGroupId); const injections = stats?.accumulatedInjections ?? []; - // Get total INPUT tokens from the target AI group (excluding output tokens, - // since visible context is part of input only) - let totalTokens: number | undefined; + let lastUsage: ContextUsageLike | null = null; + let lastModelName: string | undefined; const targetItem = conversation.items.find( (item) => item.type === 'ai' && item.group.id === targetAiGroupId ); @@ -181,27 +184,51 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { for (let i = responses.length - 1; i >= 0; i--) { const msg = responses[i]; if (msg.type === 'assistant' && msg.usage) { - const usage = msg.usage; - totalTokens = - (usage.input_tokens ?? 0) + - (usage.cache_read_input_tokens ?? 0) + - (usage.cache_creation_input_tokens ?? 0); + lastUsage = msg.usage; + lastModelName = msg.model; break; } } } - return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens }; + return { + allContextInjections: injections, + lastAssistantUsage: lastUsage, + lastAssistantModelName: lastModelName, + }; }, [sessionContextStats, conversation, selectedContextPhase, sessionPhaseInfo]); - - const visibleContextPercentLabel = useMemo(() => { - const visibleTokens = sumContextInjectionTokens(allContextInjections); - return formatPercentOfTotal(visibleTokens, lastAiGroupTotalTokens); - }, [allContextInjections, lastAiGroupTotalTokens]); + const visibleContextTokens = useMemo( + () => sumContextInjectionTokens(allContextInjections), + [allContextInjections] + ); + const sessionLeadContext = sessionTeam ? (leadContextByTeam[sessionTeam.teamName] ?? null) : null; + const contextMetrics = useMemo( + () => + deriveContextMetrics({ + usage: lastAssistantUsage, + modelName: lastAssistantModelName, + contextWindowTokens: sessionLeadContext?.contextWindowTokens ?? null, + visibleContextTokens, + }), + [ + lastAssistantModelName, + lastAssistantUsage, + sessionLeadContext?.contextWindowTokens, + visibleContextTokens, + ] + ); + const contextUsedPercentLabel = useMemo(() => { + const percent = contextMetrics.contextUsedPercentOfContextWindow; + return percent === null ? null : `${percent.toFixed(1)}%`; + }, [contextMetrics.contextUsedPercentOfContextWindow]); const remainingContext = useMemo( - () => computeRemainingContext(lastAiGroupTotalTokens), - [lastAiGroupTotalTokens] + () => + computeRemainingContext( + contextMetrics.contextUsedTokens ?? undefined, + contextMetrics.contextWindowTokens ?? undefined + ), + [contextMetrics.contextUsedTokens, contextMetrics.contextWindowTokens] ); // State for navigation highlight (blue, used for Turn navigation from CLAUDE.md panel) @@ -839,7 +866,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { onNavigateToTurn={handleNavigateToTurn} onNavigateToTool={handleNavigateToTool} onNavigateToUserGroup={handleNavigateToUserGroup} - totalSessionTokens={lastAiGroupTotalTokens} + contextMetrics={contextMetrics} sessionMetrics={sessionDetail?.metrics} subagentCostUsd={subagentCostUsd} onViewReport={effectiveTabId ? () => openSessionReport(effectiveTabId) : undefined} @@ -877,9 +904,9 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { : 'var(--color-text-secondary)', }} > - {visibleContextPercentLabel ? ( + {contextUsedPercentLabel ? ( <> - {visibleContextPercentLabel} + {contextUsedPercentLabel} {remainingContext && remainingContext.urgency !== 'normal' && ( void; @@ -42,7 +42,7 @@ interface SessionContextHeaderProps { export const SessionContextHeader = ({ injectionCount, totalTokens, - totalSessionTokens, + contextMetrics, sessionMetrics, subagentCostUsd, onClose, @@ -53,6 +53,45 @@ export const SessionContextHeader = ({ viewMode, onViewModeChange, }: Readonly): React.ReactElement => { + const formatPercentLabel = (percent: number | null, suffix: string): string | null => { + if (percent === null) { + return null; + } + return `${percent.toFixed(1)}% ${suffix}`; + }; + + const renderMetricValue = ( + label: string, + tokens: number | null, + percentLabel: string | null, + options?: { + approximate?: boolean; + unavailableLabel?: string; + } + ): React.ReactElement => ( +
+ {label} +
+
+ {tokens === null + ? (options?.unavailableLabel ?? 'Unavailable') + : `${options?.approximate ? '~' : ''}${formatTokens(tokens)}`} +
+ {percentLabel && ( +
+ {percentLabel} +
+ )} +
+
+ ); + + const codexTelemetryUnavailable = + contextMetrics?.providerId === 'codex' && contextMetrics.promptInputSource === 'unavailable'; + return (
{/* Title row */} @@ -60,7 +99,7 @@ export const SessionContextHeader = ({

- Visible Context + Context

- {/* Token comparison stats */} + {/* Primary metrics */}
-
- {/* Visible Context tokens */} -
- Visible: - - ~{formatTokens(totalTokens)} - -
- {/* Total Input tokens (if provided) */} - {totalSessionTokens !== undefined && totalSessionTokens > 0 && ( -
- Input: - - {formatTokens(totalSessionTokens)} - -
- )} -
- {/* Percentage of total */} - {formatPercentOfTotal(totalTokens, totalSessionTokens) && ( - - {formatPercentOfTotal(totalTokens, totalSessionTokens)} - + {renderMetricValue( + 'Context Used', + contextMetrics?.contextUsedTokens ?? null, + formatPercentLabel( + contextMetrics?.contextUsedPercentOfContextWindow ?? null, + 'of context' + ) + )} + {renderMetricValue( + 'Prompt Input', + contextMetrics?.promptInputTokens ?? null, + formatPercentLabel( + contextMetrics?.promptInputPercentOfContextWindow ?? null, + 'of context' + ) + )} + {renderMetricValue( + 'Visible Context', + totalTokens, + formatPercentLabel( + contextMetrics?.visibleContextPercentOfPromptInput ?? null, + 'of prompt' + ), + { approximate: true } )}
+ {codexTelemetryUnavailable && ( +
+ Codex prompt-side usage is not exposed by the current runtime telemetry yet, so Prompt + Input and Context Used stay unavailable instead of showing a fake zero. +
+ )} + {/* Session Metrics Breakdown */} {sessionMetrics && (
{
- {/* What is Visible Context */} + {/* Metric definitions */}
- What is Visible Context? + Context Used

- Tokens consumed by file reads, tool outputs, and configuration files (CLAUDE.md) - that are injected into the conversation. + Prompt input plus output tokens currently occupying the model's context + window.

- {/* Difference with Total */}
- Total Context vs Visible Context -
-
-
- - Total: - - - Total tokens that are injected into the conversation - -
-
- - Visible: - - - Subset of tokens that you can optimize & debug - -
+ Prompt Input
+

+ Tokens sent to the model before generation. For Claude this includes `input_tokens + + cache_creation_input_tokens + cache_read_input_tokens`. +

- {/* Tips */}
- Optimization Tips + Visible Context
-
    -
  • Shorten large CLAUDE.md files
  • -
  • Split large @-mentioned files
  • -
  • Adjust MCP tool output verbosity
  • -
+

+ The inspectable subset of prompt input: files, CLAUDE.md, tool outputs, user + messages, and similar injections that you can optimize directly. +

+
+ +
+
+ Availability +
+

+ If a provider runtime does not expose prompt-side usage yet, the panel shows + metrics as unavailable instead of pretending they are zero. +

, diff --git a/src/renderer/components/chat/SessionContextPanel/index.tsx b/src/renderer/components/chat/SessionContextPanel/index.tsx index 60754d7e..e44e25e3 100644 --- a/src/renderer/components/chat/SessionContextPanel/index.tsx +++ b/src/renderer/components/chat/SessionContextPanel/index.tsx @@ -48,7 +48,7 @@ export const SessionContextPanel = ({ onNavigateToTurn, onNavigateToTool, onNavigateToUserGroup, - totalSessionTokens, + contextMetrics, sessionMetrics, subagentCostUsd, onViewReport, @@ -193,7 +193,7 @@ export const SessionContextPanel = ({ void; /** Navigate to the user message group preceding the AI group at turnIndex */ onNavigateToUserGroup?: (turnIndex: number) => void; - /** Total session tokens (input + output + cache) for comparison */ - totalSessionTokens?: number; + /** Unified context metrics for the selected AI group */ + contextMetrics?: DerivedContextMetrics; /** Full session metrics (input, output, cache tokens, cost) */ sessionMetrics?: SessionMetrics; /** Combined cost of all subagent processes */ diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx index 7cd94409..4baeff2b 100644 --- a/src/renderer/components/chat/UserChatGroup.tsx +++ b/src/renderer/components/chat/UserChatGroup.tsx @@ -7,6 +7,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors' import { useTabUI } from '@renderer/hooks/useTabUI'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; @@ -398,7 +399,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React. // Get team members for @mention highlighting and team names for @team linkification const { members, teams } = useStore( useShallow((s) => ({ - members: s.selectedTeamData?.members, + members: selectResolvedMembersForTeamName(s, s.selectedTeamName), teams: s.teams, })) ); diff --git a/src/renderer/components/chat/items/TeammateMessageItem.tsx b/src/renderer/components/chat/items/TeammateMessageItem.tsx index 81ab8195..bd5bc904 100644 --- a/src/renderer/components/chat/items/TeammateMessageItem.tsx +++ b/src/renderer/components/chat/items/TeammateMessageItem.tsx @@ -10,6 +10,7 @@ import { import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting'; import { formatTokensCompact } from '@renderer/utils/formatters'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; @@ -86,7 +87,9 @@ export const TeammateMessageItem: React.FC = ({ const { isLight } = useTheme(); // Get team members for @mention highlighting - const members = useStore(useShallow((s) => s.selectedTeamData?.members)); + const members = useStore( + useShallow((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName)) + ); const memberColorMap = useMemo( () => (members ? buildMemberColorMap(members) : new Map()), [members] diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index a9b9454c..1e671096 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -71,10 +71,21 @@ interface MarkdownViewerProps { onTeamClick?: (teamName: string) => void; } +interface CompactMarkdownPreviewProps { + content: string; + className?: string; + /** Optional precomputed team color map to avoid subscribing to the full team list. */ + teamColorByName?: ReadonlyMap; + /** Optional team click handler to avoid subscribing to store in leaf renderers. */ + onTeamClick?: (teamName: string) => void; +} + const EMPTY_TEAMS: { teamName?: string; displayName?: string; color?: string }[] = []; const EMPTY_TEAM_COLOR_MAP = new Map(); const NOOP_TEAM_CLICK = (): void => undefined; +type ViewerMarkdownMode = 'default' | 'compact-preview'; + // ============================================================================= // Helpers // ============================================================================= @@ -322,53 +333,89 @@ function createViewerMarkdownComponents( isLight = false, teamColorByName: ReadonlyMap = new Map(), onTeamClick?: (teamName: string) => void, - copyCodeBlocks: boolean = false + copyCodeBlocks: boolean = false, + mode: ViewerMarkdownMode = 'default' ): Components { const hl = (children: React.ReactNode): React.ReactNode => searchCtx ? highlightSearchInChildren(children, searchCtx) : children; + const isCompactPreview = mode === 'compact-preview'; + + const renderCompactInline = ( + children: React.ReactNode, + className: string, + style: React.CSSProperties + ): React.ReactElement => ( + + {hl(children)}{' '} + + ); return { // Headings - h1: ({ children }) => ( -

- {hl(children)} -

- ), - h2: ({ children }) => ( -

- {hl(children)} -

- ), - h3: ({ children }) => ( -

- {hl(children)} -

- ), - h4: ({ children }) => ( -

- {hl(children)} -

- ), - h5: ({ children }) => ( -
- {hl(children)} -
- ), - h6: ({ children }) => ( -
- {hl(children)} -
- ), + h1: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) + ) : ( +

+ {hl(children)} +

+ ), + h2: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) + ) : ( +

+ {hl(children)} +

+ ), + h3: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) + ) : ( +

+ {hl(children)} +

+ ), + h4: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) + ) : ( +

+ {hl(children)} +

+ ), + h5: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-medium', { color: PROSE_HEADING }) + ) : ( +
+ {hl(children)} +
+ ), + h6: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-medium', { color: PROSE_HEADING }) + ) : ( +
+ {hl(children)} +
+ ), // Paragraphs - p: ({ children }) => ( -

- {hl(children)} -

- ), + p: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, '', { color: PROSE_BODY }) + ) : ( +

+ {hl(children)} +

+ ), // Links — inline element, no hl(); parent block element's hl() descends here // task:// links render with TaskTooltip + are clickable via ancestor onClickCapture @@ -570,6 +617,20 @@ function createViewerMarkdownComponents( // Code blocks — intercept mermaid diagrams at the pre level pre: ({ children, node }) => { + if (isCompactPreview) { + const compactText = extractTextFromReactNode(children).trim(); + return ( + + {compactText} + + ); + } // Check if this pre contains a mermaid code block const codeEl = node?.children?.[0]; if (codeEl && 'tagName' in codeEl && codeEl.tagName === 'code' && 'properties' in codeEl) { @@ -596,74 +657,107 @@ function createViewerMarkdownComponents( }, // Blockquotes - blockquote: ({ children }) => ( -
- {hl(children)} -
- ), + blockquote: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'italic', { color: PROSE_MUTED }) + ) : ( +
+ {hl(children)} +
+ ), // Lists - ul: ({ children }) => ( -
    - {children} -
- ), - ol: ({ children }) => ( -
    - {children} -
- ), - li: ({ children }) => ( -
  • - {hl(children)} -
  • - ), + ul: ({ children }) => + isCompactPreview ? ( + {children} + ) : ( +
      + {children} +
    + ), + ol: ({ children }) => + isCompactPreview ? ( + {children} + ) : ( +
      + {children} +
    + ), + li: ({ children }) => + isCompactPreview ? ( + + • {hl(children)}{' '} + + ) : ( +
  • + {hl(children)} +
  • + ), // Tables - table: ({ children }) => ( -
    -
    + isCompactPreview ? ( + {children} + ) : ( +
    +
    + {children} +
    + + ), + thead: ({ children }) => + isCompactPreview ? ( + {children} + ) : ( + {children} + ), + th: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) + ) : ( + - {children} - - - ), - thead: ({ children }) => ( - {children} - ), - th: ({ children }) => ( - - {hl(children)} - - ), - td: ({ children }) => ( - - {hl(children)} - - ), + {hl(children)} + + ), + td: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, '', { color: PROSE_BODY }) + ) : ( + + {hl(children)} + + ), // Horizontal rule - hr: () =>
    , + hr: () => + isCompactPreview ? ( + + · + + ) : ( +
    + ), }; } @@ -679,6 +773,78 @@ const LARGE_PREVIEW_CHARS = 30_000; // Component // ============================================================================= +function useResolvedViewerTeamContext( + providedTeamColorByName?: ReadonlyMap, + providedOnTeamClick?: (teamName: string) => void +): { + teamColorByName: ReadonlyMap; + onTeamClick?: (teamName: string) => void; +} { + const teams = useStore(useShallow((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams))); + const openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab)); + + const fallbackTeamColorByName = React.useMemo(() => { + const result = new Map(); + for (const team of teams) { + if (team.teamName) { + result.set(team.teamName, team.color ?? ''); + } + if (team.displayName) { + result.set(team.displayName, team.color ?? ''); + } + } + return result; + }, [teams]); + + return { + teamColorByName: providedTeamColorByName ?? fallbackTeamColorByName ?? EMPTY_TEAM_COLOR_MAP, + onTeamClick: providedOnTeamClick ?? openTeamTab, + }; +} + +export const CompactMarkdownPreview: React.FC = React.memo( + function CompactMarkdownPreview({ + content, + className = '', + teamColorByName: providedTeamColorByName, + onTeamClick: providedOnTeamClick, + }) { + const { isLight } = useTheme(); + const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext( + providedTeamColorByName, + providedOnTeamClick + ); + + const components = React.useMemo( + () => + createViewerMarkdownComponents( + null, + isLight, + teamColorByName, + onTeamClick, + false, + 'compact-preview' + ), + [isLight, onTeamClick, teamColorByName] + ); + + return ( +
    + + {content} + +
    + ); + } +); + export const MarkdownViewer: React.FC = ({ content, maxHeight = 'max-h-96', @@ -695,24 +861,10 @@ export const MarkdownViewer: React.FC = ({ const [showRaw, setShowRaw] = React.useState(false); const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); const { isLight } = useTheme(); - const teams = useStore(useShallow((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams))); - const openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab)); - - const fallbackTeamColorByName = React.useMemo(() => { - const result = new Map(); - for (const team of teams) { - if (team.teamName) { - result.set(team.teamName, team.color ?? ''); - } - if (team.displayName) { - result.set(team.displayName, team.color ?? ''); - } - } - return result; - }, [teams]); - const teamColorByName = - providedTeamColorByName ?? fallbackTeamColorByName ?? EMPTY_TEAM_COLOR_MAP; - const onTeamClick = providedOnTeamClick ?? openTeamTab; + const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext( + providedTeamColorByName, + providedOnTeamClick + ); const isTooLarge = content.length > MAX_MARKDOWN_CHARS; const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS; diff --git a/src/renderer/components/common/OngoingIndicator.tsx b/src/renderer/components/common/OngoingIndicator.tsx index 8a0473ab..86008604 100644 --- a/src/renderer/components/common/OngoingIndicator.tsx +++ b/src/renderer/components/common/OngoingIndicator.tsx @@ -14,6 +14,8 @@ interface OngoingIndicatorProps { showLabel?: boolean; /** Custom label text */ label?: string; + /** Accessible title/tooltip text */ + title?: string; } /** @@ -24,11 +26,12 @@ export const OngoingIndicator = ({ size = 'sm', showLabel = false, label = 'Session in progress...', + title = label, }: Readonly): React.JSX.Element => { const dotSize = size === 'sm' ? 'h-2 w-2' : 'h-2.5 w-2.5'; return ( - + diff --git a/src/renderer/components/common/TokenUsageDisplay.tsx b/src/renderer/components/common/TokenUsageDisplay.tsx index 7bd72fbd..a1c52f23 100644 --- a/src/renderer/components/common/TokenUsageDisplay.tsx +++ b/src/renderer/components/common/TokenUsageDisplay.tsx @@ -48,8 +48,6 @@ interface TokenUsageDisplayProps { totalPhases?: number; /** Optional USD cost for this usage */ costUsd?: number; - /** Context window size (e.g., 200000 or 1000000). When provided, shows "X% context used" instead of "X% of input". */ - contextWindowSize?: number; } /** @@ -59,27 +57,22 @@ interface TokenUsageDisplayProps { const SessionContextSection = ({ contextStats, totalInputTokens, - contextWindowSize, }: Readonly<{ contextStats: ContextStats; totalInputTokens: number; - contextWindowSize?: number; }>): React.JSX.Element => { const [expanded, setExpanded] = useState(false); const { tokensByCategory } = contextStats; // contextStats.totalEstimatedTokens already includes all categories (CLAUDE.md, @files, - // tool outputs, thinking+text, task coordination, user messages) — no manual adjustment needed. - // Show context window usage % when contextWindowSize is available (more useful), - // otherwise fall back to visible context / total input ratio. + // tool outputs, thinking+text, task coordination, user messages) - no manual adjustment needed. + // Visible Context is always shown as a share of prompt-side input tokens so this section + // stays aligned with the unified context contract instead of silently switching semantics. const contextPercent = - contextWindowSize && contextWindowSize > 0 - ? Math.min((totalInputTokens / contextWindowSize) * 100, 100).toFixed(1) - : totalInputTokens > 0 - ? Math.min((contextStats.totalEstimatedTokens / totalInputTokens) * 100, 100).toFixed(1) - : '0.0'; - const contextLabel = contextWindowSize ? 'of context' : 'of input'; + totalInputTokens > 0 + ? Math.min((contextStats.totalEstimatedTokens / totalInputTokens) * 100, 100).toFixed(1) + : '0.0'; // Count accumulated injections by category const claudeMdCount = contextStats.accumulatedInjections.filter( @@ -152,7 +145,7 @@ const SessionContextSection = ({ className="whitespace-nowrap text-[10px] tabular-nums" style={{ color: COLOR_TEXT_MUTED }} > - {formatTokens(contextStats.totalEstimatedTokens)} ({contextPercent}% {contextLabel}) + {formatTokens(contextStats.totalEstimatedTokens)} ({contextPercent}% of prompt input) @@ -261,10 +254,9 @@ export const TokenUsageDisplay = ({ phaseNumber, totalPhases, costUsd, - contextWindowSize, }: Readonly): React.JSX.Element => { const totalTokens = inputTokens + cacheReadTokens + cacheCreationTokens + outputTokens; - // Total input tokens only (without output) — used as denominator for visible context % + // Total prompt-side tokens only (without output) - used as denominator for visible context % const totalInputTokens = inputTokens + cacheReadTokens + cacheCreationTokens; const formattedTotal = formatTokens(totalTokens); @@ -540,7 +532,6 @@ export const TokenUsageDisplay = ({ )} diff --git a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx index 458bcce7..40df90fd 100644 --- a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx @@ -29,7 +29,7 @@ import { useShallow } from 'zustand/react/shallow'; import { resolveSkillProjectPath } from './skillProjectUtils'; -import type { SkillValidationIssue } from '@shared/types'; +import type { SkillValidationIssue } from '@shared/types/extensions'; interface SkillDetailDialogProps { skillId: string | null; diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts index 0ab735d2..85ffa332 100644 --- a/src/renderer/components/settings/hooks/useSettingsConfig.ts +++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts @@ -54,6 +54,7 @@ export interface SafeConfig { notifyOnCrossTeamMessage: boolean; notifyOnTeamLaunched: boolean; notifyOnToolApproval: boolean; + autoResumeOnRateLimit: boolean; statusChangeOnlySolo: boolean; statusChangeStatuses: string[]; triggers: AppConfig['notifications']['triggers']; @@ -195,6 +196,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn { notifyOnCrossTeamMessage: displayConfig?.notifications?.notifyOnCrossTeamMessage ?? true, notifyOnTeamLaunched: displayConfig?.notifications?.notifyOnTeamLaunched ?? true, notifyOnToolApproval: displayConfig?.notifications?.notifyOnToolApproval ?? true, + autoResumeOnRateLimit: displayConfig?.notifications?.autoResumeOnRateLimit ?? false, statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true, statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [ 'in_progress', diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts index 94245140..ac027199 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -311,6 +311,7 @@ export function useSettingsHandlers({ notifyOnCrossTeamMessage: true, notifyOnTeamLaunched: true, notifyOnToolApproval: true, + autoResumeOnRateLimit: false, statusChangeOnlySolo: true, statusChangeStatuses: ['in_progress', 'completed'], triggers: defaultTriggers, diff --git a/src/renderer/components/settings/sections/AdvancedSection.tsx b/src/renderer/components/settings/sections/AdvancedSection.tsx index 086599af..949a3c00 100644 --- a/src/renderer/components/settings/sections/AdvancedSection.tsx +++ b/src/renderer/components/settings/sections/AdvancedSection.tsx @@ -172,7 +172,7 @@ export const AdvancedSection = ({

    - Claude Agent Teams UI + Agent Teams UI

    {isElectron && ( - {showArchived ? 'Hide archived' : 'Show archived'} + {effectiveShowArchived ? 'Hide archived' : 'Show archived'}
    @@ -579,7 +608,7 @@ export const GlobalTaskList = ({
    {/* Content */} -
    +
    {globalTasksLoading && !globalTasksInitialized && (
    {[1, 2, 3].map((i) => ( @@ -626,15 +655,31 @@ export const GlobalTaskList = ({ projectGroups.map((group) => { if (group.tasks.length === 0) return null; const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey); - const groupColor = projectColor(group.projectLabel); + const isNoProjectGroup = group.projectKey === NO_PROJECT_KEY; + const groupColor = isNoProjectGroup + ? noProjectGroupColor + : projectColor(group.projectLabel); + const visibleCount = getProjectGroupVisibleCount( + projectVisibleCountByKey[group.projectKey], + group.tasks.length + ); + const visibleTasks = group.tasks.slice(0, visibleCount); + const showMoreVisible = canProjectGroupShowMore(visibleCount, group.tasks.length); + const showLessVisible = canProjectGroupShowLess(visibleCount, group.tasks.length); let lastTeam: string | null = null; return (
    {!isGroupCollapsed && - group.tasks.map((task) => { + visibleTasks.map((task) => { const showTeamHeader = task.teamName !== lastTeam; lastTeam = task.teamName; return ( @@ -691,6 +739,44 @@ export const GlobalTaskList = ({
    ); })} + {!isGroupCollapsed && (showMoreVisible || showLessVisible) && ( +
    + {showMoreVisible && ( + + )} + {showLessVisible && ( + + )} +
    + )}
    ); })} diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 5267a413..688aa958 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -144,11 +144,13 @@ export const SidebarTaskItem = ({ ); const showTeamRow = showTeamName && !hideTeamName; + const unreadBackgroundClass = + unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : ''; return (
    @@ -655,18 +754,24 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({ teamName, ...props }: TeamMemberListBridgeProps): React.JSX.Element { - const { leadActivity, progress, memberSpawnStatuses, memberSpawnSnapshot } = useStore( - useShallow((s) => ({ - leadActivity: s.leadActivityByTeam[teamName], - progress: getCurrentProvisioningProgressForTeam(s, teamName), - memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], - memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], - })) - ); + const { leadActivity, progress, memberSpawnStatuses, memberSpawnSnapshot, runtimeSnapshot } = + useStore( + useShallow((s) => ({ + leadActivity: s.leadActivityByTeam[teamName], + progress: getCurrentProvisioningProgressForTeam(s, teamName), + memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], + memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], + runtimeSnapshot: s.teamAgentRuntimeByTeam[teamName], + })) + ); const memberSpawnStatusMap = useMemo( () => buildMemberSpawnStatusMap(memberSpawnStatuses), [memberSpawnStatuses] ); + const memberRuntimeMap = useMemo( + () => buildTeamAgentRuntimeMap(runtimeSnapshot?.members), + [runtimeSnapshot?.members] + ); const isLaunchSettling = useMemo(() => { if (progress?.state !== 'ready') { return false; @@ -685,6 +790,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({ {...props} leadActivity={leadActivity} memberSpawnStatuses={memberSpawnStatusMap} + memberRuntimeEntries={memberRuntimeMap} isLaunchSettling={isLaunchSettling} /> ); @@ -740,19 +846,23 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( }: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null { const { leadActivity, + liveMember, progress, - members: launchMembers, + launchMembers, memberSpawnStatuses, memberSpawnSnapshot, spawnEntry, + runtimeEntry, } = useStore( useShallow((s) => ({ leadActivity: s.leadActivityByTeam[teamName], + liveMember: member ? selectResolvedMemberForTeamName(s, teamName, member.name) : null, progress: getCurrentProvisioningProgressForTeam(s, teamName), - members: s.selectedTeamName === teamName ? (s.selectedTeamData?.members ?? []) : [], + launchMembers: selectTeamMemberSnapshotsForName(s, teamName), memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined, + runtimeEntry: member ? s.teamAgentRuntimeByTeam[teamName]?.members[member.name] : undefined, })) ); const isLaunchSettling = useMemo(() => { @@ -772,10 +882,11 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( ); }); @@ -821,7 +932,6 @@ export const TeamDetailView = ({ ); const provisioningBannerRef = useRef(null); const wasProvisioningRef = useRef(false); - const pendingReplyRefreshTimerRef = useRef(null); const handleOpenGraphTab = useCallback(() => { const state = useStore.getState(); const displayName = state.teamByName[teamName]?.displayName ?? teamName; @@ -898,7 +1008,7 @@ export const TeamDetailView = ({ initialActivityFilter, } = (e as CustomEvent).detail ?? {}; if (tn !== teamName || !data) return; - const member = data.members.find((m: { name: string }) => m.name === memberName); + const member = members.find((m: { name: string }) => m.name === memberName); if (member) { setSelectedMember(member); setSelectedMemberView({ @@ -1059,6 +1169,7 @@ export const TeamDetailView = ({ const { data, + members, loading, error, projects, @@ -1081,6 +1192,7 @@ export const TeamDetailView = ({ lastSendMessageResult, reviewActionError, addMember, + restartMember, removeMember, updateMemberRole, launchTeam, @@ -1088,6 +1200,7 @@ export const TeamDetailView = ({ clearProvisioningError, isTeamProvisioning, refreshTeamData, + syncTeamPendingReplyRefresh, kanbanFilterQuery, clearKanbanFilter, softDeleteTask, @@ -1126,6 +1239,7 @@ export const TeamDetailView = ({ lastSendMessageResult: s.lastSendMessageResult, reviewActionError: s.reviewActionError, addMember: s.addMember, + restartMember: s.restartMember, removeMember: s.removeMember, updateMemberRole: s.updateMemberRole, launchTeam: s.launchTeam, @@ -1133,9 +1247,11 @@ export const TeamDetailView = ({ clearProvisioningError: s.clearProvisioningError, isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false, data: s.selectedTeamName === teamName ? s.selectedTeamData : null, + members: selectResolvedMembersForTeamName(s, teamName), loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false, error: s.selectedTeamName === teamName ? s.selectedTeamError : null, refreshTeamData: s.refreshTeamData, + syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh, kanbanFilterQuery: s.kanbanFilterQuery, clearKanbanFilter: s.clearKanbanFilter, softDeleteTask: s.softDeleteTask, @@ -1169,13 +1285,12 @@ export const TeamDetailView = ({ diagnostic.count += 1; const commitMs = performance.now() - renderStartedAtRef.current; - const messagesCount = data?.messages.length ?? 0; const tasksCount = data?.tasks.length ?? 0; - const membersCount = data?.members.length ?? 0; + const membersCount = members.length; const processesCount = data?.processes.length ?? 0; const shouldWarnSlow = commitMs >= TEAM_DETAIL_COMMIT_WARN_MS; const shouldWarnBurst = diagnostic.count >= TEAM_DETAIL_RENDER_BURST_WARN_COUNT; - const shouldWarnLarge = messagesCount >= 150 || tasksCount >= 80; + const shouldWarnLarge = tasksCount >= 80; if ( (shouldWarnSlow || shouldWarnBurst || shouldWarnLarge) && @@ -1187,7 +1302,7 @@ export const TeamDetailView = ({ now - diagnostic.windowStartedAt } activeTab=${isThisTabActive ? 'yes' : 'no'} paneFocused=${isPaneFocused ? 'yes' : 'no'} loading=${ loading ? 'yes' : 'no' - } messages=${messagesCount} tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}` + } tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}` ); } }); @@ -1301,36 +1416,34 @@ export const TeamDetailView = ({ ); const leadSessionId = data?.config.leadSessionId ?? null; + const pendingReplyRefreshSourceId = useId(); const sessionHistoryKey = useMemo( () => (data?.config.sessionHistory ?? []).join('|'), [data?.config.sessionHistory] ); // Keep team message state fresh while we are explicitly waiting for a reply. - // Use a delayed single-shot refresh instead of a tight polling loop so we - // don't keep rewriting the whole team snapshot every 2 seconds. + // This stays enabled even for hidden mounted tabs, because the waiting state + // is renderer-local and should keep its lightweight polling until resolved. useEffect(() => { - if (pendingReplyRefreshTimerRef.current != null) { - window.clearTimeout(pendingReplyRefreshTimerRef.current); - pendingReplyRefreshTimerRef.current = null; - } - - if (!isThisTabActive) return; - if (!data?.isAlive) return; - if (Object.keys(pendingRepliesByMember).length === 0) return; - - pendingReplyRefreshTimerRef.current = window.setTimeout(() => { - pendingReplyRefreshTimerRef.current = null; - void refreshTeamData(teamName, { withDedup: true }); - }, TEAM_PENDING_REPLY_REFRESH_DELAY_MS); + const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0; + syncTeamPendingReplyRefresh( + teamName, + pendingReplyRefreshSourceId, + Boolean(data?.isAlive) && hasPendingReplies, + TEAM_PENDING_REPLY_REFRESH_DELAY_MS + ); return () => { - if (pendingReplyRefreshTimerRef.current != null) { - window.clearTimeout(pendingReplyRefreshTimerRef.current); - pendingReplyRefreshTimerRef.current = null; - } + syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId, false); }; - }, [isThisTabActive, data, pendingRepliesByMember, refreshTeamData, teamName]); + }, [ + data?.isAlive, + pendingRepliesByMember, + pendingReplyRefreshSourceId, + syncTeamPendingReplyRefresh, + teamName, + ]); useEffect(() => { if (!projectId) return; @@ -1364,9 +1477,9 @@ export const TeamDetailView = ({ // Live git branch tracking for the lead project and member worktrees const teamProjectPath = data?.config.projectPath?.trim() ?? null; const leadProjectPath = useMemo(() => { - const explicitLeadPath = data?.members.find((member) => isLeadMember(member))?.cwd?.trim(); + const explicitLeadPath = members.find((member) => isLeadMember(member))?.cwd?.trim(); return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath; - }, [data?.members, teamProjectPath]); + }, [members, teamProjectPath]); const branchSyncPaths = useMemo(() => { const uniquePaths = new Map(); const addPath = (candidate: string | null | undefined): void => { @@ -1378,12 +1491,12 @@ export const TeamDetailView = ({ }; addPath(leadProjectPath); - for (const member of data?.members ?? []) { + for (const member of members) { addPath(member.cwd); } return Array.from(uniquePaths.values()); - }, [data?.members, leadProjectPath]); + }, [members, leadProjectPath]); useBranchSync(branchSyncPaths, { live: true }); const trackedBranches = useStore( useShallow((s) => @@ -1401,7 +1514,7 @@ export const TeamDetailView = ({ const membersWithLiveBranches = useMemo(() => { if (!data) return []; - return data.members.map((member) => { + return members.map((member) => { const memberPath = member.cwd?.trim(); const nextGitBranch = memberPath && !isLeadMember(member) && leadBranch !== null @@ -1423,7 +1536,7 @@ export const TeamDetailView = ({ } return nextMember; }); - }, [data, leadBranch, trackedBranches]); + }, [leadBranch, members, trackedBranches]); // Filter sessions to team-only using sessionHistory + leadSessionId const teamSessionIds = useMemo(() => { @@ -1787,7 +1900,6 @@ export const TeamDetailView = ({ mountPoint: messagesPanelMountPoint, members: activeMembers, tasks: data?.tasks ?? [], - messages: data?.messages ?? [], isTeamAlive: data?.isAlive, timeWindow, teamSessionIds, @@ -1805,7 +1917,6 @@ export const TeamDetailView = ({ activeMembers, data?.config.leadSessionId, data?.isAlive, - data?.messages, data?.tasks, handleCreateTaskFromMessage, handleOpenTask, @@ -1837,6 +1948,14 @@ export const TeamDetailView = ({ isTeamAlive={data?.isAlive} /> ); + const teamAgentRuntimeWatcher = ( + + ); const leadContextWatcher = ( setRequestChangesTaskId(null)} onSubmit={(comment, taskRefs) => { if (!requestChangesTaskId) { @@ -2509,11 +2628,11 @@ export const TeamDetailView = ({ teamName={teamName} members={membersWithLiveBranches} tasks={data.tasks} - messages={data.messages} initialTab={selectedMemberView?.initialTab} initialActivityFilter={selectedMemberView?.initialActivityFilter} isTeamAlive={data.isAlive} isTeamProvisioning={isTeamProvisioning} + launchParams={launchParams} onClose={closeSelectedMemberDialog} onSendMessage={() => { const name = selectedMember?.name ?? ''; @@ -2529,6 +2648,7 @@ export const TeamDetailView = ({ closeSelectedMemberDialog(); openCreateTaskDialog('', '', name); }} + onRestartMember={(memberName) => restartMember(teamName, memberName)} onTaskClick={(task) => { closeSelectedMemberDialog(); setSelectedTask(task); @@ -2858,7 +2978,7 @@ export const TeamDetailView = ({ if (task) setSelectedTask(task); }} onOpenMemberProfile={(memberName, options) => { - const member = data.members.find((m) => m.name === memberName); + const member = members.find((m) => m.name === memberName); if (member) { setSelectedMember(member); setSelectedMemberView({ @@ -2877,6 +2997,7 @@ export const TeamDetailView = ({ return ( <> {spawnStatusWatcher} + {teamAgentRuntimeWatcher} {leadContextWatcher} {renderBody()} diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index d9ea2670..0b909a0c 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -59,6 +59,7 @@ import type { ResolvedTeamMember, TeamCreateRequest, TeamLaunchRequest, + TeamMemberSnapshot, TeamSummary, TeamSummaryMember, } from '@shared/types'; @@ -94,6 +95,17 @@ function folderName(fullPath: string): string { return getBaseName(fullPath) || fullPath; } +function resolveLaunchDialogMembers(members: readonly TeamMemberSnapshot[]): ResolvedTeamMember[] { + return members.map((member) => { + return { + ...member, + status: member.currentTaskId ? 'active' : 'idle', + messageCount: 0, + lastActiveAt: null, + }; + }); +} + function renderMemberChips(members: TeamSummaryMember[], isLight: boolean): React.JSX.Element { const teamColorMap = buildMemberColorMap(members); return ( @@ -625,7 +637,7 @@ export const TeamListView = (): React.JSX.Element => { try { const data = await api.teams.getData(teamName); setLaunchDialogTeamName(teamName); - setLaunchDialogMembers(data.members ?? []); + setLaunchDialogMembers(resolveLaunchDialogMembers(data.members ?? [])); setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath); setLaunchDialogOpen(true); } catch (err) { diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx index 5666e93d..337792af 100644 --- a/src/renderer/components/team/ToolApprovalSheet.tsx +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { shortenDisplayPath } from '@renderer/utils/pathDisplay'; import { highlightLines } from '@renderer/utils/syntaxHighlighter'; import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react'; @@ -149,6 +150,7 @@ export const ToolApprovalSheet: React.FC = () => { teams, selectedTeamName, selectedTeamData, + selectedTeamMembers, } = useStore( useShallow((s) => ({ pendingApprovals: s.pendingApprovals, @@ -157,6 +159,7 @@ export const ToolApprovalSheet: React.FC = () => { teams: s.teams, selectedTeamName: s.selectedTeamName, selectedTeamData: s.selectedTeamData, + selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName), })) ); const { isLight } = useTheme(); @@ -273,9 +276,9 @@ export const ToolApprovalSheet: React.FC = () => { // Resolve teammate color for MemberBadge (when source !== 'lead') const sourceColor = useMemo(() => { if (!current || current.source === 'lead') return undefined; - const member = selectedTeamData?.members?.find((m) => m.name === current.source); + const member = selectedTeamMembers.find((m) => m.name === current.source); return member?.color; - }, [current, selectedTeamData?.members]); + }, [current, selectedTeamMembers]); if (!current) return null; diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index f29ff996..0ad6473d 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -1,12 +1,20 @@ import { Fragment, memo, useCallback, useMemo } from 'react'; -import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; +import { + CompactMarkdownPreview, + MarkdownViewer, +} from '@renderer/components/chat/viewers/MarkdownViewer'; import { CopyButton } from '@renderer/components/common/CopyButton'; import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { TaskTooltip } from '@renderer/components/team/TaskTooltip'; import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@renderer/components/ui/tooltip'; import { CARD_BG, CARD_BG_ZEBRA, @@ -668,11 +676,8 @@ export const ActivityItem = memo( }, [message.timestamp]); const structured = parseStructuredAgentMessage(message.text); - const bootstrapDisplay = useMemo(() => getBootstrapPromptDisplay(message), [message]); - const bootstrapAcknowledgement = useMemo( - () => getBootstrapAcknowledgementDisplay(message), - [message] - ); + const bootstrapDisplay = getBootstrapPromptDisplay(message); + const bootstrapAcknowledgement = getBootstrapAcknowledgementDisplay(message); // Only flag agent messages as rate-limited, not user's own quotes const rateLimited = message.from !== 'user' && isRateLimitMessage(message.text); // Highlight messages containing API errors @@ -681,22 +686,16 @@ export const ActivityItem = memo( const isAuthError = isApiError && AUTH_ERROR_PATTERNS.some((p) => p.test(message.text)); // Never collapse rate limit messages as noise — they must be visible const noiseLabel = structured && !rateLimited ? getNoiseLabel(structured) : null; - const idleSemantic = useMemo(() => classifyIdleNotification(message), [message]); + const idleSemantic = classifyIdleNotification(message); const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null; const isManaged = collapseMode === 'managed'; const isExpanded = isManaged ? !isCollapsed : true; - const parsedCrossTeamPrefix = useMemo(() => parseCrossTeamPrefix(message.text), [message.text]); - const qualifiedRecipient = useMemo(() => parseQualifiedRecipient(message.to), [message.to]); - const crossTeamSentTarget = useMemo( - () => getCrossTeamSentTarget(message.to, teamName, localMemberNames), - [message.to, teamName, localMemberNames] - ); - const crossTeamSentMemberName = useMemo( - () => getCrossTeamSentMemberName(message.to), - [message.to] - ); + const parsedCrossTeamPrefix = parseCrossTeamPrefix(message.text); + const qualifiedRecipient = parseQualifiedRecipient(message.to); + const crossTeamSentTarget = getCrossTeamSentTarget(message.to, teamName, localMemberNames); + const crossTeamSentMemberName = getCrossTeamSentMemberName(message.to); const isCrossTeam = message.source === CROSS_TEAM_SOURCE || parsedCrossTeamPrefix !== null; const isCrossTeamSent = message.source === CROSS_TEAM_SENT_SOURCE || crossTeamSentTarget !== null; @@ -789,7 +788,7 @@ export const ActivityItem = memo( if (!isCrossTeamAny || !strippedText) return ''; const oneLine = strippedText.replace(/\n+/g, ' ').trim(); if (!oneLine) return ''; - return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine; + return oneLine; }, [isCrossTeamAny, strippedText]); const rawSummary = useMemo(() => { @@ -815,8 +814,7 @@ export const ActivityItem = memo( // Fallback: use the beginning of message text as preview for plain-text messages const plain = getSanitizedInboxMessageText(message).trim(); if (!plain) return ''; - const oneLine = plain.replace(/\n+/g, ' '); - return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine; + return plain.replace(/\n+/g, ' '); }, [ crossTeamPreview, isSlashCommandMessage, @@ -827,7 +825,47 @@ export const ActivityItem = memo( slashCommandMeta, structured, ]); - const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]); + const summaryText = extractMarkdownPlainText(rawSummary); + const compactPreviewMarkdown = useMemo(() => { + if (idleSemantic?.hasPeerSummary && idleSemantic.peerSummary) { + return idleSemantic.peerSummary; + } + if (isSlashCommandResult && message.commandOutput) { + return message.summary || getCommandOutputSummary(message.text); + } + if (isSlashCommandMessage && slashCommandMeta) { + if (slashCommandMeta.args) { + const oneLine = slashCommandMeta.args.replace(/\n+/g, ' ').trim(); + return `${slashCommandMeta.command} ${oneLine}`; + } + return slashCommandMeta.command; + } + if (crossTeamPreview) return crossTeamPreview; + + const formattedDisplayText = displayText?.trim() ?? ''; + if (formattedDisplayText) { + return formattedDisplayText; + } + + return summaryText || rawSummary; + }, [ + crossTeamPreview, + displayText, + idleSemantic, + isSlashCommandMessage, + isSlashCommandResult, + message, + message.commandOutput, + rawSummary, + slashCommandMeta, + summaryText, + ]); + const compactPreviewTooltipText = useMemo(() => { + const normalized = extractMarkdownPlainText(compactPreviewMarkdown) + .replace(/\n+/g, ' ') + .trim(); + return normalized || compactPreviewMarkdown; + }, [compactPreviewMarkdown]); const commentTaskRef = message.messageKind === 'task_comment_notification' ? (message.taskRefs?.[0] ?? null) : null; const commentTaskDisplayId = @@ -1187,13 +1225,109 @@ export const ActivityItem = memo( )}
    -
    - {summaryContent} + + + +
    + +
    +
    + + {compactPreviewTooltipText} + +
    +
    +
    + ) : !isExpanded ? ( +
    +
    + {isUnread ? ( + + ) : null} + {showChevron ? ( + + ) : null} + {crossTeamOrigin ? ( + + ) : null} + {senderBadge} + {!compactHeader && formattedRole && !isSlashCommandResult ? ( + + {formattedRole} + + ) : null} + {messageTypeBadge} + {leadSourceBadge} + {statusBadge} + {recipientBadge} +
    + + {timestamp} + + {onExpand && expandItemKey && ( + + )} +
    + + + +
    + +
    +
    + + {compactPreviewTooltipText} + +
    +
    ) : ( <> diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 31b5510b..d803c2de 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -48,12 +48,6 @@ interface ActivityTimelineProps { expandOverrides?: Set; /** Called when user toggles expand/collapse override on a specific message. */ onToggleExpandOverride?: (key: string) => void; - /** - * All session IDs belonging to this team (current + history). - * Used together with currentLeadSessionId to suppress only the reconnect boundary - * from the current live session back into the team's previous session history. - */ - teamSessionIds?: Set; /** Current lead session ID for the active team, if known. */ currentLeadSessionId?: string; /** Whether the current team is alive. */ @@ -281,7 +275,6 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ allCollapsed, expandOverrides, onToggleExpandOverride, - teamSessionIds, currentLeadSessionId, isTeamAlive, leadActivity, @@ -425,10 +418,13 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ setVisibleCount(Infinity); }; - const getItemSessionId = (item: TimelineItem): string | undefined => - item.type === 'lead-thoughts' - ? item.group.thoughts[0].leadSessionId - : item.message.leadSessionId; + const getItemSessionAnchorId = (item: TimelineItem): string | undefined => { + if (item.type === 'lead-thoughts') { + return item.group.thoughts[0]?.leadSessionId; + } + + return undefined; + }; // Pin the newest thought group (if first) so it stays at the top and doesn't jump. const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null; @@ -535,32 +531,29 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ // Session boundary separator (messages sorted desc — new on top) let sessionSeparator: React.JSX.Element | null = null; if (realIndex > 0) { - const prevSessionId = getItemSessionId(timelineItems[realIndex - 1]); - const currSessionId = getItemSessionId(item); - if (prevSessionId && currSessionId && prevSessionId !== currSessionId) { - // Suppress only the boundary between the current live session and the team's - // older session history. Older historical session boundaries should still render. - const isReconnectBoundary = - !!currentLeadSessionId && - teamSessionIds && - teamSessionIds.has(prevSessionId) && - teamSessionIds.has(currSessionId) && - (prevSessionId === currentLeadSessionId || currSessionId === currentLeadSessionId); - if (!isReconnectBoundary) { - sessionSeparator = ( -
    -
    - - New session - -
    -
    - ); + const currSessionId = getItemSessionAnchorId(item); + let prevSessionId: string | undefined; + for (let searchIndex = realIndex - 1; searchIndex >= 0; searchIndex -= 1) { + const candidateSessionId = getItemSessionAnchorId(timelineItems[searchIndex]); + if (candidateSessionId) { + prevSessionId = candidateSessionId; + break; } } + if (prevSessionId && currSessionId && prevSessionId !== currSessionId) { + sessionSeparator = ( +
    +
    + + New session + +
    +
    + ); + } } if (item.type === 'lead-thoughts') { diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 6943a68b..9ee1adc1 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -9,8 +9,14 @@ import { useState, } from 'react'; +import { CompactMarkdownPreview } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@renderer/components/ui/tooltip'; import { CARD_BG, CARD_BG_ZEBRA, @@ -26,12 +32,14 @@ import { areThoughtMessagesEquivalentForRender, } from '@renderer/utils/messageRenderEquality'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; +import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; import { isThoughtProtocolNoise } from '@shared/utils/inboxNoise'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react'; +import { buildThoughtDisplayContent } from './activityMarkdown'; import { AnimatedHeightReveal, ENTRY_REVEAL_ANIMATION_MS, @@ -582,18 +590,30 @@ const LeadThoughtsGroupRowComponent = ({ return calls.length > 0 ? calls : undefined; }, [thoughts]); - // Extract text preview for header: use newest thought's text, fallback through group - const headerTextPreview = useMemo(() => { + // Reuse the same markdown preprocessing as the expanded thought body. + const compactPreviewMarkdown = useMemo(() => { // Try newest first (most relevant), then scan for any text for (const t of thoughts) { if (t.text && t.text.trim()) { - const plain = extractMarkdownPlainText(t.text); - const firstLine = plain.split('\n').find((l) => l.trim().length > 0) ?? ''; - return firstLine.trim(); + const stripped = stripAgentBlocks(t.text).trim(); + if (stripped) { + return buildThoughtDisplayContent(t, memberColorMap, teamNames, { + preserveLineBreaks: false, + stripAgentOnlyBlocks: true, + }) + .replace(/\n+/g, ' ') + .trim(); + } } } - return null; - }, [thoughts]); + return totalToolSummary; + }, [memberColorMap, teamNames, thoughts, totalToolSummary]); + const compactPreviewTooltipText = useMemo(() => { + const normalized = extractMarkdownPlainText(compactPreviewMarkdown ?? '') + .replace(/\n+/g, ' ') + .trim(); + return normalized || compactPreviewMarkdown; + }, [compactPreviewMarkdown]); // Detect if any thought in this group is an API error const hasApiError = useMemo(() => thoughts.some((t) => isApiErrorMessage(t.text)), [thoughts]); @@ -756,7 +776,6 @@ const LeadThoughtsGroupRowComponent = ({ ? formatTime(oldest.timestamp) : `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`; const useCompactCollapsedHeader = compactHeader && !isBodyVisible; - const compactPreviewText = headerTextPreview ?? totalToolSummary; return ( @@ -829,14 +848,113 @@ const LeadThoughtsGroupRowComponent = ({ )}
    - {compactPreviewText ? ( -
    - {compactPreviewText} + {compactPreviewMarkdown ? ( + + + +
    + +
    +
    + + {compactPreviewTooltipText} + +
    +
    + ) : null} +
    + ) : !isBodyVisible ? ( +
    +
    + {canToggleBodyVisibility && !compactHeader ? ( + + ) : null} + {!compactHeader ? ( +
    + + +
    + ) : null} + + + {thoughts.length} thoughts + +
    + + {timestampLabel} + + {onExpand && expandItemKey && ( + + )}
    +
    + {compactPreviewMarkdown ? ( + + + +
    + +
    +
    + + {compactPreviewTooltipText} + +
    +
    ) : null}
    ) : ( @@ -871,26 +989,7 @@ const LeadThoughtsGroupRowComponent = ({ {thoughts.length} thoughts - {!isBodyVisible && headerTextPreview ? ( - - - - {headerTextPreview} - - - {totalToolSummary ? ( - - - - ) : null} - - ) : totalToolSummary ? ( + {totalToolSummary ? ( diff --git a/src/renderer/components/team/activity/ThoughtBodyContent.tsx b/src/renderer/components/team/activity/ThoughtBodyContent.tsx index d4b7c212..75985812 100644 --- a/src/renderer/components/team/activity/ThoughtBodyContent.tsx +++ b/src/renderer/components/team/activity/ThoughtBodyContent.tsx @@ -4,17 +4,16 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer import { CopyButton } from '@renderer/components/common/CopyButton'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { CARD_ICON_MUTED, CARD_TEXT_LIGHT } from '@renderer/constants/cssVariables'; -import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { areStringArraysEqual, areStringMapsEqual, areThoughtMessagesEquivalentForRender, } from '@renderer/utils/messageRenderEquality'; -import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; +import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; -import { stripTeammateMessageBlocks } from '@shared/utils/inboxNoise'; import { Reply } from 'lucide-react'; +import { buildThoughtDisplayContent } from './activityMarkdown'; import { formatTimeWithSec, ToolSummaryTooltipContent } from './LeadThoughtsGroup'; import type { InboxMessage } from '@shared/types'; @@ -42,17 +41,10 @@ export const ThoughtBodyContent = memo( onTeamClick, }: ThoughtBodyContentProps): JSX.Element { const displayContent = useMemo(() => { - // Strip leaked protocol XML ( blocks) before rendering - let text = stripTeammateMessageBlocks(thought.text).replace(/\n/g, ' \n'); - text = linkifyTaskIdsInMarkdown(text, thought.taskRefs); - if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) { - text = linkifyAllMentionsInMarkdown( - text, - (memberColorMap ?? new Map()) as Map, - teamNames - ); - } - return text; + return buildThoughtDisplayContent(thought, memberColorMap, teamNames, { + preserveLineBreaks: true, + stripAgentOnlyBlocks: true, + }); }, [thought.text, thought.taskRefs, memberColorMap, teamNames]); const handleTaskLinkClick = useCallback( diff --git a/src/renderer/components/team/activity/activityMarkdown.ts b/src/renderer/components/team/activity/activityMarkdown.ts new file mode 100644 index 00000000..02776c70 --- /dev/null +++ b/src/renderer/components/team/activity/activityMarkdown.ts @@ -0,0 +1,36 @@ +import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; +import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; +import { stripAgentBlocks } from '@shared/constants/agentBlocks'; +import { stripTeammateMessageBlocks } from '@shared/utils/inboxNoise'; + +import type { InboxMessage } from '@shared/types'; + +interface ThoughtDisplayContentOptions { + preserveLineBreaks?: boolean; + stripAgentOnlyBlocks?: boolean; +} + +export function buildThoughtDisplayContent( + thought: Pick, + memberColorMap?: ReadonlyMap, + teamNames: string[] = [], + options: ThoughtDisplayContentOptions = {} +): string { + const { preserveLineBreaks = true, stripAgentOnlyBlocks = false } = options; + let text = stripTeammateMessageBlocks(thought.text); + if (stripAgentOnlyBlocks) { + text = stripAgentBlocks(text); + } + if (preserveLineBreaks) { + text = text.replace(/\n/g, ' \n'); + } + text = linkifyTaskIdsInMarkdown(text, thought.taskRefs); + if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) { + text = linkifyAllMentionsInMarkdown( + text, + (memberColorMap ?? new Map()) as Map, + teamNames + ); + } + return text; +} diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index e2aaee78..f6216327 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -43,12 +43,9 @@ import { import { normalizePath } from '@renderer/utils/pathNormalize'; import { getTeamModelSelectionError, - normalizeTeamModelForUi, + normalizeExplicitTeamModelForUi, } from '@renderer/utils/teamModelAvailability'; -import { - getTeamProviderLabel as getCatalogTeamProviderLabel, - normalizeTeamModelForUi as normalizeCatalogTeamModelForUi, -} from '@renderer/utils/teamModelCatalog'; +import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; @@ -56,7 +53,9 @@ import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; import { AdvancedCliSection } from './AdvancedCliSection'; import { OptionalSettingsSection } from './OptionalSettingsSection'; import { ProjectPathSelector } from './ProjectPathSelector'; +import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey'; import { + buildReusableProviderPrepareModelResults, getProviderPrepareCachedSnapshot, type ProviderPrepareDiagnosticsModelResult, runProviderPrepareDiagnostics, @@ -111,7 +110,7 @@ function getStoredTeamModel(providerId: TeamProviderId): string { if (stored === null) { return providerId === 'anthropic' ? 'opus' : ''; } - return normalizeCatalogTeamModelForUi(providerId, stored === '__default__' ? '' : stored); + return normalizeExplicitTeamModelForUi(providerId, stored === '__default__' ? '' : stored); } function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): boolean { @@ -127,14 +126,6 @@ function getProviderLabel(providerId: TeamProviderId): string { return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic'; } -function buildPrepareModelCacheKey( - cwd: string, - providerId: TeamProviderId, - backendSummary: string | null | undefined -): string { - return `${cwd}::${providerId}::${backendSummary ?? ''}`; -} - function alignProvisioningChecks( existingChecks: ProvisioningProviderCheck[], providerIds: TeamProviderId[] @@ -408,7 +399,7 @@ export const CreateTeamDialog = ({ }, [advancedKey]); const setSelectedModel = (value: string): void => { - const normalizedValue = normalizeTeamModelForUi(selectedProviderId, value); + const normalizedValue = normalizeExplicitTeamModelForUi(selectedProviderId, value); setSelectedModelRaw(normalizedValue); localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, normalizedValue); }; @@ -646,7 +637,12 @@ export const CreateTeamDialog = ({ return Array.from(next); })(); const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; - const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary); + const cacheKey = buildProviderPrepareModelCacheKey({ + cwd: effectiveCwd, + providerId, + backendSummary, + limitContext, + }); const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {}; const cachedSnapshot = getProviderPrepareCachedSnapshot({ providerId, @@ -716,7 +712,7 @@ export const CreateTeamDialog = ({ } prepareModelResultsCacheRef.current.set( plan.cacheKey, - plan.prepResult.modelResultsById + buildReusableProviderPrepareModelResults(plan.prepResult.modelResultsById) ); checks = updateProviderCheck(checks, plan.providerId, { status: plan.prepResult.status, diff --git a/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx b/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx index 2445a6c6..c24bf8c7 100644 --- a/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useStore } from '@renderer/store'; +import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { buildTaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest'; import { ExternalLink } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -24,6 +25,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { closeGlobalTaskDetail, selectedTeamName, selectedTeamData, + selectedTeamMembers, selectedTeamLoading, selectedTeamError, selectTeam, @@ -36,6 +38,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { closeGlobalTaskDetail: s.closeGlobalTaskDetail, selectedTeamName: s.selectedTeamName, selectedTeamData: s.selectedTeamData, + selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName), selectedTeamLoading: s.selectedTeamLoading, selectedTeamError: s.selectedTeamError, selectTeam: s.selectTeam, @@ -94,8 +97,8 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { }, [globalTaskDetail, globalTasks, isFullTeamLoaded, selectedTeamData]); const activeMembers = useMemo( - () => (isFullTeamLoaded ? (selectedTeamData?.members.filter((m) => !m.removedAt) ?? []) : []), - [isFullTeamLoaded, selectedTeamData] + () => (isFullTeamLoaded ? selectedTeamMembers.filter((m) => !m.removedAt) : []), + [isFullTeamLoaded, selectedTeamMembers] ); const handleOpenTeam = useCallback((): void => { diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index ad9e4fd0..7574de53 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -36,7 +36,10 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; -import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; +import { + isTeamProvisioningActive, + selectResolvedMembersForTeamName, +} from '@renderer/store/slices/teamSlice'; import { isGeminiUiFrozen, normalizeCreateLaunchProviderForUi, @@ -45,12 +48,9 @@ import { normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; import { getTeamModelSelectionError, - normalizeTeamModelForUi, + normalizeExplicitTeamModelForUi, } from '@renderer/utils/teamModelAvailability'; -import { - getTeamProviderLabel as getCatalogTeamProviderLabel, - normalizeTeamModelForUi as normalizeCatalogTeamModelForUi, -} from '@renderer/utils/teamModelCatalog'; +import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { @@ -72,7 +72,9 @@ import { EffortLevelSelector } from './EffortLevelSelector'; import { resolveLaunchDialogPrefill } from './launchDialogPrefill'; import { OptionalSettingsSection } from './OptionalSettingsSection'; import { ProjectPathSelector } from './ProjectPathSelector'; +import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey'; import { + buildReusableProviderPrepareModelResults, getProviderPrepareCachedSnapshot, type ProviderPrepareDiagnosticsModelResult, runProviderPrepareDiagnostics, @@ -109,14 +111,6 @@ import type { UpdateSchedulePatch, } from '@shared/types'; -function buildPrepareModelCacheKey( - cwd: string, - providerId: TeamProviderId, - backendSummary: string | null | undefined -): string { - return `${cwd}::${providerId}::${backendSummary ?? ''}`; -} - function alignProvisioningChecks( existingChecks: ProvisioningProviderCheck[], providerIds: TeamProviderId[] @@ -195,7 +189,7 @@ function getStoredTeamModel(providerId: TeamProviderId): string { if (stored === null) { return providerId === 'anthropic' ? 'opus' : ''; } - return normalizeCatalogTeamModelForUi(providerId, stored === '__default__' ? '' : stored); + return normalizeExplicitTeamModelForUi(providerId, stored === '__default__' ? '' : stored); } function getProviderLabel(providerId: TeamProviderId): string { @@ -319,7 +313,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const [prepareWarnings, setPrepareWarnings] = useState([]); const [prepareChecks, setPrepareChecks] = useState([]); const prepareRequestSeqRef = useRef(0); - const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []); + const storeMembers = useStore((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName)); const previousLaunchParams = useStore((s) => effectiveTeamName ? s.launchParamsByTeam[effectiveTeamName] : undefined ); @@ -467,7 +461,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }; const setSelectedModel = (value: string): void => { - const normalizedValue = normalizeTeamModelForUi(selectedProviderId, value); + const normalizedValue = normalizeExplicitTeamModelForUi(selectedProviderId, value); setSelectedModelRaw(normalizedValue); localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, normalizedValue); }; @@ -932,7 +926,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const providerPlans = selectedMemberProviders.map((providerId) => { const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? []; const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; - const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary); + const cacheKey = buildProviderPrepareModelCacheKey({ + cwd: effectiveCwd, + providerId, + backendSummary, + limitContext, + }); const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {}; const cachedSnapshot = getProviderPrepareCachedSnapshot({ providerId, @@ -1000,7 +999,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen } else if (plan.prepResult.status === 'notes') { anyNotes = true; } - prepareModelResultsCacheRef.current.set(plan.cacheKey, plan.prepResult.modelResultsById); + prepareModelResultsCacheRef.current.set( + plan.cacheKey, + buildReusableProviderPrepareModelResults(plan.prepResult.modelResultsById) + ); checks = updateProviderCheck(checks, plan.providerId, { status: plan.prepResult.status, backendSummary: plan.backendSummary, diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx index 27bb8dfe..d4be245c 100644 --- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -135,7 +135,7 @@ function summarizeDetail( ) { return 'CLI binary could not be started'; } - if (lower.includes('preflight check for `claude -p` did not complete')) { + if (lower.includes('preflight check for `') && lower.includes('-p` did not complete')) { return 'CLI preflight did not complete'; } if (lower.includes('not authenticated') || lower.includes('not logged in')) { diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 83d2cedf..f89efcc5 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; +import { OngoingIndicator } from '@renderer/components/common/OngoingIndicator'; import { ImageLightbox, LightboxLockProvider, @@ -156,6 +157,9 @@ export const TaskDetailDialog = ({ const [logsRefreshing, setLogsRefreshing] = useState(false); const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false); + const [logsSectionOpen, setLogsSectionOpen] = useState(false); + const [taskLogActivityActive, setTaskLogActivityActive] = useState(false); + const [taskLogStreamCount, setTaskLogStreamCount] = useState(undefined); const [changesSectionOpen, setChangesSectionOpen] = useState(false); const [taskChangesFiles, setTaskChangesFiles] = useState(null); const [taskChangesLoading, setTaskChangesLoading] = useState(false); @@ -231,6 +235,9 @@ export const TaskDetailDialog = ({ setTaskChangesError(null); setLogsRefreshing(false); setExecutionPreviewOnline(false); + setLogsSectionOpen(false); + setTaskLogActivityActive(false); + setTaskLogStreamCount(undefined); }, [open, currentTask?.id]); const [replyTo, setReplyTo] = useState<{ @@ -1258,16 +1265,24 @@ export const TaskDetailDialog = ({ key={`task-logs:${currentTask.id}`} title="Task Logs" icon={} + badge={taskLogStreamCount} + headerExtra={ + taskLogActivityActive ? ( + + ) : null + } contentClassName="pl-2.5 overflow-visible" headerClassName="-mx-6 w-[calc(100%+3rem)]" headerContentClassName="pl-6" defaultOpen={false} + onOpenChange={setLogsSectionOpen} keepMounted >
    diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index 08b44e1e..55fae00d 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -27,6 +27,7 @@ import { getProviderScopedTeamModelLabel, getTeamModelLabel as getCatalogTeamModelLabel, getTeamProviderLabel as getCatalogTeamProviderLabel, + isAnthropicHaikuTeamModel, } from '@renderer/utils/teamModelCatalog'; import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults'; @@ -109,7 +110,7 @@ export function computeEffectiveTeamModel( const base = extractProviderScopedBaseModel(selectedModel, providerId); if (limitContext) return base || getAnthropicDefaultTeamModel(true); - if (base === 'haiku') return base; + if (isAnthropicHaikuTeamModel(base)) return base; return base ? `${base}[1m]` : getAnthropicDefaultTeamModel(limitContext); } @@ -141,7 +142,7 @@ export const TeamModelSelector: React.FC = ({ disableGeminiOption && isGeminiUiFrozen() && providerId === 'gemini' ? 'anthropic' : providerId; const defaultModelTooltip = useMemo(() => { if (effectiveProviderId === 'anthropic') { - return 'Uses the Claude team default model.\nResolves to Opus 1M, or Opus 200K when Limit context is enabled.'; + return 'Uses the Claude team default model.\nResolves to Opus 4.7 with 1M context, or Opus 4.7 with 200K context when Limit context is enabled.'; } return 'Uses the runtime default for the selected provider.'; }, [effectiveProviderId]); diff --git a/src/renderer/components/team/dialogs/launchDialogPrefill.ts b/src/renderer/components/team/dialogs/launchDialogPrefill.ts index 638097b1..242bfb42 100644 --- a/src/renderer/components/team/dialogs/launchDialogPrefill.ts +++ b/src/renderer/components/team/dialogs/launchDialogPrefill.ts @@ -1,5 +1,5 @@ import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze'; -import { normalizeTeamModelForUi as normalizeCatalogTeamModelForUi } from '@renderer/utils/teamModelCatalog'; +import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability'; import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; import { isLeadMember } from '@shared/utils/leadDetection'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; @@ -102,7 +102,7 @@ export function resolveLaunchDialogPrefill({ return { providerId, model: matchingModel - ? normalizeCatalogTeamModelForUi(providerId, matchingModel) + ? normalizeExplicitTeamModelForUi(providerId, matchingModel) : getStoredModel(providerId), effort, limitContext, diff --git a/src/renderer/components/team/dialogs/providerPrepareCacheKey.ts b/src/renderer/components/team/dialogs/providerPrepareCacheKey.ts new file mode 100644 index 00000000..e67e1efa --- /dev/null +++ b/src/renderer/components/team/dialogs/providerPrepareCacheKey.ts @@ -0,0 +1,20 @@ +import type { TeamProviderId } from '@shared/types'; + +export function buildProviderPrepareModelCacheKey({ + cwd, + providerId, + backendSummary, + limitContext, +}: { + cwd: string; + providerId: TeamProviderId; + backendSummary: string | null | undefined; + limitContext: boolean; +}): string { + return [ + cwd, + providerId, + backendSummary ?? '', + limitContext ? 'limit-context:on' : 'limit-context:off', + ].join('::'); +} diff --git a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts index cf1bb17d..626c5476 100644 --- a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts +++ b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts @@ -39,6 +39,14 @@ export interface ProviderPrepareDiagnosticsResult { modelResultsById: Record; } +export function buildReusableProviderPrepareModelResults( + modelResultsById: Record +): Record { + return Object.fromEntries( + Object.entries(modelResultsById).filter(([, result]) => result.status !== 'notes') + ); +} + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } @@ -187,6 +195,29 @@ function getResultReason(modelId: string, result: TeamProvisioningPrepareResult) return null; } +function getModelScopedEntries(modelId: string, result: TeamProvisioningPrepareResult): string[] { + const escapedModelId = escapeRegExp(modelId); + const scopedPattern = new RegExp(`^Selected model ${escapedModelId}\\b`, 'i'); + return [...(result.details ?? []), ...(result.warnings ?? []), result.message] + .map((entry) => entry?.trim() ?? '') + .filter(Boolean) + .filter((entry) => scopedPattern.test(entry)); +} + +function getScopedModelReason(modelId: string, entries: string[]): string | null { + for (const entry of entries) { + const stripped = stripSelectedModelPrefix(modelId, entry); + if (!stripped) { + continue; + } + const normalized = normalizeModelReason(stripped); + if (normalized) { + return normalized; + } + } + return null; +} + function buildModelFailureLine( providerId: TeamProviderId, modelId: string, @@ -201,6 +232,116 @@ function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string return [...(result.details ?? []), ...(result.warnings ?? [])]; } +function extractTimedOutPreflightProbeModelId(detail: string): string | null { + const trimmed = detail.trim(); + if (!trimmed) { + return null; + } + if ( + !trimmed.toLowerCase().includes('preflight check for `') || + !trimmed.toLowerCase().includes('-p` did not complete') + ) { + return null; + } + const match = /--model\s+([^\s]+)/i.exec(trimmed); + return match?.[1]?.trim() || null; +} + +function suppressSupersededRuntimeWarnings(params: { + runtimeDetailLines: string[]; + runtimeWarnings: string[]; + modelResultsById: Map; +}): { + runtimeDetailLines: string[]; + runtimeWarnings: string[]; +} { + const suppressedEntries = new Set(); + + for (const warning of params.runtimeWarnings) { + const probedModelId = extractTimedOutPreflightProbeModelId(warning); + if (!probedModelId) { + continue; + } + if (params.modelResultsById.get(probedModelId)?.status !== 'ready') { + continue; + } + suppressedEntries.add(warning); + } + + return { + runtimeDetailLines: params.runtimeDetailLines.filter( + (detail) => !suppressedEntries.has(detail) + ), + runtimeWarnings: params.runtimeWarnings.filter((warning) => !suppressedEntries.has(warning)), + }; +} + +function resolveModelResultFromBatch( + providerId: TeamProviderId, + modelId: string, + result: TeamProvisioningPrepareResult, + isOnlyModel: boolean +): ProviderPrepareDiagnosticsModelResult { + const modelScopedEntries = getModelScopedEntries(modelId, result); + const normalizedReason = + getScopedModelReason(modelId, modelScopedEntries) ?? + (isOnlyModel ? normalizeModelReason(result.message) : null); + + const hasVerifiedLine = modelScopedEntries.some((entry) => + /selected model .* verified for launch\./i.test(entry) + ); + if (hasVerifiedLine) { + return { + status: 'ready', + line: buildModelSuccessLine(providerId, modelId), + warningLine: null, + }; + } + + const hasUnavailableLine = modelScopedEntries.some((entry) => + /selected model .* is unavailable\./i.test(entry) + ); + if (hasUnavailableLine || (!result.ready && isOnlyModel)) { + return { + status: 'failed', + line: buildModelFailureLine(providerId, modelId, 'unavailable', normalizedReason), + warningLine: null, + }; + } + + const hasVerificationWarningLine = modelScopedEntries.some((entry) => + /selected model .* could not be verified\./i.test(entry) + ); + if (hasVerificationWarningLine || ((result.warnings?.length ?? 0) > 0 && isOnlyModel)) { + const line = buildModelFailureLine(providerId, modelId, 'check failed', normalizedReason); + return { + status: 'notes', + line, + warningLine: line, + }; + } + + if (result.ready) { + return { + status: 'ready', + line: buildModelSuccessLine(providerId, modelId), + warningLine: null, + }; + } + + const line = buildModelFailureLine( + providerId, + modelId, + 'check failed', + normalizedReason ?? 'Model verification failed' + ); + return { + status: 'notes', + line, + warningLine: line, + }; +} + export async function runProviderPrepareDiagnostics({ cwd, providerId, @@ -254,7 +395,7 @@ export async function runProviderPrepareDiagnostics({ const modelLines = new Map(); let completedCount = 0; let hasFailure = false; - let hasNotes = runtimeWarnings.length > 0; + let hasNotes = false; const modelWarnings: string[] = []; for (const modelId of orderedModelIds) { @@ -289,73 +430,64 @@ export async function runProviderPrepareDiagnostics({ emitProgress(); - await Promise.all( - orderedModelIds - .filter((modelId) => !modelResultsById.has(modelId)) - .map(async (modelId) => { - try { - const modelResult = await prepareProvisioning( - cwd, - providerId, - [providerId], - [modelId], - limitContext - ); - if (!modelResult.ready) { - hasFailure = true; - const line = buildModelFailureLine( - providerId, - modelId, - 'unavailable', - getResultReason(modelId, modelResult) ?? normalizeModelReason(modelResult.message) - ); - modelLines.set(modelId, line); - modelResultsById.set(modelId, { - status: 'failed', - line, - warningLine: null, - }); - } else if ((modelResult.warnings?.length ?? 0) > 0) { - hasNotes = true; - const reason = getResultReason(modelId, modelResult); - const line = buildModelFailureLine(providerId, modelId, 'check failed', reason); - modelLines.set(modelId, line); - modelWarnings.push(line); - modelResultsById.set(modelId, { - status: 'notes', - line, - warningLine: line, - }); - } else { - const line = buildModelSuccessLine(providerId, modelId); - modelLines.set(modelId, line); - modelResultsById.set(modelId, { - status: 'ready', - line, - warningLine: null, - }); - } - } catch (error) { - hasNotes = true; - const reason = normalizeModelReason( - error instanceof Error ? error.message.trim() : String(error).trim() - ); - const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null); - modelLines.set(modelId, line); - modelWarnings.push(line); - modelResultsById.set(modelId, { - status: 'notes', - line, - warningLine: line, - }); - } finally { - completedCount += 1; - emitProgress(); - } - }) - ); + const uncachedModelIds = orderedModelIds.filter((modelId) => !modelResultsById.has(modelId)); + if (uncachedModelIds.length > 0) { + try { + const batchedModelResult = await prepareProvisioning( + cwd, + providerId, + [providerId], + uncachedModelIds, + limitContext + ); - const dedupedWarnings = Array.from(new Set([...runtimeWarnings, ...modelWarnings])); + for (const modelId of uncachedModelIds) { + const resolvedResult = resolveModelResultFromBatch( + providerId, + modelId, + batchedModelResult, + uncachedModelIds.length === 1 + ); + modelLines.set(modelId, resolvedResult.line); + modelResultsById.set(modelId, resolvedResult); + if (resolvedResult.status === 'failed') { + hasFailure = true; + } else if (resolvedResult.status === 'notes') { + hasNotes = true; + } + if (resolvedResult.warningLine) { + modelWarnings.push(resolvedResult.warningLine); + } + } + } catch (error) { + hasNotes = true; + const reason = normalizeModelReason( + error instanceof Error ? error.message.trim() : String(error).trim() + ); + for (const modelId of uncachedModelIds) { + const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null); + modelLines.set(modelId, line); + modelWarnings.push(line); + modelResultsById.set(modelId, { + status: 'notes', + line, + warningLine: line, + }); + } + } finally { + completedCount += uncachedModelIds.length; + emitProgress(); + } + } + + const filteredRuntime = suppressSupersededRuntimeWarnings({ + runtimeDetailLines, + runtimeWarnings, + modelResultsById, + }); + const dedupedWarnings = Array.from( + new Set([...filteredRuntime.runtimeWarnings, ...modelWarnings]) + ); const selectedModelResultsById = Object.fromEntries( orderedModelIds .map((modelId) => [modelId, modelResultsById.get(modelId)] as const) @@ -365,9 +497,9 @@ export async function runProviderPrepareDiagnostics({ ); return { - status: hasFailure ? 'failed' : hasNotes ? 'notes' : 'ready', + status: hasFailure ? 'failed' : hasNotes || dedupedWarnings.length > 0 ? 'notes' : 'ready', details: [ - ...runtimeDetailLines, + ...filteredRuntime.runtimeDetailLines, ...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''), ], warnings: dedupedWarnings, diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx new file mode 100644 index 00000000..908c7c35 --- /dev/null +++ b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx @@ -0,0 +1,162 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@renderer/components/team/MemberBadge', () => ({ + MemberBadge: ({ name }: { name: string }) => React.createElement('span', null, name), +})); + +vi.mock('@renderer/components/team/UnreadCommentsBadge', () => ({ + UnreadCommentsBadge: () => null, +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + className, + onClick, + disabled, + 'aria-label': ariaLabel, + }: { + children: React.ReactNode; + className?: string; + onClick?: React.MouseEventHandler; + disabled?: boolean; + 'aria-label'?: string; + }) => + React.createElement( + 'button', + { className, onClick, disabled, 'aria-label': ariaLabel, type: 'button' }, + children + ), +})); + +vi.mock('@renderer/components/ui/popover', () => ({ + Popover: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + PopoverTrigger: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + PopoverContent: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), +})); + +vi.mock('@renderer/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + TooltipTrigger: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + TooltipContent: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), +})); + +vi.mock('@renderer/hooks/useTheme', () => ({ + useTheme: () => ({ isLight: false }), +})); + +vi.mock('@renderer/hooks/useUnreadCommentCount', () => ({ + useUnreadCommentCount: () => 0, +})); + +import { KanbanTaskCard } from './KanbanTaskCard'; + +import type { TeamTaskWithKanban } from '@shared/types/team'; + +const baseTask: TeamTaskWithKanban = { + id: 'task-1', + displayId: 'abcd1234', + subject: 'Implement safer onboarding flow', + owner: 'alice', + reviewer: '', + status: 'in_progress', + changePresence: 'unknown', + comments: [], + blockedBy: [], + blocks: [], + workIntervals: [], + historyEvents: [], + createdAt: '2026-04-18T10:00:00.000Z', + updatedAt: '2026-04-18T10:10:00.000Z', +} as unknown as TeamTaskWithKanban; + +const noop = (): void => undefined; + +describe('KanbanTaskCard change badge', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('does not render a No changes badge when changePresence is no_changes', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(KanbanTaskCard, { + task: { ...baseTask, changePresence: 'no_changes' }, + teamName: 'my-team', + columnId: 'in_progress', + hasReviewers: true, + compact: false, + taskMap: new Map(), + memberColorMap: new Map([['alice', 'blue']]), + onRequestReview: noop, + onApprove: noop, + onRequestChanges: noop, + onMoveBackToDone: noop, + onStartTask: noop, + onCompleteTask: noop, + onCancelTask: noop, + onViewChanges: noop, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).not.toContain('No changes'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('still renders the Changes action when changePresence is has_changes', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(KanbanTaskCard, { + task: { ...baseTask, changePresence: 'has_changes' }, + teamName: 'my-team', + columnId: 'in_progress', + hasReviewers: true, + compact: false, + taskMap: new Map(), + memberColorMap: new Map([['alice', 'blue']]), + onRequestReview: noop, + onApprove: noop, + onRequestChanges: noop, + onMoveBackToDone: noop, + onStartTask: noop, + onCompleteTask: noop, + onCancelTask: noop, + onViewChanges: noop, + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[aria-label="Changes"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 87933fb6..7c84488d 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -268,10 +268,6 @@ export const KanbanTaskCard = memo( onViewChanges!(task.id); }} /> - ) : canDisplay && task.changePresence === 'no_changes' ? ( - - No changes - ) : null} {onDeleteTask ? ( diff --git a/src/renderer/components/team/members/CurrentTaskIndicator.tsx b/src/renderer/components/team/members/CurrentTaskIndicator.tsx index e4df93f6..d168359a 100644 --- a/src/renderer/components/team/members/CurrentTaskIndicator.tsx +++ b/src/renderer/components/team/members/CurrentTaskIndicator.tsx @@ -6,7 +6,6 @@ import type { TeamTaskWithKanban } from '@shared/types'; interface CurrentTaskIndicatorProps { task: TeamTaskWithKanban; borderColor: string; - /** Max characters for the subject before truncating */ maxSubjectLength?: number; activityLabel?: string; onOpenTask?: () => void; @@ -19,21 +18,24 @@ interface CurrentTaskIndicatorProps { export const CurrentTaskIndicator = ({ task, borderColor, - maxSubjectLength = 36, + maxSubjectLength, activityLabel = 'working on', onOpenTask, }: CurrentTaskIndicatorProps): React.JSX.Element => { - const truncated = task.subject.length > maxSubjectLength; - const subjectText = truncated ? `${task.subject.slice(0, maxSubjectLength)}…` : task.subject; + const subjectText = + typeof maxSubjectLength === 'number' && + maxSubjectLength > 0 && + task.subject.length > maxSubjectLength + ? `${task.subject.slice(0, maxSubjectLength)}…` + : task.subject; return ( - <> +
    {activityLabel} - +
    ); }; diff --git a/src/renderer/components/team/members/LeadModelRow.tsx b/src/renderer/components/team/members/LeadModelRow.tsx index dfa4c323..9039a996 100644 --- a/src/renderer/components/team/members/LeadModelRow.tsx +++ b/src/renderer/components/team/members/LeadModelRow.tsx @@ -13,6 +13,7 @@ import { Label } from '@renderer/components/ui/label'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { cn } from '@renderer/lib/utils'; +import { isAnthropicHaikuTeamModel } from '@renderer/utils/teamModelCatalog'; import { getMemberColorByName } from '@shared/constants/memberColors'; import { AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react'; @@ -151,7 +152,7 @@ export const LeadModelRow = ({ id="lead-limit-context" checked={limitContext} onCheckedChange={onLimitContextChange} - disabled={model === 'haiku'} + disabled={isAnthropicHaikuTeamModel(model)} /> ) : null}
    diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 3937e09f..5ea2e0fe 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -1,6 +1,6 @@ import { Badge } from '@renderer/components/ui/badge'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; -import { getTeamColorSet, getThemedBadge, scaleColorAlpha } from '@renderer/constants/teamColors'; +import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { @@ -101,6 +101,7 @@ export const MemberCard = ({ const completed = taskCounts?.completed ?? 0; const totalTasks = pending + inProgress + completed; const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0; + const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); const activityTask = currentTask ?? reviewTask ?? null; const activityTitle = currentTask ? `Current task: #${deriveTaskDisplayId(currentTask.id)}` @@ -111,7 +112,8 @@ export const MemberCard = ({ !isRemoved && presenceLabel === 'starting' && spawnLaunchState !== 'failed_to_start' && - !activityTask; + !activityTask && + !runtimeSummary; const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask; const showRuntimeAdvisoryBadge = !isRemoved && @@ -119,18 +121,14 @@ export const MemberCard = ({ !showStartingBadge && spawnStatus !== 'error' && (Boolean(activityTask) || !isAwaitingReply); - const cardTint = scaleColorAlpha(getThemedBadge(colors, isLight), 0.5); return (
    - {member.name} +
    + {member.name} +
    -
    +
    {displayMemberName(member.name)} @@ -209,20 +215,16 @@ export const MemberCard = ({ style={{ backgroundColor: 'var(--skeleton-base)' }} />
    - ) : runtimeSummary ? ( -
    - {runtimeSummary} + ) : runtimeSummary || roleLabel ? ( +
    + {runtimeSummary ? {runtimeSummary} : null} + {runtimeSummary && roleLabel ? ( + + ) : null} + {roleLabel ? {roleLabel} : null}
    ) : null}
    - {(() => { - const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); - return roleLabel ? ( - - {roleLabel} - - ) : null; - })()} {showStartingBadge ? ( void; onSendMessage: () => void; onAssignTask: () => void; onTaskClick: (task: TeamTaskWithKanban) => void; onRemoveMember?: () => void; + onRestartMember?: (memberName: string) => Promise | void; onUpdateRole?: (memberName: string, role: string | undefined) => Promise | void; updatingRole?: boolean; onViewMemberChanges?: (memberName: string, filePath?: string) => void; @@ -57,7 +68,6 @@ export const MemberDetailDialog = ({ teamName, members, tasks, - messages, initialTab = 'tasks', initialActivityFilter = 'all', isTeamAlive, @@ -65,11 +75,14 @@ export const MemberDetailDialog = ({ isLaunchSettling, leadActivity, spawnEntry, + runtimeEntry, + launchParams, onClose, onSendMessage, onAssignTask, onTaskClick, onRemoveMember, + onRestartMember, onUpdateRole, updatingRole, onViewMemberChanges, @@ -78,33 +91,20 @@ export const MemberDetailDialog = ({ () => (member ? tasks.filter((t) => t.owner === member.name) : []), [tasks, member] ); - - const seedMemberMessages = useMemo( - () => (member ? messages.filter((m) => m.from === member.name || m.to === member.name) : []), - [messages, member] + const memberMessages = useStore((state) => + selectMemberMessagesForTeamMember(state, teamName, member?.name ?? null) ); - const memberMessages = seedMemberMessages; const memberActivityCount = useMemo(() => { if (!member) { return 0; } - const leadId = `lead:${teamName}`; - const leadName = - members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`; - const ownerNodeId = - member.name === leadName ? leadId : buildGraphMemberNodeIdForMember(teamName, member); - const entries = buildInlineActivityEntries({ - data: { - members, - tasks, - messages: memberMessages, - }, + return buildMemberActivityEntries({ teamName, - leadId, - leadName, - ownerNodeIds: new Set([leadId, ownerNodeId]), - }); - return (entries.get(ownerNodeId) ?? []).length; + memberName: member.name, + members, + tasks, + messages: memberMessages, + }).length; }, [member, memberMessages, members, tasks, teamName]); const inProgressTasks = useMemo( @@ -118,12 +118,24 @@ export const MemberDetailDialog = ({ ); const [activeTab, setActiveTab] = useState(initialTab); + const [restarting, setRestarting] = useState(false); + const [restartError, setRestartError] = useState(null); + + const runtimeSummary = useMemo( + () => + member + ? resolveMemberRuntimeSummary(member, launchParams, spawnEntry, runtimeEntry) + : undefined, + [launchParams, member, runtimeEntry, spawnEntry] + ); useEffect(() => { if (!open || !member) { return; } setActiveTab(initialTab); + setRestartError(null); + setRestarting(false); }, [initialTab, member, open]); const { @@ -143,6 +155,7 @@ export const MemberDetailDialog = ({ + {restartError ? ( +
    {restartError}
    + ) : runtimeEntry?.pid ? ( +
    + PID {runtimeEntry.pid} +
    + ) : ( +
    + )} {member.removedAt ? ( Removed {new Date(member.removedAt).toLocaleDateString()} ) : ( <> + {onRestartMember && + !isLeadMember(member) && + (isTeamAlive || isTeamProvisioning) && + runtimeEntry?.restartable !== false && ( + + )}
    diff --git a/src/renderer/components/team/members/MemberExecutionLog.tsx b/src/renderer/components/team/members/MemberExecutionLog.tsx index 40a498a5..96d2f727 100644 --- a/src/renderer/components/team/members/MemberExecutionLog.tsx +++ b/src/renderer/components/team/members/MemberExecutionLog.tsx @@ -28,10 +28,7 @@ export const MemberExecutionLog = ({ const conversation = useMemo(() => transformChunksToConversation(chunks, [], false), [chunks]); // Show newest groups first — most recent activity is most relevant in execution logs. - const orderedItems = useMemo( - () => [...conversation.items].reverse(), - [conversation.items] - ); + const orderedItems = useMemo(() => [...conversation.items].reverse(), [conversation.items]); // Store collapsed groups instead of expanded: by default, everything is expanded. // This avoids resetting state in an effect when conversation changes. @@ -179,6 +176,8 @@ const AIExecutionGroup = ({ return enhanceAIGroup({ ...group, processes: filteredProcesses }); }, [group, memberName]); const hasToggleContent = enhanced.displayItems.length > 0; + const visibleLastOutput = + enhanced.lastOutput?.type === 'tool_result' ? null : enhanced.lastOutput; return (
    @@ -219,7 +218,7 @@ const AIExecutionGroup = ({
    ) : null} - +
    ); }; diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx index c43e7d31..14c3f851 100644 --- a/src/renderer/components/team/members/MemberHoverCard.tsx +++ b/src/renderer/components/team/members/MemberHoverCard.tsx @@ -8,7 +8,13 @@ import { } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; -import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice'; +import { + getCurrentProvisioningProgressForTeam, + selectResolvedMemberForTeamName, + selectTeamIsAliveForName, + selectTeamMemberSnapshotsForName, + selectTeamTasksForName, +} from '@renderer/store/slices/teamSlice'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, @@ -17,6 +23,7 @@ import { } from '@renderer/utils/memberHelpers'; import { isLeadMember } from '@shared/utils/leadDetection'; import { ExternalLink } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps'; @@ -38,7 +45,7 @@ interface MemberHoverCardProps { /** * Wraps children in a HoverCard that shows member info on hover. - * Reads member data from the store (selectedTeamData.members). + * Reads member data from the team snapshot + resolved member selectors. * Falls back to a simple wrapper when member data is unavailable. */ export const MemberHoverCard = ({ @@ -53,20 +60,22 @@ export const MemberHoverCard = ({ const effectiveTeamName = teamName ?? selectedTeamName; const { member, - members, + teamMembers, + tasks, isTeamAlive, progress, memberSpawnSnapshot, memberSpawnStatuses, spawnEntry, leadActivity, - } = useStore((s) => { - const isSelectedTeam = Boolean(effectiveTeamName && s.selectedTeamName === effectiveTeamName); - const selectedTeamData = isSelectedTeam ? s.selectedTeamData : null; - return { - member: selectedTeamData?.members.find((m) => m.name === name) ?? null, - members: selectedTeamData?.members ?? [], - isTeamAlive: selectedTeamData?.isAlive, + } = useStore( + useShallow((s) => ({ + member: effectiveTeamName + ? selectResolvedMemberForTeamName(s, effectiveTeamName, name) + : null, + teamMembers: effectiveTeamName ? selectTeamMemberSnapshotsForName(s, effectiveTeamName) : [], + tasks: effectiveTeamName ? selectTeamTasksForName(s, effectiveTeamName) : [], + isTeamAlive: effectiveTeamName ? selectTeamIsAliveForName(s, effectiveTeamName) : undefined, progress: effectiveTeamName ? getCurrentProvisioningProgressForTeam(s, effectiveTeamName) : null, @@ -80,21 +89,16 @@ export const MemberHoverCard = ({ ? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name] : undefined, leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined, - }; - }); - const openMemberProfile = useStore((s) => s.openMemberProfile); - const tasks = useStore((s) => - effectiveTeamName && s.selectedTeamName === effectiveTeamName - ? s.selectedTeamData?.tasks - : undefined + })) ); + const openMemberProfile = useStore((s) => s.openMemberProfile); if (!member) { return <>{children}; } const launchJoinMilestones = getLaunchJoinMilestonesFromMembers({ - members, + members: teamMembers, memberSpawnStatuses, memberSpawnSnapshot, }); @@ -117,10 +121,9 @@ export const MemberHoverCard = ({ const presenceLabel = launchPresentation.presenceLabel; const dotClass = launchPresentation.dotClass; const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle; - const currentTask: TeamTaskWithKanban | null = - member.currentTaskId && tasks - ? (tasks.find((t) => t.id === member.currentTaskId) ?? null) - : null; + const currentTask: TeamTaskWithKanban | null = member.currentTaskId + ? (tasks.find((t) => t.id === member.currentTaskId) ?? null) + : null; const reviewTask: TeamTaskWithKanban | null = tasks ? (tasks.find( (task) => diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 341a19c1..ece0043c 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -12,6 +12,7 @@ import type { LeadActivityState, MemberSpawnStatusEntry, ResolvedTeamMember, + TeamAgentRuntimeEntry, TeamTaskWithKanban, } from '@shared/types'; @@ -21,6 +22,7 @@ interface MemberListProps { taskMap?: Map; pendingRepliesByMember?: Record; memberSpawnStatuses?: Map; + memberRuntimeEntries?: Map; isLaunchSettling?: boolean; isTeamAlive?: boolean; isTeamProvisioning?: boolean; @@ -169,6 +171,30 @@ function areLaunchParamsEquivalent( ); } +function areMemberRuntimeEntriesEquivalent( + left: Map | undefined, + right: Map | undefined +): boolean { + if (left === right) return true; + if (!left || !right) return left === right; + if (left.size !== right.size) return false; + for (const [key, leftEntry] of left) { + const rightEntry = right.get(key); + if ( + leftEntry.memberName !== rightEntry?.memberName || + leftEntry.alive !== rightEntry?.alive || + leftEntry.restartable !== rightEntry?.restartable || + leftEntry.backendType !== rightEntry?.backendType || + leftEntry.pid !== rightEntry?.pid || + leftEntry.runtimeModel !== rightEntry?.runtimeModel || + leftEntry.rssBytes !== rightEntry?.rssBytes + ) { + return false; + } + } + return true; +} + function areMemberListPropsEqual( prev: Readonly, next: Readonly @@ -179,6 +205,7 @@ function areMemberListPropsEqual( areMemberTaskMapsEquivalent(prev.taskMap, next.taskMap) && arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) && areMemberSpawnStatusesEquivalent(prev.memberSpawnStatuses, next.memberSpawnStatuses) && + areMemberRuntimeEntriesEquivalent(prev.memberRuntimeEntries, next.memberRuntimeEntries) && prev.isLaunchSettling === next.isLaunchSettling && prev.isTeamAlive === next.isTeamAlive && prev.isTeamProvisioning === next.isTeamProvisioning && @@ -193,6 +220,7 @@ export const MemberList = memo(function MemberList({ taskMap, pendingRepliesByMember, memberSpawnStatuses, + memberRuntimeEntries, isLaunchSettling, isTeamAlive, isTeamProvisioning, @@ -240,9 +268,10 @@ export const MemberList = memo(function MemberList({ const buildRuntimeSummary = useCallback( ( member: ResolvedTeamMember, - spawnEntry: MemberSpawnStatusEntry | undefined + spawnEntry: MemberSpawnStatusEntry | undefined, + runtimeEntry: TeamAgentRuntimeEntry | undefined ): string | undefined => { - return resolveMemberRuntimeSummary(member, launchParams, spawnEntry); + return resolveMemberRuntimeSummary(member, launchParams, spawnEntry, runtimeEntry); }, [launchParams] ); @@ -275,6 +304,7 @@ export const MemberList = memo(function MemberList({ reviewCandidate && reviewCandidate.id !== member.currentTaskId ? reviewCandidate : null; const awaitingReply = isTeamAlive !== false && Boolean(pendingRepliesByMember?.[member.name]); const spawnEntry = memberSpawnStatuses?.get(member.name); + const runtimeEntry = memberRuntimeEntries?.get(member.name); return ( previewVisibleCount; const previewOnline = useMemo((): boolean => { + if (!enabled) return false; if (!previewLog) return false; // Determine the most recent activity timestamp from preview messages const newest = previewMessages[0]; @@ -398,7 +401,7 @@ export const MemberLogsTab = ({ if (taskStatus === 'in_progress') return ageMs <= 60_000; // Completed/other tasks — shorter window return ageMs <= 15_000; - }, [previewLog, previewMessages, taskStatus]); + }, [enabled, previewLog, previewMessages, taskStatus]); const expandedLogSummary = useMemo(() => { if (!expandedId) return null; @@ -443,6 +446,17 @@ export const MemberLogsTab = ({ useEffect(() => { let cancelled = false; const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress'; + if (!enabled) { + return () => { + cancelled = true; + refreshCountRef.current = 0; + if (refreshHideTimeoutRef.current) { + clearTimeout(refreshHideTimeoutRef.current); + refreshHideTimeoutRef.current = null; + } + setRefreshing(false); + }; + } const load = async (): Promise => { let didBeginRefreshing = false; @@ -505,7 +519,17 @@ export const MemberLogsTab = ({ setRefreshing(false); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey + taskSince drive refresh; deps intentionally minimal to avoid refetch loops - }, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey, taskSince, isTabActive]); + }, [ + enabled, + teamName, + memberName, + taskId, + taskOwner, + taskStatus, + intervalsKey, + taskSince, + isTabActive, + ]); const fetchDetailForLog = useCallback( async ( @@ -532,6 +556,9 @@ export const MemberLogsTab = ({ ); useEffect(() => { + if (!enabled) { + return; + } if (!shouldShowPreview) { setPreviewChunks(null); return; @@ -557,9 +584,10 @@ export const MemberLogsTab = ({ return () => { cancelled = true; }; - }, [fetchDetailForLog, previewLog, shouldShowPreview, intervalsKey]); + }, [enabled, fetchDetailForLog, previewLog, shouldShowPreview, intervalsKey]); useEffect(() => { + if (!enabled) return; if (!shouldShowPreview) return; if (!previewLog) return; @@ -594,9 +622,11 @@ export const MemberLogsTab = ({ taskStatus, intervalsKey, isTabActive, + enabled, ]); useEffect(() => { + if (!enabled) return; const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; if (!expandedLogSummary) return; if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return; @@ -634,6 +664,7 @@ export const MemberLogsTab = ({ taskStatus, intervalsKey, isTabActive, + enabled, ]); const handleExpand = useCallback( diff --git a/src/renderer/components/team/members/MemberMessagesTab.tsx b/src/renderer/components/team/members/MemberMessagesTab.tsx index 3fc87f1c..6e5c9eac 100644 --- a/src/renderer/components/team/members/MemberMessagesTab.tsx +++ b/src/renderer/components/team/members/MemberMessagesTab.tsx @@ -1,7 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { buildInlineActivityEntries } from '@features/agent-graph/renderer'; -import { api } from '@renderer/api'; import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; import { buildMessageContext, @@ -10,17 +8,18 @@ import { import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog'; import { Button } from '@renderer/components/ui/button'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; -import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; -import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; +import { useStore } from '@renderer/store'; +import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; -import { isLeadMember } from '@shared/utils/leadDetection'; +import { useShallow } from 'zustand/react/shallow'; + +import { buildMemberActivityEntries } from './memberActivityEntries'; import type { MemberActivityFilter } from './memberDetailTypes'; import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup'; -import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; +import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; interface MemberMessagesTabProps { - messages: InboxMessage[]; teamName: string; memberName: string; members: ResolvedTeamMember[]; @@ -31,7 +30,6 @@ interface MemberMessagesTabProps { } const MAX_MESSAGES = 100; -const MEMBER_MESSAGES_PAGE_SIZE = 50; const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[] = [ { value: 'all', label: 'All' }, { value: 'messages', label: 'Messages' }, @@ -39,7 +37,6 @@ const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[] ]; export const MemberMessagesTab = ({ - messages, teamName, memberName, members, @@ -48,20 +45,16 @@ export const MemberMessagesTab = ({ onCreateTask, onTaskClick, }: MemberMessagesTabProps): React.JSX.Element => { - const [pagedMessages, setPagedMessages] = useState([]); - const [nextCursor, setNextCursor] = useState(null); - const [hasMore, setHasMore] = useState(false); - const [loading, setLoading] = useState(false); const [activityFilter, setActivityFilter] = useState(initialFilter); const [expandedItem, setExpandedItem] = useState(null); - const { readSet } = useTeamMessagesRead(teamName); - const leadId = `lead:${teamName}`; - const leadName = useMemo( - () => members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`, - [members, teamName] + const { messages, messagesState, loadOlderTeamMessages } = useStore( + useShallow((s) => ({ + messages: selectMemberMessagesForTeamMember(s, teamName, memberName), + messagesState: teamName ? s.teamMessagesByName[teamName] : undefined, + loadOlderTeamMessages: s.loadOlderTeamMessages, + })) ); - const ownerNodeId = memberName === leadName ? leadId : `member:${teamName}:${memberName}`; - const ownerNodeIds = useMemo(() => new Set([leadId, ownerNodeId]), [leadId, ownerNodeId]); + const { readSet } = useTeamMessagesRead(teamName); const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]); const messageContext = useMemo(() => buildMessageContext(members), [members]); @@ -69,106 +62,45 @@ export const MemberMessagesTab = ({ setActivityFilter(initialFilter); }, [initialFilter, memberName, teamName]); - useEffect(() => { - let cancelled = false; - setPagedMessages([]); - setNextCursor(null); - setHasMore(false); - setLoading(true); - - void (async () => { - try { - const page = await api.teams.getMessagesPage(teamName, { - limit: MEMBER_MESSAGES_PAGE_SIZE, - }); - if (cancelled) return; - const memberPageMessages = page.messages.filter( - (message) => message.from === memberName || message.to === memberName - ); - setPagedMessages(memberPageMessages); - setNextCursor(page.nextCursor); - setHasMore(page.hasMore); - } catch { - if (!cancelled) { - setPagedMessages([]); - setNextCursor(null); - setHasMore(false); - } - } finally { - if (!cancelled) setLoading(false); - } - })(); - - return () => { - cancelled = true; - }; - }, [teamName, memberName]); - const loadOlderMessages = useCallback(async () => { - if (!nextCursor || loading) return; - setLoading(true); - try { - const page = await api.teams.getMessagesPage(teamName, { - beforeTimestamp: nextCursor, - limit: MEMBER_MESSAGES_PAGE_SIZE, - }); - const memberPageMessages = page.messages.filter( - (message) => message.from === memberName || message.to === memberName - ); - setPagedMessages((prev) => mergeTeamMessages(prev, memberPageMessages)); - setNextCursor(page.nextCursor); - setHasMore(page.hasMore); - } catch { - // best-effort - } finally { - setLoading(false); + if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) { + return; } - }, [teamName, memberName, nextCursor, loading]); + await loadOlderTeamMessages(teamName); + }, [loadOlderTeamMessages, messagesState, teamName]); - const effectiveMessages = useMemo( - () => mergeTeamMessages(messages, pagedMessages), - [messages, pagedMessages] - ); - - const filteredMessages = useMemo( - () => - filterTeamMessages(effectiveMessages, { - timeWindow: null, - filter: { from: new Set(), to: new Set(), showNoise: true }, - searchQuery: '', - }), - [effectiveMessages] - ); + const loading = (messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false); + const loadingOlderMessages = messagesState?.loadingOlder ?? false; + const hasMore = messagesState?.hasMore ?? false; const activityEntries = useMemo(() => { - const entriesByOwner = buildInlineActivityEntries({ - data: { - members, - tasks, - messages: filteredMessages, - }, + return buildMemberActivityEntries({ teamName, - leadId, - leadName, - ownerNodeIds, + memberName, + members, + tasks, + messages, }); - return (entriesByOwner.get(ownerNodeId) ?? []).slice(0, MAX_MESSAGES); - }, [filteredMessages, leadId, leadName, members, ownerNodeId, ownerNodeIds, tasks, teamName]); + }, [memberName, members, messages, tasks, teamName]); + const visibleActivityEntries = useMemo( + () => activityEntries.slice(0, MAX_MESSAGES), + [activityEntries] + ); const displayEntries = useMemo(() => { switch (activityFilter) { case 'messages': - return activityEntries.filter( + return visibleActivityEntries.filter( (entry) => entry.message.messageKind !== 'task_comment_notification' ); case 'comments': - return activityEntries.filter( + return visibleActivityEntries.filter( (entry) => entry.message.messageKind === 'task_comment_notification' ); default: - return activityEntries; + return visibleActivityEntries; } - }, [activityEntries, activityFilter]); + }, [activityFilter, visibleActivityEntries]); const expandedItemsByKey = useMemo(() => { const items = new Map(); @@ -198,7 +130,8 @@ export const MemberMessagesTab = ({ [onTaskClick, taskMap, tasks] ); - const emptyStateText = loading + const initialPageLoading = loading && activityEntries.length === 0; + const emptyStateText = initialPageLoading ? 'Loading activity...' : activityFilter === 'comments' ? 'No comments for this member' @@ -206,9 +139,10 @@ export const MemberMessagesTab = ({ ? hasMore ? 'No loaded messages for this member yet' : 'No messages with this member' - : 'No activity with this member'; - const canLoadOlderMessages = - hasMore && activityFilter !== 'comments' && displayEntries.length > 0; + : hasMore + ? 'No loaded activity for this member yet' + : 'No activity with this member'; + const canLoadOlderMessages = hasMore && activityFilter !== 'comments'; return (
    @@ -289,10 +223,11 @@ export const MemberMessagesTab = ({ variant="ghost" size="sm" className="text-xs" - disabled={loading} + aria-busy={loadingOlderMessages} + disabled={loadingOlderMessages} onClick={() => void loadOlderMessages()} > - {loading ? 'Loading...' : 'Load older messages'} + Load older messages
    )} diff --git a/src/renderer/components/team/members/memberActivityEntries.ts b/src/renderer/components/team/members/memberActivityEntries.ts new file mode 100644 index 00000000..d3e1edf1 --- /dev/null +++ b/src/renderer/components/team/members/memberActivityEntries.ts @@ -0,0 +1,42 @@ +import { buildInlineActivityEntries } from '@features/agent-graph/renderer'; +import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; +import { isLeadMember } from '@shared/utils/leadDetection'; + +import type { InlineActivityEntry } from '@features/agent-graph/renderer'; +import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; + +export function buildMemberActivityEntries({ + teamName, + memberName, + members, + tasks, + messages, +}: { + teamName: string; + memberName: string; + members: ResolvedTeamMember[]; + tasks: TeamTaskWithKanban[]; + messages: InboxMessage[]; +}): InlineActivityEntry[] { + const filteredMessages = filterTeamMessages(messages, { + timeWindow: null, + filter: { from: new Set(), to: new Set(), showNoise: true }, + searchQuery: '', + }); + const leadId = `lead:${teamName}`; + const leadName = members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`; + const ownerNodeId = memberName === leadName ? leadId : `member:${teamName}:${memberName}`; + const ownerNodeIds = new Set([leadId, ownerNodeId]); + const entriesByOwner = buildInlineActivityEntries({ + data: { + members, + tasks, + messages: filteredMessages, + }, + teamName, + leadId, + leadName, + ownerNodeIds, + }); + return entriesByOwner.get(ownerNodeId) ?? []; +} diff --git a/src/renderer/components/team/members/membersEditorUtils.ts b/src/renderer/components/team/members/membersEditorUtils.ts index 599ebd15..b69843ad 100644 --- a/src/renderer/components/team/members/membersEditorUtils.ts +++ b/src/renderer/components/team/members/membersEditorUtils.ts @@ -2,9 +2,7 @@ import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRole import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; -import { normalizeTeamModelForUi as normalizeCatalogTeamModelForUi } from '@renderer/utils/teamModelCatalog'; -import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; +import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability'; import { isLeadMember } from '@shared/utils/leadDetection'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; @@ -34,7 +32,6 @@ function newDraftId(): string { export function createMemberDraft(initial?: Partial): MemberDraft { const providerId = initial?.providerId; - const normalizedModel = extractProviderScopedBaseModel(initial?.model ?? '', providerId) ?? ''; return { id: initial?.id ?? newDraftId(), name: initial?.name ?? '', @@ -42,7 +39,7 @@ export function createMemberDraft(initial?: Partial): MemberDraft { customRole: initial?.customRole ?? '', workflow: initial?.workflow, providerId, - model: normalizeCatalogTeamModelForUi(providerId, normalizedModel), + model: normalizeExplicitTeamModelForUi(providerId, initial?.model ?? ''), effort: initial?.effort, removedAt: initial?.removedAt, }; @@ -221,7 +218,7 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning } const model = member.model?.trim(); if (model) { - result.model = normalizeTeamModelForUi(providerId, model); + result.model = normalizeExplicitTeamModelForUi(providerId, model); } const effort = normalizeDraftEffort(member.effort); if (effort) { diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 5638726c..8a883579 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -1,7 +1,6 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Sheet, type SheetRef } from 'react-modal-sheet'; -import { api } from '@renderer/api'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; @@ -9,11 +8,10 @@ import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMe import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useStore } from '@renderer/store'; -import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; +import { selectTeamMessages } from '@renderer/store/slices/teamSlice'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics'; -import { createLogger } from '@shared/utils/logger'; import { CheckCheck, ChevronsDownUp, @@ -53,9 +51,6 @@ interface TimeWindow { end: number; } -const logger = createLogger('Component:MessagesPanel'); -const MESSAGES_PANEL_FILTER_WARN_MS = 8; -const MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS = 6; const BOTTOM_SHEET_HEADER_HEIGHT = 40; const BOTTOM_SHEET_COLLAPSED_SNAP_INDEX = 1; const BOTTOM_SHEET_COMPOSER_SNAP_INDEX = 2; @@ -70,8 +65,6 @@ interface MessagesPanelProps { members: ResolvedTeamMember[]; /** All team tasks. */ tasks: TeamTaskWithKanban[]; - /** All raw messages from team data. */ - messages: InboxMessage[]; /** Whether the team is alive. */ isTeamAlive?: boolean; /** Live lead activity status for the current team. */ @@ -80,8 +73,6 @@ interface MessagesPanelProps { leadContextUpdatedAt?: string; /** Time window for filtering. */ timeWindow: TimeWindow | null; - /** Team session IDs for timeline. */ - teamSessionIds: Set; /** Current lead session ID. */ currentLeadSessionId?: string; /** Pending replies tracker (shared with parent for MemberList). */ @@ -109,12 +100,10 @@ export const MessagesPanel = memo(function MessagesPanel({ mountPoint, members, tasks, - messages, isTeamAlive, leadActivity, leadContextUpdatedAt, timeWindow, - teamSessionIds, currentLeadSessionId, pendingRepliesByMember, onPendingReplyChange, @@ -133,6 +122,9 @@ export const MessagesPanel = memo(function MessagesPanel({ lastSendMessageResult, teams, openTeamTab, + messages, + messagesState, + loadOlderTeamMessages, } = useStore( useShallow((s) => ({ sendTeamMessage: s.sendTeamMessage, @@ -142,79 +134,24 @@ export const MessagesPanel = memo(function MessagesPanel({ lastSendMessageResult: s.lastSendMessageResult, teams: s.teams, openTeamTab: s.openTeamTab, + messages: selectTeamMessages(s, teamName), + messagesState: teamName ? s.teamMessagesByName[teamName] : undefined, + loadOlderTeamMessages: s.loadOlderTeamMessages, })) ); - // ── Paginated message fetching ── - // Messages are now fetched via getMessagesPage API instead of coming - // from getTeamData. The `messages` prop is used as initial seed if non-empty. - const PAGE_SIZE = 50; - const [fetchedMessages, setFetchedMessages] = useState([]); - const [nextCursor, setNextCursor] = useState(null); - const [hasMore, setHasMore] = useState(false); - const [messagesLoading, setMessagesLoading] = useState(false); - const fetchIdRef = useRef(0); - - // Initial fetch on mount or team change - useEffect(() => { - const id = ++fetchIdRef.current; - setMessagesLoading(true); - void (async () => { - try { - const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE }); - if (fetchIdRef.current !== id) return; - setFetchedMessages(page.messages); - setNextCursor(page.nextCursor); - setHasMore(page.hasMore); - } catch { - // Fallback: use prop messages if API fails - if (fetchIdRef.current === id && messages.length > 0) { - setFetchedMessages(messages); - } - } finally { - if (fetchIdRef.current === id) setMessagesLoading(false); - } - })(); - }, [teamName]); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally only on teamName change - - // Auto-refresh: poll for NEW messages only (prepend to head). - // Does NOT touch nextCursor/hasMore — those belong to the "Load older" flow. - useEffect(() => { - if (!isTeamAlive && leadActivity !== 'active') return; - const interval = setInterval(async () => { - try { - const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE }); - setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages)); - } catch { - // best-effort - } - }, 5000); - return () => clearInterval(interval); - }, [teamName, isTeamAlive, leadActivity]); - const loadOlderMessages = useCallback(async () => { - if (!nextCursor || messagesLoading) return; - setMessagesLoading(true); - try { - const page = await api.teams.getMessagesPage(teamName, { - beforeTimestamp: nextCursor, - limit: PAGE_SIZE, - }); - setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages)); - setNextCursor(page.nextCursor); - setHasMore(page.hasMore); - } catch { - // best-effort - } finally { - setMessagesLoading(false); + if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) { + return; } - }, [teamName, nextCursor, messagesLoading]); + await loadOlderTeamMessages(teamName); + }, [loadOlderTeamMessages, messagesState, teamName]); - // Use fetched messages, fall back to prop messages during initial load - const effectiveMessages = useMemo(() => { - if (fetchedMessages.length === 0) return messages; - return mergeTeamMessages(fetchedMessages, messages); - }, [fetchedMessages, messages]); + const messagesLoading = + (messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false); + const loadingOlderMessages = messagesState?.loadingOlder ?? false; + const hasMore = messagesState?.hasMore ?? false; + const effectiveMessages = messages; const composerTextareaRef = useRef(null); const sidebarScrollRef = useRef(null); @@ -307,7 +244,7 @@ export const MessagesPanel = memo(function MessagesPanel({ for (const [element, setHeight] of observedEntries) { if (!element) continue; - const updateHeight = () => { + const updateHeight = (): void => { const nextHeight = Math.ceil(element.getBoundingClientRect().height); if (nextHeight > 0) { setHeight(nextHeight); @@ -329,41 +266,21 @@ export const MessagesPanel = memo(function MessagesPanel({ }, [position, mountPoint]); const filteredMessages = useMemo(() => { - const startedAt = performance.now(); - const result = filterTeamMessages(effectiveMessages, { + return filterTeamMessages(effectiveMessages, { timeWindow, filter: messagesFilter, searchQuery: messagesSearchQuery, }); - const ms = performance.now() - startedAt; - if (ms >= MESSAGES_PANEL_FILTER_WARN_MS) { - logger.warn( - `[perf] filter team=${teamName} stage=messages ms=${ms.toFixed(1)} input=${effectiveMessages.length} output=${result.length} searchLen=${messagesSearchQuery.trim().length} noise=${ - messagesFilter.showNoise ? 'on' : 'off' - }` - ); - } - return result; - }, [effectiveMessages, messagesFilter, messagesSearchQuery, teamName, timeWindow]); + }, [effectiveMessages, messagesFilter, messagesSearchQuery, timeWindow]); const activityTimelineMessages = useMemo(() => { - const startedAt = performance.now(); - const result = filterTeamMessages(effectiveMessages, { + return filterTeamMessages(effectiveMessages, { includePassiveIdlePeerSummariesWhenNoiseHidden: true, timeWindow, filter: messagesFilter, searchQuery: messagesSearchQuery, }); - const ms = performance.now() - startedAt; - if (ms >= MESSAGES_PANEL_FILTER_WARN_MS) { - logger.warn( - `[perf] filter team=${teamName} stage=timeline ms=${ms.toFixed(1)} input=${effectiveMessages.length} output=${result.length} searchLen=${messagesSearchQuery.trim().length} noise=${ - messagesFilter.showNoise ? 'on' : 'off' - }` - ); - } - return result; - }, [effectiveMessages, messagesFilter, messagesSearchQuery, teamName, timeWindow]); + }, [effectiveMessages, messagesFilter, messagesSearchQuery, timeWindow]); const replyCandidateMessages = useMemo( () => @@ -377,33 +294,21 @@ export const MessagesPanel = memo(function MessagesPanel({ // Resolve the expanded item from filtered messages const expandedItem = useMemo(() => { - const startedAt = performance.now(); - if (!expandedItemKey) return null; + if (!expandedItemKey) { + return null; + } if (!expandedItemKey.startsWith('thoughts-')) { const msg = activityTimelineMessages.find((m) => toMessageKey(m) === expandedItemKey); - const result: TimelineItem | null = msg ? { type: 'message', message: msg } : null; - const ms = performance.now() - startedAt; - if (ms >= MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS) { - logger.warn( - `[perf] expandedItem team=${teamName} ms=${ms.toFixed(1)} mode=message timelineMessages=${activityTimelineMessages.length}` - ); - } - return result; + return msg ? { type: 'message', message: msg } : null; } const allItems = groupTimelineItems(activityTimelineMessages); - const result = + return ( allItems.find( (item) => item.type === 'lead-thoughts' && getThoughtGroupKey(item.group) === expandedItemKey - ) ?? null; - const ms = performance.now() - startedAt; - if (ms >= MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS) { - logger.warn( - `[perf] expandedItem team=${teamName} ms=${ms.toFixed(1)} mode=thoughts timelineMessages=${activityTimelineMessages.length} groups=${allItems.length}` - ); - } - return result; - }, [expandedItemKey, activityTimelineMessages, teamName]); + ) ?? null + ); + }, [expandedItemKey, activityTimelineMessages]); // Auto-clear stale expanded key useEffect(() => { @@ -661,7 +566,6 @@ export const MessagesPanel = memo(function MessagesPanel({ allCollapsed={messagesCollapsed} expandOverrides={expandedSet} onToggleExpandOverride={toggleExpandOverride} - teamSessionIds={teamSessionIds} currentLeadSessionId={currentLeadSessionId} isTeamAlive={isTeamAlive} leadActivity={leadActivity} @@ -684,10 +588,11 @@ export const MessagesPanel = memo(function MessagesPanel({ variant="ghost" size="sm" className="text-xs text-text-muted" - disabled={messagesLoading} + aria-busy={loadingOlderMessages} + disabled={loadingOlderMessages} onClick={() => void loadOlderMessages()} > - {messagesLoading ? 'Loading...' : 'Load older messages'} + Load older messages
    )} @@ -846,7 +751,6 @@ export const MessagesPanel = memo(function MessagesPanel({ allCollapsed={messagesCollapsed} expandOverrides={expandedSet} onToggleExpandOverride={toggleExpandOverride} - teamSessionIds={teamSessionIds} currentLeadSessionId={currentLeadSessionId} isTeamAlive={isTeamAlive} leadActivity={leadActivity} @@ -869,10 +773,11 @@ export const MessagesPanel = memo(function MessagesPanel({ variant="ghost" size="sm" className="text-xs text-text-muted" - disabled={messagesLoading} + aria-busy={loadingOlderMessages} + disabled={loadingOlderMessages} onClick={() => void loadOlderMessages()} > - {messagesLoading ? 'Loading...' : 'Load older messages'} + Load older messages
    )} @@ -1132,7 +1037,6 @@ export const MessagesPanel = memo(function MessagesPanel({ allCollapsed={messagesCollapsed} expandOverrides={expandedSet} onToggleExpandOverride={toggleExpandOverride} - teamSessionIds={teamSessionIds} currentLeadSessionId={currentLeadSessionId} isTeamAlive={isTeamAlive} leadActivity={leadActivity} @@ -1155,10 +1059,11 @@ export const MessagesPanel = memo(function MessagesPanel({ variant="ghost" size="sm" className="text-xs text-text-muted" - disabled={messagesLoading} + aria-busy={loadingOlderMessages} + disabled={loadingOlderMessages} onClick={() => void loadOlderMessages()} > - {messagesLoading ? 'Loading...' : 'Load older messages'} + Load older messages
    )} diff --git a/src/renderer/components/team/provisioningSteps.ts b/src/renderer/components/team/provisioningSteps.ts index bb5eb924..92d7df18 100644 --- a/src/renderer/components/team/provisioningSteps.ts +++ b/src/renderer/components/team/provisioningSteps.ts @@ -3,10 +3,14 @@ import { isLeadMember } from '@shared/utils/leadDetection'; import type { MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, - ResolvedTeamMember, TeamProvisioningProgress, } from '@shared/types'; +interface LaunchJoinMemberLike { + name: string; + removedAt?: number; +} + /** Display steps for the provisioning stepper (0-indexed). */ export const DISPLAY_STEPS = [ { key: 'starting', label: 'Starting' }, @@ -52,7 +56,7 @@ export function getLaunchJoinMilestonesFromMembers({ memberSpawnStatuses, memberSpawnSnapshot, }: { - members: readonly ResolvedTeamMember[]; + members: readonly LaunchJoinMemberLike[]; memberSpawnStatuses?: MemberSpawnStatusCollection; memberSpawnSnapshot?: Pick; }): LaunchJoinMilestones { diff --git a/src/renderer/components/team/taskLogs/TaskActivitySection.tsx b/src/renderer/components/team/taskLogs/TaskActivitySection.tsx index 5a99c65e..e41ec239 100644 --- a/src/renderer/components/team/taskLogs/TaskActivitySection.tsx +++ b/src/renderer/components/team/taskLogs/TaskActivitySection.tsx @@ -1,4 +1,4 @@ -import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { asEnhancedChunkArray } from '@renderer/types/data'; @@ -26,6 +26,7 @@ import type { interface TaskActivitySectionProps { teamName: string; taskId: string; + enabled?: boolean; } function isHighSignalTaskActivityEntry(entry: BoardTaskActivityEntry): boolean { @@ -262,12 +263,14 @@ const Row = ({ export const TaskActivitySection = ({ teamName, taskId, + enabled = true, }: TaskActivitySectionProps): React.JSX.Element => { const [detailStates, setDetailStates] = useState>({}); const [entries, setEntries] = useState([]); const [expandedId, setExpandedId] = useState(null); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(enabled); const [error, setError] = useState(null); + const hasLoadedRef = useRef(false); const fetchDetail = useCallback( async (entry: BoardTaskActivityEntry): Promise => { @@ -325,13 +328,27 @@ export const TaskActivitySection = ({ ); useEffect(() => { - let cancelled = false; - setEntries([]); setExpandedId(null); setDetailStates({}); - setLoading(true); setError(null); + setLoading(enabled); + hasLoadedRef.current = false; + }, [taskId, teamName]); + + useEffect(() => { + if (!enabled) { + setLoading(false); + } + }, [enabled]); + + useEffect(() => { + let cancelled = false; + if (!enabled) { + return () => { + cancelled = true; + }; + } const load = async (showSpinner: boolean): Promise => { try { @@ -344,6 +361,7 @@ export const TaskActivitySection = ({ const result = await api.teams.getTaskActivity(teamName, taskId); if (!cancelled) { setEntries(result); + hasLoadedRef.current = true; } } catch (loadError) { if (!cancelled) { @@ -357,7 +375,7 @@ export const TaskActivitySection = ({ } }; - void load(true); + void load(!hasLoadedRef.current); const intervalId = window.setInterval(() => { void load(false); }, 8000); @@ -366,7 +384,7 @@ export const TaskActivitySection = ({ cancelled = true; window.clearInterval(intervalId); }; - }, [teamName, taskId]); + }, [enabled, teamName, taskId]); const visibleEntries = useMemo( () => diff --git a/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx index b1908430..1f47a6ec 100644 --- a/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx +++ b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; @@ -14,8 +14,12 @@ import type { interface TaskLogStreamSectionProps { teamName: string; taskId: string; + taskStatus?: string; + liveEnabled?: boolean; } +const LIVE_RELOAD_DEBOUNCE_MS = 350; + function formatRelativeTime(isoString: string): string { const date = new Date(isoString); const diffMs = Date.now() - date.getTime(); @@ -86,39 +90,160 @@ const SegmentBlock = ({ export const TaskLogStreamSection = ({ teamName, taskId, + taskStatus, + liveEnabled = true, }: TaskLogStreamSectionProps): React.JSX.Element => { const [stream, setStream] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedParticipantKey, setSelectedParticipantKey] = useState<'all' | string>('all'); + const requestSeqRef = useRef(0); + const streamRef = useRef(null); + const reloadTimerRef = useRef | null>(null); useEffect(() => { - let cancelled = false; + streamRef.current = stream; + }, [stream]); - const run = async (): Promise => { - try { + const loadStream = useCallback( + async (options?: { resetSelection?: boolean; background?: boolean }): Promise => { + const resetSelection = options?.resetSelection ?? false; + const background = options?.background ?? false; + const hadExistingStream = streamRef.current != null; + const requestSeq = requestSeqRef.current + 1; + requestSeqRef.current = requestSeq; + + if (!background) { setLoading(true); - setError(null); + } + setError((prev) => (background ? prev : null)); + + try { const response = normalizeResponse(await api.teams.getTaskLogStream(teamName, taskId)); - if (cancelled) return; + if (requestSeqRef.current !== requestSeq) { + return; + } + setStream(response); - setSelectedParticipantKey(response.defaultFilter); + setSelectedParticipantKey((prev) => { + if (resetSelection) { + return response.defaultFilter; + } + const availableParticipantKeys = new Set([ + 'all', + ...response.participants.map((participant) => participant.key), + ]); + return availableParticipantKeys.has(prev) ? prev : response.defaultFilter; + }); + setError(null); } catch (loadError) { - if (cancelled) return; - setError(loadError instanceof Error ? loadError.message : 'Failed to load task log stream'); - setStream(null); + if (requestSeqRef.current !== requestSeq) { + return; + } + + if (!background || streamRef.current == null) { + setError( + loadError instanceof Error ? loadError.message : 'Failed to load task log stream' + ); + setStream(null); + } } finally { - if (!cancelled) { + if (requestSeqRef.current === requestSeq && (!background || !hadExistingStream)) { setLoading(false); } } + }, + [taskId, teamName] + ); + + useEffect(() => { + setStream(null); + streamRef.current = null; + setError(null); + setSelectedParticipantKey('all'); + requestSeqRef.current += 1; + if (reloadTimerRef.current) { + clearTimeout(reloadTimerRef.current); + reloadTimerRef.current = null; + } + void loadStream({ resetSelection: true }); + }, [loadStream]); + + const previousTaskMetaRef = useRef({ taskId, taskStatus }); + + useEffect(() => { + const previousTaskMeta = previousTaskMetaRef.current; + previousTaskMetaRef.current = { taskId, taskStatus }; + + if (previousTaskMeta.taskId !== taskId) { + return; + } + + if ( + previousTaskMeta.taskStatus === 'in_progress' && + taskStatus && + taskStatus !== 'in_progress' + ) { + void loadStream({ background: true }); + } + }, [loadStream, taskId, taskStatus]); + + useEffect(() => { + if (!liveEnabled) { + if (reloadTimerRef.current) { + clearTimeout(reloadTimerRef.current); + reloadTimerRef.current = null; + } + return; + } + + const scheduleReload = (): void => { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { + return; + } + if (reloadTimerRef.current) { + clearTimeout(reloadTimerRef.current); + } + reloadTimerRef.current = setTimeout(() => { + reloadTimerRef.current = null; + void loadStream({ background: true }); + }, LIVE_RELOAD_DEBOUNCE_MS); }; - void run(); - return () => { - cancelled = true; + const unsubscribe = api.teams.onTeamChange?.((_event, event) => { + if ( + event.teamName !== teamName || + event.type !== 'task-log-change' || + event.taskId !== taskId + ) { + return; + } + scheduleReload(); + }); + + const handleVisibilityChange = (): void => { + if (document.visibilityState === 'visible') { + scheduleReload(); + } }; - }, [taskId, teamName]); + + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', handleVisibilityChange); + } + + return () => { + if (reloadTimerRef.current) { + clearTimeout(reloadTimerRef.current); + reloadTimerRef.current = null; + } + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', handleVisibilityChange); + } + if (typeof unsubscribe === 'function') { + unsubscribe(); + } + }; + }, [liveEnabled, loadStream, taskId, teamName]); const participants = stream?.participants ?? []; const showChips = participants.length > 1; diff --git a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx index 0d364575..4c0582ff 100644 --- a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx +++ b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx @@ -1,5 +1,6 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { api } from '@renderer/api'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { ExecutionSessionsSection } from './ExecutionSessionsSection'; @@ -14,6 +15,7 @@ type TaskLogsTab = 'activity' | 'stream' | 'sessions'; interface TaskLogsPanelProps { teamName: string; task: TeamTaskWithKanban; + isOpen?: boolean; taskSince?: string; isExecutionRefreshing?: boolean; isExecutionPreviewOnline?: boolean; @@ -21,11 +23,17 @@ interface TaskLogsPanelProps { showSubagentPreview?: boolean; showLeadPreview?: boolean; onPreviewOnlineChange?: (isOnline: boolean) => void; + onTaskLogActivityChange?: (isActive: boolean) => void; + onTaskLogCountChange?: (count: number | undefined) => void; } +const TASK_LOG_ACTIVITY_PULSE_MS = 1800; +const TASK_LOG_COUNT_RELOAD_DEBOUNCE_MS = 350; + export const TaskLogsPanel = ({ teamName, task, + isOpen = true, taskSince, isExecutionRefreshing = false, isExecutionPreviewOnline = false, @@ -33,6 +41,8 @@ export const TaskLogsPanel = ({ showSubagentPreview = false, showLeadPreview = false, onPreviewOnlineChange, + onTaskLogActivityChange, + onTaskLogCountChange, }: TaskLogsPanelProps): React.JSX.Element => { const availableTabs = useMemo(() => { const tabs: TaskLogsTab[] = []; @@ -48,6 +58,14 @@ export const TaskLogsPanel = ({ const defaultTab = availableTabs[0] ?? 'sessions'; const [activeTab, setActiveTab] = useState(defaultTab); + const [isTaskLogActivityActive, setIsTaskLogActivityActive] = useState(false); + const [taskLogSegmentCount, setTaskLogSegmentCount] = useState(null); + const [hasOpenedContent, setHasOpenedContent] = useState(isOpen); + const pulseTimerRef = useRef | null>(null); + const countReloadTimerRef = useRef | null>(null); + const countRequestSeqRef = useRef(0); + const taskLogTrackingEnabled = task.status === 'in_progress' && availableTabs.includes('stream'); + const taskLogSummaryEnabled = availableTabs.includes('stream'); useEffect(() => { setActiveTab(defaultTab); @@ -59,6 +77,160 @@ export const TaskLogsPanel = ({ } }, [activeTab, availableTabs, defaultTab]); + useEffect(() => { + if (isOpen) { + setHasOpenedContent(true); + } + }, [isOpen]); + + useEffect(() => { + onTaskLogActivityChange?.(isTaskLogActivityActive); + }, [isTaskLogActivityActive, onTaskLogActivityChange]); + + useEffect(() => { + onTaskLogCountChange?.( + taskLogSegmentCount != null && taskLogSegmentCount > 0 ? taskLogSegmentCount : undefined + ); + }, [onTaskLogCountChange, taskLogSegmentCount]); + + useEffect(() => { + if (pulseTimerRef.current) { + clearTimeout(pulseTimerRef.current); + pulseTimerRef.current = null; + } + if (countReloadTimerRef.current) { + clearTimeout(countReloadTimerRef.current); + countReloadTimerRef.current = null; + } + countRequestSeqRef.current += 1; + setIsTaskLogActivityActive(false); + setTaskLogSegmentCount(null); + }, [task.id]); + + useEffect(() => { + if (!taskLogSummaryEnabled || !api.teams.getTaskLogStreamSummary) { + setTaskLogSegmentCount(null); + return; + } + + const requestSeq = countRequestSeqRef.current + 1; + countRequestSeqRef.current = requestSeq; + + void Promise.resolve(api.teams.getTaskLogStreamSummary(teamName, task.id)) + .then((summary) => { + if (countRequestSeqRef.current !== requestSeq) { + return; + } + setTaskLogSegmentCount(summary.segmentCount); + }) + .catch(() => { + if (countRequestSeqRef.current !== requestSeq) { + return; + } + setTaskLogSegmentCount((prev) => prev); + }); + }, [task.id, taskLogSummaryEnabled, teamName]); + + useEffect(() => { + if (!taskLogTrackingEnabled || !api.teams.setTaskLogStreamTracking) { + return; + } + + void Promise.resolve(api.teams.setTaskLogStreamTracking(teamName, true)).catch(() => undefined); + return () => { + void Promise.resolve(api.teams.setTaskLogStreamTracking(teamName, false)).catch( + () => undefined + ); + }; + }, [taskLogTrackingEnabled, teamName]); + + useEffect(() => { + if (!taskLogTrackingEnabled) { + if (pulseTimerRef.current) { + clearTimeout(pulseTimerRef.current); + pulseTimerRef.current = null; + } + if (countReloadTimerRef.current) { + clearTimeout(countReloadTimerRef.current); + countReloadTimerRef.current = null; + } + setIsTaskLogActivityActive(false); + return; + } + + const scheduleCountReload = (): void => { + if (!api.teams.getTaskLogStreamSummary) { + return; + } + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { + return; + } + if (countReloadTimerRef.current) { + clearTimeout(countReloadTimerRef.current); + } + countReloadTimerRef.current = setTimeout(() => { + countReloadTimerRef.current = null; + const requestSeq = countRequestSeqRef.current + 1; + countRequestSeqRef.current = requestSeq; + void Promise.resolve(api.teams.getTaskLogStreamSummary(teamName, task.id)) + .then((summary) => { + if (countRequestSeqRef.current !== requestSeq) { + return; + } + setTaskLogSegmentCount(summary.segmentCount); + }) + .catch(() => undefined); + }, TASK_LOG_COUNT_RELOAD_DEBOUNCE_MS); + }; + + const unsubscribe = api.teams.onTeamChange?.((_event, event) => { + if ( + event.teamName !== teamName || + event.type !== 'task-log-change' || + event.taskId !== task.id + ) { + return; + } + + setIsTaskLogActivityActive(true); + if (pulseTimerRef.current) { + clearTimeout(pulseTimerRef.current); + } + pulseTimerRef.current = setTimeout(() => { + pulseTimerRef.current = null; + setIsTaskLogActivityActive(false); + }, TASK_LOG_ACTIVITY_PULSE_MS); + scheduleCountReload(); + }); + + const handleVisibilityChange = (): void => { + if (document.visibilityState === 'visible') { + scheduleCountReload(); + } + }; + + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', handleVisibilityChange); + } + + return () => { + if (pulseTimerRef.current) { + clearTimeout(pulseTimerRef.current); + pulseTimerRef.current = null; + } + if (countReloadTimerRef.current) { + clearTimeout(countReloadTimerRef.current); + countReloadTimerRef.current = null; + } + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', handleVisibilityChange); + } + if (typeof unsubscribe === 'function') { + unsubscribe(); + } + }; + }, [task.id, taskLogTrackingEnabled, teamName]); + return ( - {availableTabs.includes('stream') ? ( + {availableTabs.includes('stream') && hasOpenedContent ? ( - + ) : null} - {availableTabs.includes('activity') ? ( + {availableTabs.includes('activity') && hasOpenedContent ? ( - + ) : null} - - - + {hasOpenedContent ? ( + + + + ) : null} ); }; diff --git a/src/renderer/components/team/useTeamProvisioningPresentation.ts b/src/renderer/components/team/useTeamProvisioningPresentation.ts index 3eac3f72..0cdf27ec 100644 --- a/src/renderer/components/team/useTeamProvisioningPresentation.ts +++ b/src/renderer/components/team/useTeamProvisioningPresentation.ts @@ -3,7 +3,7 @@ import { useMemo } from 'react'; import { useStore } from '@renderer/store'; import { getCurrentProvisioningProgressForTeam, - selectTeamDataForName, + selectTeamMemberSnapshotsForName, } from '@renderer/store/slices/teamSlice'; import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; import { useShallow } from 'zustand/react/shallow'; @@ -20,7 +20,7 @@ export function useTeamProvisioningPresentation(teamName: string): { useShallow((s) => ({ progress: getCurrentProvisioningProgressForTeam(s, teamName), cancelProvisioning: s.cancelProvisioning, - teamMembers: selectTeamDataForName(s, teamName)?.members ?? [], + teamMembers: selectTeamMemberSnapshotsForName(s, teamName), memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], })) diff --git a/src/renderer/hooks/useTaskSuggestions.ts b/src/renderer/hooks/useTaskSuggestions.ts index 0a6f3218..892942a1 100644 --- a/src/renderer/hooks/useTaskSuggestions.ts +++ b/src/renderer/hooks/useTaskSuggestions.ts @@ -1,6 +1,10 @@ import { useMemo } from 'react'; import { useStore } from '@renderer/store'; +import { + selectResolvedMembersForTeamName, + selectTeamDataForName, +} from '@renderer/store/slices/teamSlice'; import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils'; import { getTaskDisplayId } from '@shared/utils/taskIdentity'; import { useShallow } from 'zustand/react/shallow'; @@ -57,11 +61,13 @@ function isVisibleTask(task: TeamTaskWithKanban | GlobalTask): boolean { } export function useTaskSuggestions(currentTeamName: string | null): UseTaskSuggestionsResult { - const { globalTasks, selectedTeamName, selectedTeamData, teamByName } = useStore( + const { globalTasks, currentTeamData, currentTeamMembers, teamByName } = useStore( useShallow((s) => ({ globalTasks: s.globalTasks, - selectedTeamName: s.selectedTeamName, - selectedTeamData: s.selectedTeamData, + currentTeamData: currentTeamName ? selectTeamDataForName(s, currentTeamName) : null, + currentTeamMembers: currentTeamName + ? selectResolvedMembersForTeamName(s, currentTeamName) + : [], teamByName: s.teamByName, })) ); @@ -73,14 +79,10 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge if (currentTeamName) { const currentTeamSummary = teamByName[currentTeamName]; const currentTeamDisplayName = currentTeamSummary?.displayName || currentTeamName; - const currentTeamMembers = - selectedTeamName === currentTeamName && selectedTeamData - ? selectedTeamData.members - : (currentTeamSummary?.members ?? []); const currentTeamTasks = - selectedTeamName === currentTeamName && selectedTeamData - ? selectedTeamData.tasks - : globalTasks.filter((task) => task.teamName === currentTeamName); + currentTeamData?.tasks ?? globalTasks.filter((task) => task.teamName === currentTeamName); + const currentTeamMemberColors = + currentTeamMembers.length > 0 ? currentTeamMembers : (currentTeamSummary?.members ?? []); for (const task of currentTeamTasks) { if (!isVisibleTask(task)) continue; @@ -91,7 +93,7 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge teamDisplayName: currentTeamDisplayName, teamColor: currentTeamSummary?.color, isCurrentTeamTask: true, - ownerColor: currentTeamMembers.find((member) => member.name === task.owner)?.color, + ownerColor: currentTeamMemberColors.find((member) => member.name === task.owner)?.color, }); } } @@ -123,7 +125,7 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge }); return tasks.map(buildTaskSuggestion); - }, [currentTeamName, globalTasks, selectedTeamData, selectedTeamName, teamByName]); + }, [currentTeamData, currentTeamMembers, currentTeamName, globalTasks, teamByName]); return { suggestions }; } diff --git a/src/renderer/index.html b/src/renderer/index.html index ced0a449..1f849482 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -4,7 +4,7 @@ - Claude Agent Teams UI + Agent Teams UI