diff --git a/.board-task-log-freshness/351e2899-3aba-4992-9250-bf85dccb4399.json b/.board-task-log-freshness/351e2899-3aba-4992-9250-bf85dccb4399.json new file mode 100644 index 00000000..b471db6f --- /dev/null +++ b/.board-task-log-freshness/351e2899-3aba-4992-9250-bf85dccb4399.json @@ -0,0 +1 @@ +{"taskId":"351e2899-3aba-4992-9250-bf85dccb4399","teamName":"ember-collective","provider":"codex","source":"codex-native-trace","updatedAt":"2026-05-09T07:59:53.638Z"} \ No newline at end of file diff --git a/.board-task-log-freshness/351e2899.json b/.board-task-log-freshness/351e2899.json new file mode 100644 index 00000000..afb5a35e --- /dev/null +++ b/.board-task-log-freshness/351e2899.json @@ -0,0 +1 @@ +{"taskId":"351e2899","teamName":"ember-collective","provider":"codex","source":"codex-native-trace","updatedAt":"2026-05-09T08:00:39.185Z"} \ No newline at end of file diff --git a/agent-teams-controller/src/internal/review.js b/agent-teams-controller/src/internal/review.js index 74631333..bf65f7ef 100644 --- a/agent-teams-controller/src/internal/review.js +++ b/agent-teams-controller/src/internal/review.js @@ -443,7 +443,9 @@ function requestReview(context, taskId, flags = {}) { text: `**Please review** task #${task.displayId || task.id}\n\n` + wrapAgentBlock( - `FIRST call review_start to signal you are beginning the review:\n` + + `This request is for the CURRENT review cycle. If you reviewed this task earlier, do not treat this message as a duplicate while the task is still in review; prior approvals become stale after later work.\n\n` + + `Before declaring it duplicate, call task_get and check the current reviewState/status. If it is still in review for you, continue with the review tools below.\n\n` + + `FIRST call review_start to signal you are beginning the review:\n` + `{ teamName: "${context.teamName}", taskId: "${task.id}", from: "" }\n\n` + `When approved, use MCP tool review_approve:\n` + `{ teamName: "${context.teamName}", taskId: "${task.id}", from: "", note?: "", notifyOwner: true }\n\n` + diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index 11393b6c..598beb52 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -774,6 +774,9 @@ describe('agent-teams-controller API', () => { expect(inbox).toHaveLength(1); expect(inbox[0].text).toContain(''); + expect(inbox[0].text).toContain('CURRENT review cycle'); + expect(inbox[0].text).toContain('Before declaring it duplicate, call task_get'); + expect(inbox[0].text).toContain('reviewState/status'); expect(inbox[0].text).toContain('review_approve'); expect(inbox[0].text).not.toContain(''); expect(inbox[0].leadSessionId).toBe('lead-session-1'); diff --git a/docs/team-management/opencode-runtime-delivery-advisory-phase-1-2-plan.md b/docs/team-management/opencode-runtime-delivery-advisory-phase-1-2-plan.md new file mode 100644 index 00000000..e06166ef --- /dev/null +++ b/docs/team-management/opencode-runtime-delivery-advisory-phase-1-2-plan.md @@ -0,0 +1,1334 @@ +# OpenCode Runtime Delivery Advisory Policy - Phase 1.2 Plan + +## Summary + +Implement a shared user-facing advisory policy for OpenCode prompt delivery records. + +The delivery ledger must remain strict. `failed_terminal` still means automatic OpenCode delivery attempts are exhausted for that inbox row. The new policy decides whether that fact should be shown to a human or lead process as an immediate error, deferred while proof can still arrive, surfaced later as a soft warning, or suppressed because a later proof already exists. + +Recommended scope: 🎯 9 🛡️ 9 🧠 7 - roughly `420-650` changed lines including tests. + +Phase 1.2 intentionally does not change the direct send/composer warning contract yet. That is Phase 1.3. This phase fixes the member card, member snapshot advisory, human notification, and lead notice paths. + +## Problem + +Observed user-visible failure: + +1. OpenCode accepts a prompt. +2. The app observes no visible assistant response or no sufficient proof in time. +3. The prompt delivery ledger reaches `failed_terminal` with a generic proof reason such as `empty_assistant_turn`. +4. Member cards show `OpenCode delivery error`. +5. A later `runtime_delivery` reply or task progress proof arrives. +6. The card clears itself. + +This is technically consistent with the ledger, but it is wrong UX. A strict ledger fact is being treated as a final user-facing diagnosis too early. + +Concrete local evidence showed this shape: + +- ledger record: `failed_terminal`, `responseState: "empty_assistant_turn"`, `attempts: 3`, `maxAttempts: 3` +- later inbox reply: `source: "runtime_delivery"`, same `relayOfMessageId` +- result: the UI error was temporary and scary, but the participant was not actually unavailable + +## Current Hotspots + +Member card advisory source: + +- `src/main/services/team/TeamDataService.ts` +- `src/main/services/team/TeamMemberRuntimeAdvisoryService.ts` +- `src/renderer/utils/memberHelpers.ts` + +Notification and lead notice source: + +- `src/main/services/team/TeamProvisioningService.ts` + - `logOpenCodePromptDeliveryEvent` + - `shouldSurfaceOpenCodeRuntimeDeliveryAdvisory` + - `shouldNotifyOpenCodeRuntimeDeliveryBeforeTerminal` + - `fireOpenCodeRuntimeDeliveryErrorNotification` + - `notifyLeadAboutOpenCodeRuntimeDeliveryError` + +Existing reason helpers: + +- `src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts` +- `src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts` + +Existing proof lookup is embedded in: + +- `TeamMemberRuntimeAdvisoryService.readVisibleOpenCodeRuntimeDeliveryReplyTimes` +- `TeamMemberRuntimeAdvisoryService.readTaskProgressProofTimes` +- `TeamMemberRuntimeAdvisoryService.hasSupersedingProofForOpenCodeDeliveryRecord` + +## Core Design + +Separate three concepts: + +1. **Ledger fact** + The durable state of OpenCode prompt delivery. Example: `failed_terminal`. + +2. **Proof snapshot** + Whether a later visible reply, task progress, or newer success supersedes the failure. + +3. **User impact** + Whether the app should suppress, defer, warn, or error. + +4. **Side-effect eligibility** + Whether a surfaced impact should emit a member refresh, desktop notification, or lead notice for this particular event. + +This follows SRP: + +- ledger store owns durable delivery facts; +- proof reader owns reading canonical proof sources; +- policy owns user-facing classification; +- provisioning service owns event-specific side effects such as notifications and team-change events; +- renderer only displays the already-classified advisory. + +Critical constraint: user impact is not the same thing as notification eligibility. Phase 1.2 must not accidentally broaden notification scope. A hard advisory can be appropriate for a member card while still not producing a desktop notification for event types that never notified before. + +## New Module + +Add: + +```txt +src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryAdvisoryPolicy.ts +``` + +Keep this module pure: + +- no filesystem reads; +- no inbox/task readers; +- no `TeamProvisioningService` import; +- no `TeamMemberRuntimeAdvisoryService` import; +- deterministic output from `record`, `proof`, and `nowMs`. + +This prevents circular dependencies and keeps the policy easy to unit test. + +Recommended exported types: + +```ts +import type { MemberRuntimeAdvisory } from '@shared/types'; +import type { OpenCodePromptDeliveryLedgerRecord } from './OpenCodePromptDeliveryLedger'; + +export type OpenCodeRuntimeDeliveryAdvisoryAction = 'suppress' | 'defer' | 'surface'; + +export type OpenCodeRuntimeDeliveryAdvisorySeverity = 'warning' | 'error'; + +export interface OpenCodeRuntimeDeliveryProofSnapshot { + latestSuccessAt: number | null; + visibleReplyAt: number | null; + taskProgressAt: number | null; +} + +export interface OpenCodeRuntimeDeliveryAdvisoryDecision { + action: OpenCodeRuntimeDeliveryAdvisoryAction; + reason: + | 'no_reason' + | 'responded' + | 'newer_success' + | 'visible_reply_proof' + | 'task_progress_proof' + | 'hard_error' + | 'proof_pending' + | 'proof_missing_confirmed' + | 'not_user_visible'; + severity?: OpenCodeRuntimeDeliveryAdvisorySeverity; + reasonCode?: MemberRuntimeAdvisory['reasonCode']; + message?: string; + observedAt?: string; + nextReviewAt?: string; +} + +export interface OpenCodeRuntimeDeliveryAdvisoryPolicyInput { + record: OpenCodePromptDeliveryLedgerRecord; + proof: OpenCodeRuntimeDeliveryProofSnapshot; + nowMs: number; + graceMs?: number; +} + +export interface OpenCodeRuntimeDeliveryEventSideEffectDecision { + emitMemberAdvisoryRefresh: boolean; + scheduleReviewAt?: string; + notifyHuman: boolean; + notifyLead: boolean; +} +``` + +Recommended default grace: + +```ts +export const OPENCODE_RUNTIME_DELIVERY_GENERIC_PROOF_GRACE_MS = 120_000; +``` + +Why `120_000`: + +- real observed late proof arrived roughly 40 seconds after `failed_terminal`; +- current advisory cache TTL is 30 seconds, so less than that can still flicker; +- 2 minutes is long enough to absorb OpenCode transcript/materialization lag without hiding real stale state for too long. + +## Policy Rules + +### Suppress + +Return `suppress` when: + +- selected reason is null; +- record is `responded`; +- latest success is newer than the candidate error; +- visible runtime reply proof is correlated and timestamp-eligible for the original prompt; +- task progress proof matches `taskRefs`, member actor/author, and original prompt time. + +Code sketch: + +```ts +export function decideOpenCodeRuntimeDeliveryAdvisory( + input: OpenCodeRuntimeDeliveryAdvisoryPolicyInput +): OpenCodeRuntimeDeliveryAdvisoryDecision { + const recordTimeMs = getOpenCodeRuntimeDeliveryRecordTimeMs(input.record); + const reason = selectOpenCodeRuntimeDeliveryReason(input.record); + + if (!reason) { + return suppress('no_reason'); + } + if (input.record.status === 'responded') { + return suppress('responded'); + } + if (input.proof.latestSuccessAt != null && input.proof.latestSuccessAt > recordTimeMs) { + return suppress('newer_success'); + } + if (input.proof.visibleReplyAt != null && input.proof.visibleReplyAt > recordTimeMs) { + return suppress('visible_reply_proof'); + } + if (input.proof.taskProgressAt != null && input.proof.taskProgressAt > recordTimeMs) { + return suppress('task_progress_proof'); + } + + // Continue with hard/generic classification. +} +``` + +### Surface immediate error + +Return `surface/error` immediately for hard errors. + +Hard errors include: + +- auth and login problems; +- quota/credits/capacity; +- provider or bridge unavailable; +- permission blocked when action is required; +- payload mismatch; +- attachment payload unavailable or unsupported; +- project/runtime identity unavailable; +- terminal session/runtime errors with specific non-generic diagnostics. + +For non-terminal records, keep current intent but narrow the scary path: + +- non-terminal `session_error`, `tool_error`, `permission_blocked`, or `reconcile_failed` with action-required/hard reason can surface immediately; +- non-terminal generic retryable states should not create a member card error while retries or observe-later work can still run; +- non-terminal generic states should be handled by the delivery watchdog/direct-send pending UX, not by the member card. + +Use existing `selectOpenCodeRuntimeDeliveryReason()` and `isActionRequiredOpenCodeRuntimeDeliveryReason()` first. Add a policy-local hard token set for app/runtime errors that are not provider API text. + +Example hard token set: + +```ts +const HARD_RUNTIME_DELIVERY_REASON_TOKENS = [ + 'auth_unavailable', + 'authentication_failed', + 'invalid api key', + 'insufficient credits', + 'quota exceeded', + 'key limit exceeded', + 'opencode_prompt_delivery_payload_mismatch', + 'opencode_inbox_attachment_payload_unavailable', + 'opencode_inbox_attachment_payload_read_failed', + 'opencode_attachment_delivery_prepare_failed', + 'opencode_runtime_message_bridge_unavailable', + 'opencode_project_path_unavailable', +]; +``` + +Do not classify generic proof states as hard only because the ledger is terminal. + +Do not notify for recipient-shape or removed-member cases by default: + +- `recipient_is_not_opencode` +- `recipient_removed` +- removed member filtered out by config/meta + +Those are routing/config facts, not evidence that a live OpenCode participant is broken. They may be useful diagnostics in logs, but they should not produce the scary OpenCode runtime delivery notification path. + +### Defer generic proof failures + +Return `defer` when: + +- record is `failed_terminal`; +- reason is generic proof missing; +- no proof supersedes it; +- `nowMs < failedAt + graceMs`. + +Generic proof states include: + +- `empty_assistant_turn` +- `prompt_delivered_no_assistant_message` +- `visible_reply_still_required` +- `visible_reply_ack_only_still_requires_answer` +- `plain_text_ack_only_still_requires_answer` +- `visible_reply_destination_not_found_yet` +- `visible_reply_missing_relayOfMessageId` +- `visible_reply_missing_relayofmessageid` +- `visible_reply_missing_task_refs` +- `visible_reply_missing_task_refs_after_merge` +- `visible_reply_task_refs_merge_failed` +- `non_visible_tool_without_task_progress` + +Do not match only raw ledger tokens. `selectOpenCodeRuntimeDeliveryReason()` often returns readable fallback copy, for example `OpenCode returned an empty assistant turn.`. Add a helper that recognizes both raw and formatted reasons: + +```ts +export function isOpenCodeRuntimeDeliveryProofOnlyReason(input: { + record: OpenCodePromptDeliveryLedgerRecord; + selectedReason: string; +}): boolean { + const candidates = [ + input.record.responseState, + input.record.lastReason, + ...input.record.diagnostics, + input.selectedReason, + ] + .map((value) => value?.trim().toLowerCase()) + .filter((value): value is string => Boolean(value)); + + return candidates.some((value) => + OPEN_CODE_PROOF_ONLY_REASON_TOKENS.some((token) => value.includes(token)) + ); +} + +// Keep these lower-case because candidates are normalized with toLowerCase(). +const OPEN_CODE_PROOF_ONLY_REASON_TOKENS = [ + 'empty_assistant_turn', + 'opencode returned an empty assistant turn', + 'prompt_delivered_no_assistant_message', + 'opencode accepted the prompt, but no assistant turn was recorded', + 'visible_reply_still_required', + 'opencode responded, but did not create a visible message_send reply', + 'visible_reply_ack_only_still_requires_answer', + 'plain_text_ack_only_still_requires_answer', + 'visible_reply_destination_not_found_yet', + 'visible_reply_missing_relayofmessageid', + 'without the required relayofmessageid correlation', + 'visible_reply_missing_task_refs', + 'visible_reply_missing_task_refs_after_merge', + 'visible_reply_task_refs_merge_failed', + 'opencode created a reply without the required taskrefs metadata', + 'non_visible_tool_without_task_progress', + 'opencode used tools, but did not create a visible reply or task progress proof', +]; +``` + +This helper is the reason `empty_assistant_turn` becomes `protocol_proof_missing` after grace instead of falling through to `backend_error`. + +Code sketch: + +```ts +if (isGenericOpenCodeRuntimeDeliveryProofFailure(input.record, reason)) { + const terminalAt = getOpenCodeRuntimeDeliveryTerminalTimeMs(input.record); + const nextReviewMs = terminalAt + (input.graceMs ?? OPENCODE_RUNTIME_DELIVERY_GENERIC_PROOF_GRACE_MS); + if (input.nowMs < nextReviewMs) { + return { + action: 'defer', + reason: 'proof_pending', + observedAt: new Date(terminalAt).toISOString(), + nextReviewAt: new Date(nextReviewMs).toISOString(), + }; + } + + return { + action: 'surface', + reason: 'proof_missing_confirmed', + severity: 'warning', + reasonCode: 'protocol_proof_missing', + message: reason, + observedAt: new Date(terminalAt).toISOString(), + }; +} +``` + +### Surface confirmed soft warning + +After grace expires with no proof, return `surface/warning` with: + +- `reasonCode: 'protocol_proof_missing'` + +Rationale: + +- the member card and hover/detail surfaces can show a warning; +- human desktop notification is too noisy for a proof-only problem; +- lead notice can disturb the team and cause unnecessary human-facing messages; +- task-stall monitoring should handle real work inactivity. + +If a product decision later wants lead notice for confirmed proof gaps, add a new soft notice path with different copy. Do not reuse `Treat @member as unavailable`. + +### Surface immediate hard error + +For hard errors: + +```ts +return { + action: 'surface', + reason: 'hard_error', + severity: 'error', + reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(reason), + message: reason, + observedAt: new Date(recordTimeMs).toISOString(), +}; +``` + +Notification eligibility is decided later from the event and the impact. + +## Proof Reader Extraction + +Extract the proof lookup out of `TeamMemberRuntimeAdvisoryService` into a reusable helper: + +```txt +src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryProofReader.ts +``` + +Also extract pure matching helpers from `TeamProvisioningService` before building the reader: + +```txt +src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryProofMatching.ts +``` + +Move or wrap the non-mutating parts of: + +- `isOpenCodeRecoveredVisibleReplyCandidate` +- `isOpenCodeVisibleReplyTimestampEligible` +- `getOpenCodeVisibleReplyInboxCandidates` +- `openCodeTaskRefsIncludeAll` +- `normalizeOpenCodeTaskRefsForComparison` +- `openCodeTaskRefKey` + +Then update `TeamProvisioningService` to call those shared helpers. Do this before changing advisory behavior so existing delivery recovery tests prove the extraction did not change semantics. + +Recommended public surface: + +```ts +export interface OpenCodeRuntimeDeliveryProofReaderInput { + teamName: string; + activeMemberKeys: ReadonlySet; + recordsByMember: ReadonlyMap; +} + +export interface OpenCodeRuntimeDeliveryProofIndex { + // Raw batched reads, not final proof decisions. + inboxMessagesByInbox: ReadonlyMap; + taskProgressTimes: ReadonlyMap; + configuredLeadName: string | null; +} + +export interface OpenCodeRuntimeDeliveryRecordProofSnapshot + extends OpenCodeRuntimeDeliveryProofSnapshot { + recordId: string; + visibleReplyCorrelation: OpenCodeDeliveryVisibleReplyCorrelation | null; + visibleReplyMessageId: string | null; + visibleReplyInbox: string | null; + proofDiagnostics: string[]; +} + +export class OpenCodeRuntimeDeliveryProofReader { + async readProofIndex( + input: OpenCodeRuntimeDeliveryProofReaderInput + ): Promise { + // Batch-read candidate inboxes and tasks once per team snapshot/status read. + } + + getProofSnapshot(input: { + memberName: string; + record: OpenCodePromptDeliveryLedgerRecord; + latestSuccessAt: number | null; + proofIndex: OpenCodeRuntimeDeliveryProofIndex; + }): OpenCodeRuntimeDeliveryRecordProofSnapshot { + // Evaluate record-specific visible reply, task progress, and ledger proof. + } +} +``` + +Do not reduce visible proof to only `Map`. That shape cannot represent recovery by observed message id, recovery by `taskRefs`, lead-recipient fallback candidates, or existing `plain_assistant_text` ledger proof. The reader should batch I/O, but proof decisions must stay record-specific. + +Use dependency injection/ports so this helper does not import `TeamProvisioningService`: + +```ts +export interface OpenCodeRuntimeDeliveryProofReaderPorts { + inboxReader: Pick; + taskReader: Pick; + configReader: { readConfig(teamName: string): Promise }; +} +``` + +This keeps dependencies one-way: + +```txt +TeamProvisioningService -> ProofMatching / ProofReader +TeamMemberRuntimeAdvisoryService -> ProofReader +ProofReader -> ports/readers +``` + +Do not let `ProofReader` import `TeamProvisioningService`; that would create the wrong ownership boundary and make tests brittle. + +Keep proof strict: + +- no time-window heuristic message matching; +- no summary matching; +- no passive user reply summary; +- no cross-lane proof; +- no proof from another member; +- no proof from a task without matching `taskRefs`. + +The existing `relayOfMessageId` and task progress checks are the right base shape, but do not only move the current `TeamMemberRuntimeAdvisoryService.readVisibleOpenCodeRuntimeDeliveryReplyTimes()` as-is. That code is narrower than the delivery recovery logic in `TeamProvisioningService`. + +The proof reader must mirror the read-only parts of these current provisioning paths: + +- `findOpenCodeVisibleReplyByRelayOfMessageId` +- `findOpenCodeVisibleReplyByObservedMessageId` +- `findOpenCodeVisibleReplyByTaskRefs` +- `isOpenCodeRecoveredVisibleReplyCandidate` +- `isOpenCodeVisibleReplyTimestampEligible` +- `openCodeTaskRefsIncludeAll` + +It should recognize all existing visible proof correlations: + +- `relayOfMessageId` +- `direct_child_message_send` +- `plain_assistant_text` + +Do not call mutating repair/materialization helpers from snapshot advisory reads: + +- no `correlateRuntimeDeliveryReply` +- no taskRef merge writes +- no plain-text visible reply materialization +- no ledger mutation from `getMemberAdvisories()` + +If proof needs mutation to become durable, that belongs to the delivery/observe path. The advisory proof reader is read-only and should only suppress when proof is already visible in inbox/task/ledger state. + +Timestamp rule: + +- visible reply proof should use the existing `isOpenCodeVisibleReplyTimestampEligible` semantics, not `reply.timestamp > failedAt`; +- task progress proof should compare against the original prompt/inbox time, not terminal `failedAt`, because the terminal row can be written after the teammate already produced task progress and the app observed it late; +- use a small skew tolerance for visible replies, matching the existing `message.timestamp + 5s >= inboxTimestamp` behavior; +- never accept proof older than the prompt/inbox time unless it is explicitly correlated by the ledger's existing `visibleReplyMessageId`. + +This prevents false warnings when a reply/progress existed for the prompt but the terminal failure row was written slightly later. + +Performance constraints: + +- `TeamDataService` gives member runtime advisory loading only `250ms` per snapshot. +- The proof reader must support batched member reads, as the current service does. +- Do not call the proof reader from the delivery hot path. +- For single-record status reads in Phase 1.3, wrap proof reads with a small budget and fall back to fact-only impact if the budget is exceeded. + +## Integration - Member Advisory + +Current `buildOpenCodeDeliveryAdvisoryFromRecords()` should become policy-driven. + +Pseudo-flow: + +```ts +private buildOpenCodeDeliveryAdvisoryFromRecords( + memberName: string, + records: readonly OpenCodePromptDeliveryLedgerRecord[], + now: number, + proofIndex: OpenCodeRuntimeDeliveryProofIndex +): MemberRuntimeAdvisory | null { + const ordered = orderRecords(records); + const latestSuccessAt = getLatestSuccessAt(ordered); + const latestCandidate = findLatestPotentialError(ordered, now); + if (!latestCandidate) return null; + + const proof = this.proofReader.getProofSnapshot({ + memberName, + record: latestCandidate, + latestSuccessAt, + proofIndex, + }); + + const decision = decideOpenCodeRuntimeDeliveryAdvisory({ + record: latestCandidate, + proof, + nowMs: now, + }); + + if (decision.action !== 'surface') { + return null; + } + + return { + kind: 'api_error', + observedAt: decision.observedAt ?? new Date(now).toISOString(), + reasonCode: decision.reasonCode, + message: decision.message, + }; +} +``` + +Important: `defer` returns `null` for member card. The card should not show a temporary "checking" badge from Phase 1.2, because this is a teammate card, not a direct send composer. + +## Integration - Notifications And Lead Notice + +Replace the current boolean logic in `TeamProvisioningService` with two decisions: + +1. a cheap delivery-event impact decision that does not scan inboxes/tasks; +2. a side-effect decision that preserves current notification scope. + +Current risky behavior: + +```ts +const shouldNotifyTerminalFailure = + event === 'opencode_prompt_delivery_terminal_failure' && record.status === 'failed_terminal'; + +if (shouldNotifyTerminalFailure || shouldNotifyActionRequiredRetry) { + void this.fireOpenCodeRuntimeDeliveryErrorNotification(record); + return; +} +``` + +Do not call the full proof reader from this hot path. `logOpenCodePromptDeliveryEvent()` can run inside relay/watchdog delivery flow, and proof reads may scan inboxes and tasks. The hot path only needs to know: + +- no selected reason -> no side effects; +- hard/action-required reason -> keep existing immediate notification behavior where the event is already notification-eligible; +- generic proof failure -> schedule delayed proof recheck and do not notify immediately. + +Recommended cheap classifier: + +```ts +private classifyOpenCodeRuntimeDeliveryEventImpact( + event: string, + record: OpenCodePromptDeliveryLedgerRecord +): OpenCodeRuntimeDeliveryAdvisoryDecision { + const reason = selectOpenCodeRuntimeDeliveryReason(record); + if (!reason) { + return { action: 'suppress', reason: 'no_reason' }; + } + + const recordTimeMs = getOpenCodeRuntimeDeliveryRecordTimeMs(record); + if (isHardOpenCodeRuntimeDeliveryReason(record, reason)) { + return { + action: 'surface', + reason: 'hard_error', + severity: 'error', + reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(reason), + message: reason, + observedAt: new Date(recordTimeMs).toISOString(), + }; + } + + if (record.status === 'failed_terminal' && isGenericOpenCodeRuntimeDeliveryProofFailure(record, reason)) { + const terminalAt = getOpenCodeRuntimeDeliveryTerminalTimeMs(record); + return { + action: 'defer', + reason: 'proof_pending', + observedAt: new Date(terminalAt).toISOString(), + nextReviewAt: new Date(terminalAt + OPENCODE_RUNTIME_DELIVERY_GENERIC_PROOF_GRACE_MS).toISOString(), + }; + } + + if (isGenericOpenCodeRuntimeDeliveryProofFailure(record, reason)) { + return { action: 'suppress', reason: 'not_user_visible' }; + } + + if (record.status !== 'failed_terminal') { + return { action: 'suppress', reason: 'not_user_visible' }; + } + + // Unknown terminal non-generic failures remain visible, but keep this branch narrow + // and covered by tests so proof-only states cannot fall through here. + return { + action: 'surface', + reason: 'hard_error', + severity: 'error', + reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(reason), + message: reason, + observedAt: new Date(recordTimeMs).toISOString(), + }; +} +``` + +Recommended event side-effect policy: + +```ts +private decideOpenCodeRuntimeDeliveryEventSideEffects(input: { + event: string; + record: OpenCodePromptDeliveryLedgerRecord; + impact: OpenCodeRuntimeDeliveryAdvisoryDecision; +}): OpenCodeRuntimeDeliveryEventSideEffectDecision { + if (input.impact.action === 'suppress') { + return { + emitMemberAdvisoryRefresh: false, + notifyHuman: false, + notifyLead: false, + }; + } + + if (input.impact.action === 'defer') { + return { + emitMemberAdvisoryRefresh: true, + scheduleReviewAt: input.impact.nextReviewAt, + notifyHuman: false, + notifyLead: false, + }; + } + + const terminalFailureEvent = + input.event === 'opencode_prompt_delivery_terminal_failure' && + input.record.status === 'failed_terminal'; + const actionRequiredBeforeTerminal = + input.record.status !== 'failed_terminal' && + isActionRequiredOpenCodeRuntimeDeliveryReason(input.impact.message); + + return { + emitMemberAdvisoryRefresh: true, + notifyHuman: input.impact.severity === 'error' && (terminalFailureEvent || actionRequiredBeforeTerminal), + notifyLead: input.impact.severity === 'error' && (terminalFailureEvent || actionRequiredBeforeTerminal), + }; +} +``` + +This preserves the current notification blast radius. For example, an attachment-payload terminal record can still surface as an advisory/direct-send diagnostic without suddenly generating a new OS notification path unless the event was already notification-eligible. + +Important: deferred generic proof failures still emit `member-advisory` refresh. That refresh is needed to clear stale cached hard advisories or cached warnings immediately when the newest record is now `defer/null`. It is a cache/UI refresh only: + +- no desktop notification; +- no lead notice; +- do not mark the deferred record as surfaced in the notification/advisory dedupe map; +- do not let this refresh block the delayed post-grace `surface/warning` refresh. + +Prefer separate emit helpers: + +```ts +private emitOpenCodeRuntimeDeliveryAdvisoryRefreshEvent(record: OpenCodePromptDeliveryLedgerRecord): void { + // Invalidates card/snapshot caches. Optional short refresh dedupe is ok. +} + +private emitOpenCodeRuntimeDeliveryAdvisorySurfaceEvent(record: OpenCodePromptDeliveryLedgerRecord): void { + // Uses existing surface dedupe and can represent a visible advisory. +} +``` + +Do not reuse `emitOpenCodeRuntimeDeliveryAdvisorySurfaceEvent()` for `defer`, because the existing dedupe key includes record id/reason and can suppress the later post-grace warning refresh. + +New handler: + +```ts +private async handleOpenCodeRuntimeDeliveryAdvisorySideEffects( + event: string, + record: OpenCodePromptDeliveryLedgerRecord +): Promise { + const impact = this.classifyOpenCodeRuntimeDeliveryEventImpact(event, record); + const effects = this.decideOpenCodeRuntimeDeliveryEventSideEffects({ event, record, impact }); + + if (effects.scheduleReviewAt) { + this.scheduleOpenCodeRuntimeDeliveryAdvisoryReview(record, effects.scheduleReviewAt); + } + if (effects.emitMemberAdvisoryRefresh) { + if (impact.action === 'defer') { + this.emitOpenCodeRuntimeDeliveryAdvisoryRefreshEvent(record); + } else { + this.emitOpenCodeRuntimeDeliveryAdvisorySurfaceEvent(record); + } + } + if (effects.notifyHuman || effects.notifyLead) { + await this.fireOpenCodeRuntimeDeliveryNotificationFromDecision(record, impact, effects); + } +} +``` + +`logOpenCodePromptDeliveryEvent()` should call this asynchronously after logging. + +Do not block the delivery path on notification writes: + +```ts +void this.handleOpenCodeRuntimeDeliveryAdvisorySideEffects(event, record).catch((error) => { + logger.warn(`[${record.teamName}] Failed to handle OpenCode runtime delivery advisory: ${getErrorMessage(error)}`); +}); +``` + +### Delayed Review Timer + +Add a narrow timer map: + +```ts +private readonly openCodeRuntimeDeliveryAdvisoryReviewTimers = new Map>(); +``` + +Timer key: + +```ts +private getOpenCodeRuntimeDeliveryAdvisoryReviewKey(record: OpenCodePromptDeliveryLedgerRecord): string { + return `${record.teamName}::${record.laneId}::${record.memberName}::${record.id}`; +} +``` + +Scheduler: + +```ts +private scheduleOpenCodeRuntimeDeliveryAdvisoryReview( + record: OpenCodePromptDeliveryLedgerRecord, + nextReviewAt: string | undefined +): void { + const reviewAtMs = Date.parse(nextReviewAt ?? ''); + if (!Number.isFinite(reviewAtMs)) return; + + const delayMs = Math.max(500, Math.min(reviewAtMs - Date.now(), 180_000)); + const key = this.getOpenCodeRuntimeDeliveryAdvisoryReviewKey(record); + const existing = this.openCodeRuntimeDeliveryAdvisoryReviewTimers.get(key); + if (existing) clearTimeout(existing); + + const timer = setTimeout(() => { + this.openCodeRuntimeDeliveryAdvisoryReviewTimers.delete(key); + void this.recheckOpenCodeRuntimeDeliveryAdvisory(record).catch((error) => { + logger.warn(`[${record.teamName}] Delayed OpenCode advisory recheck failed: ${getErrorMessage(error)}`); + }); + }, delayMs); + + timer.unref?.(); + this.openCodeRuntimeDeliveryAdvisoryReviewTimers.set(key, timer); +} +``` + +Delayed recheck must re-read current ledger/proof before emitting anything: + +```ts +private async recheckOpenCodeRuntimeDeliveryAdvisory( + original: OpenCodePromptDeliveryLedgerRecord +): Promise { + const ledger = this.createOpenCodePromptDeliveryLedger(original.teamName, original.laneId); + const current = (await ledger.list()).find((record) => record.id === original.id); + if (!current) return; + + const decision = await this.decideOpenCodeRuntimeDeliveryAdvisoryForRecord(current); + if (decision.action === 'defer') { + this.scheduleOpenCodeRuntimeDeliveryAdvisoryReview(current, decision.nextReviewAt); + return; + } + if (decision.action === 'suppress') { + this.emitOpenCodeRuntimeDeliveryAdvisoryRefreshEvent(current); + return; + } + + this.emitOpenCodeRuntimeDeliveryAdvisorySurfaceEvent(current); + // Delayed generic proof warnings do not notify in Phase 1.2. + // Delayed review is allowed to refresh cards, not to retro-fire desktop + // notifications or lead notices. +} +``` + +Delayed review should emit `member-advisory` only. If a re-read somehow discovers a hard diagnostic that was not present in the original generic event, surface it in the snapshot/card and log it, but do not synthesize a new desktop notification or lead notice from the timer. Notification eligibility remains tied to live delivery events. + +This timer is an optimization for visible teams. Correctness must not depend on it: + +- if the app restarts, `TeamMemberRuntimeAdvisoryService` recomputes by wall clock; +- if the timer never fires, the next team snapshot still surfaces the confirmed warning; +- hard errors still notify immediately. + +### Delayed Review Timer Lifecycle + +The review timer must follow the same cleanup discipline as `openCodePromptDeliveryWatchdogTimers`. + +Add a helper: + +```ts +private clearOpenCodeRuntimeDeliveryAdvisoryReviewTimers(teamName?: string): void { + for (const [key, timer] of this.openCodeRuntimeDeliveryAdvisoryReviewTimers) { + if (teamName && !key.startsWith(`${teamName}::`)) continue; + clearTimeout(timer); + this.openCodeRuntimeDeliveryAdvisoryReviewTimers.delete(key); + } +} +``` + +Call it from every path that currently clears per-team prompt delivery watchdog timers: + +- `cleanupRun(teamName, ...)` and any team stop/cancel cleanup path; +- permanent team deletion cleanup; +- service shutdown/dispose cleanup; +- launch failure cleanup when a partially started team is abandoned. + +The timer callback must also revalidate current ownership before emitting: + +```ts +if (!this.teamRunStates.has(original.teamName)) return; +if (!this.isOpenCodeLaneStillCurrent(original.teamName, original.laneId, original.memberName)) return; +``` + +`isOpenCodeLaneStillCurrent()` must compare more than member name and lane id. Include `record.runId` when it is present, because a lane id can be reused across a respawn while the old ledger record still exists on disk. + +Use existing state/config readers for the actual implementation. The point is to prevent a delayed proof-missing advisory from firing after: + +- the team was stopped; +- the lane was respawned with a new process; +- the member was removed or renamed; +- a launch failed and cleaned up the run. + +Do not persist these timers. After app restart, snapshot reads can show a warning if it is still true, but startup must not retro-fire desktop notifications or lead notices for old generic proof gaps. Live event side effects stay event-driven. + +## Notification Copy + +Hard error human notification: + +```txt +Team : @ hit an OpenCode runtime delivery error while handling #. +``` + +Hard error lead notice: + +```txt +System notice: OpenCode teammate @ hit a runtime delivery error while handling #. Reason: . Treat @ as unavailable for that work until retry or restart succeeds. Do not message the human user solely because of this notice unless user action is required. +``` + +Generic proof warning: + +- no human notification in Phase 1.2; +- no lead notice in Phase 1.2; +- member card warning only after grace. + +If soft lead notice is added later, use different copy: + +```txt +System notice: OpenCode delivery proof is still missing for @ while handling #. Do not assume the teammate is unavailable. Check task progress or wait for a correlated reply before escalating. +``` + +Do not use this copy in Phase 1.2 unless there is a product decision to notify lead for proof gaps. + +## Reason Code Mapping + +Move or share `classifyRetryReason()` from `TeamMemberRuntimeAdvisoryService`. + +Recommended name: + +```ts +export function classifyOpenCodeRuntimeDeliveryReasonCode( + message: string | undefined +): MemberRuntimeAdvisory['reasonCode']; +``` + +`MemberRuntimeAdvisory['reasonCode']` already includes `protocol_proof_missing`; do not add a duplicate local enum or renderer-only string. The implementation work is to route generic proof failures to that existing reason code after grace. + +Do not let fallback `OpenCode returned an empty assistant turn.` classify as `backend_error`. It should classify as `protocol_proof_missing` after grace. + +## Edge Cases + +### Hard diagnostic mixed with generic state + +Example: + +```json +{ + "status": "failed_terminal", + "responseState": "empty_assistant_turn", + "diagnostics": [ + "Latest assistant message msg_1 failed with APIError - Insufficient credits.", + "empty_assistant_turn" + ] +} +``` + +Expected: + +- immediate `surface/error`; +- `reasonCode: "quota_exhausted"`; +- human notification yes; +- lead notice yes; +- no grace. + +### Late visible reply after terminal + +Expected: + +- before grace expires: `defer`; +- after reply appears: `suppress`; +- member card never shows error; +- delayed recheck emits `member-advisory` refresh to clear stale cached values if needed. + +### Visible reply recovered by observed message id or task refs + +Expected: + +- suppress when an existing visible reply is recovered by `visibleReplyMessageId`; +- suppress when an existing visible reply is recovered by matching `taskRefs` and semantic sufficiency; +- support `direct_child_message_send` correlation; +- do not require `source: "runtime_delivery"` when the current recovery logic accepts a missing source; +- do not mutate inbox messages from the advisory snapshot path. + +### Plain text reply already materialized + +Expected: + +- if the ledger already has `visibleReplyCorrelation: "plain_assistant_text"` and a visible reply id/inbox, suppress; +- if plain text could be materialized but has not been materialized yet, do not write from the advisory path; +- direct-send/status path may still use the delivery observer to materialize later. + +### Late task progress after terminal + +Expected: + +- suppress only if task id matches record `taskRefs`; +- author/actor matches member; +- progress timestamp is after the original prompt/inbox time; +- no cross-task suppression. + +### Newer success after older failure + +Expected: + +- suppress older failure if newer terminal success exists for same member/lane; +- this prevents a historical failed row from dominating a recovered member card. + +### No proof after grace + +Expected: + +- `surface/warning`; +- `reasonCode: "protocol_proof_missing"`; +- label should render as `OpenCode proof missing`; +- no desktop notification; +- no hard lead notice. + +### Removed member or stopped lane + +Expected: + +- removed members are already filtered by caller; +- stopped lane should not be scanned for fresh advisories; +- do not revive old stopped-lane errors on active team cards. + +### Payload mismatch and attachment payload unavailable + +Expected: + +- immediate hard error; +- not delayed as generic proof; +- these are app/runtime data consistency problems, not late-proof problems. + +### Permission blocked + +Expected: + +- if action required, immediate hard warning/error path; +- do not retry automatically while blocked; +- do not classify as proof pending. + +### Cache behavior + +Current advisory cache TTL is 30 seconds. + +Required behavior: + +- deferred generic proof returns `null`; +- defer event emits `member-advisory` refresh immediately to clear stale cached hard/warning advisories; +- cache may store null for up to TTL after that refresh; +- delayed `member-advisory` event at grace expiry invalidates cache; +- runtime reply event already invalidates cache through `emitRuntimeDeliveryReplyAdvisoryRefresh`. + +### Worker cache invalidation + +This is critical. + +`team:getData` normally prefers `team-data-worker`. The worker owns a separate `TeamDataService` instance, and that instance owns a separate `TeamMemberRuntimeAdvisoryService` with its own 30 second member/batch advisory cache. Invalidating only the main-thread advisory service is not enough. + +Add a worker request: + +```ts +export interface InvalidateMemberRuntimeAdvisoryPayload { + teamName: string; + memberName?: string; +} + +export type TeamDataWorkerRequest = + | { id: string; op: 'invalidateMemberRuntimeAdvisory'; payload: InvalidateMemberRuntimeAdvisoryPayload } + // existing variants +``` + +Worker handling: + +```ts +case 'invalidateMemberRuntimeAdvisory': { + if (msg.payload.memberName) { + teamDataService.invalidateMemberRuntimeAdvisory(msg.payload.teamName, msg.payload.memberName); + } else { + teamDataService.invalidateTeamRuntimeAdvisories(msg.payload.teamName); + } + respond({ id: msg.id, ok: true, result: null, diag: buildDiag() }); + break; +} +``` + +Add public invalidators: + +```ts +// TeamDataService +invalidateMemberRuntimeAdvisory(teamName: string, memberName: string): void { + this.memberRuntimeAdvisoryService.invalidateMemberAdvisory(teamName, memberName); +} + +invalidateTeamRuntimeAdvisories(teamName: string): void { + this.memberRuntimeAdvisoryService.invalidateTeamAdvisories(teamName); +} + +// TeamMemberRuntimeAdvisoryService +invalidateTeamAdvisories(teamName: string): void { + // clear member cache entries, batch cache, in-flight batch requests, and bump generation +} +``` + +Then update the existing main invalidator wiring: + +```ts +teamProvisioningService.setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => { + teamMemberRuntimeAdvisoryService.invalidateMemberAdvisory(teamName, memberName); + getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(teamName, memberName); +}); +``` + +Also update the `member-advisory` team-change forwarding path to invalidate the worker cache when the event was not emitted through `TeamProvisioningService` in the current process. + +Without this, the renderer can receive a `member-advisory` refresh event, call `team:getData`, hit the worker, and still see a stale cached `null` or stale advisory for up to 30 seconds. + +`TeamDataWorkerClient.invalidateMemberRuntimeAdvisory()` must also clear in-flight `getTeamData` requests for the team: + +```ts +invalidateMemberRuntimeAdvisory(teamName: string, memberName?: string): void { + if (!SAFE_NAME_RE.test(teamName)) return; + this.clearTeamDataInFlightForTeam(teamName); + this.postBestEffort('invalidateMemberRuntimeAdvisory', { teamName, memberName }); +} +``` + +`postBestEffort()` currently returns immediately when the worker has not been created. That is acceptable for advisory invalidation because an uncreated worker has no worker-side advisory cache yet. The main-thread invalidation still must happen first, and `clearTeamDataInFlightForTeam(teamName)` still matters because main may be holding a worker-backed promise that was started before the invalidation event. + +Also update `summarizeWorkerRequest()` so diagnostics and logs do not show the new operation as an unknown worker request: + +```ts +case 'invalidateMemberRuntimeAdvisory': + return { + teamName: request.payload.teamName, + memberName: request.payload.memberName, + }; +``` + +When the worker handles `invalidateTeamConfig`, clear runtime advisories for that team too. Team config changes can change the member roster, provider, model, or lane metadata, and a cached advisory from the previous shape should not survive a config invalidation: + +```ts +case 'invalidateTeamConfig': { + teamConfigReader.invalidateTeam(msg.payload.teamName); + teamDataService.invalidateMessageFeed(msg.payload.teamName); + teamDataService.invalidateTeamRuntimeAdvisories(msg.payload.teamName); + respond({ id: msg.id, ok: true, result: null, diag: buildDiag() }); + break; +} +``` + +For `member-advisory` events in `forwardTeamChange`, invalidate before sending the renderer event. The renderer often responds to `member-advisory` by immediately calling `team:getData`; if the event is sent first, the refresh can race and read stale worker cache. + +`TeamChangeEvent` currently has `type`, `teamName`, and optional string `detail`, but no structured `memberName`. Do not parse `memberName` from colon-delimited `detail`; member names are not a stable serialization format. In this forwarding path, clear the whole team's advisory cache: + +```ts +if (event.type === 'member-advisory') { + teamMemberRuntimeAdvisoryService.invalidateTeamAdvisories(event.teamName); + getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(event.teamName); +} + +safeSendToRenderer(mainWindow, TEAM_CHANGE, event); +httpServer?.broadcast('team-change', event); +``` + +Keep precise member invalidation in call sites that already have a real `memberName`, such as `setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => ...)`. + +Do not widen `TeamProvisioningService.setMemberRuntimeAdvisoryInvalidator()` to accept an optional member name just to support team-wide invalidation. That callback is currently a precise member-level contract. Team-wide invalidation belongs in the main `forwardTeamChange` wiring and in `TeamDataService`/worker invalidators. + +This does not cancel a worker request that is already running and already awaited by a renderer refresh. Keep the existing `member-advisory` safety refresh that calls `refreshTeamData(teamName)` without dedup. That second fresh read is the guard that overwrites any stale in-flight snapshot that resolves after the advisory event. + +### Dedupe behavior + +Existing event dedupe key includes record id and reason key. With policy: + +- do not mark deferred records as "sent"; +- otherwise delayed surface could be deduped away; +- dedupe notification/advisory "surface" events only after `surface`; +- deferred `member-advisory` refreshes may use a separate short refresh dedupe key, but must not share the surface dedupe key. + +## Implementation Steps + +1. Add `OpenCodeRuntimeDeliveryAdvisoryPolicy.ts`. +2. Export generic/hard classification helpers from diagnostics or add policy-local helpers. +3. Move reason-code classification into a shared helper. +4. Add `OpenCodeRuntimeDeliveryProofReader.ts` and extract read-only proof matching from both `TeamMemberRuntimeAdvisoryService` and the non-mutating parts of `TeamProvisioningService` visible-reply recovery. +5. Update `TeamMemberRuntimeAdvisoryService` to use proof reader plus policy. +6. Add advisory invalidation methods to `TeamDataService`, `TeamMemberRuntimeAdvisoryService`, `TeamDataWorkerClient`, and `team-data-worker`. +7. Update `TeamProvisioningService.logOpenCodePromptDeliveryEvent()` to use cheap event impact classification and narrow side-effect policy. +8. Add delayed recheck timers in `TeamProvisioningService`. +9. Keep renderer mostly unchanged, except tests may need copy expectations if `protocol_proof_missing` becomes the normal reason code after grace. +10. Add tests. + +## Tests + +### New policy unit tests + +File: + +```txt +test/main/services/team/OpenCodeRuntimeDeliveryAdvisoryPolicy.test.ts +``` + +Cases: + +```ts +it('defers recent terminal empty assistant proof failures', () => {}); +it('surfaces old terminal empty assistant proof failures as protocol warnings', () => {}); +it('classifies formatted empty assistant fallback text as protocol proof missing', () => {}); +it('surfaces quota diagnostics immediately even when responseState is empty_assistant_turn', () => {}); +it('does not surface non-terminal generic proof states as hard errors', () => {}); +it('does not let proof-only formatted reasons fall through to unknown hard error', () => {}); +it('suppresses when a visible runtime reply is correlated and timestamp-eligible for the prompt', () => {}); +it('suppresses when a visible reply is recovered by observed message id', () => {}); +it('suppresses when a visible reply is recovered by taskRefs and semantic sufficiency', () => {}); +it('suppresses when the ledger already has plain_assistant_text visible reply proof', () => {}); +it('does not mutate inboxes or ledgers while reading advisory proof snapshots', () => {}); +it('uses prompt inbox time rather than failedAt for late proof timestamp eligibility', () => {}); +it('suppresses when task progress proof is newer than the original prompt inbox time', () => {}); +it('suppresses when a newer terminal success exists', () => {}); +it('treats payload mismatch as immediate hard error', () => {}); +``` + +### Member advisory service tests + +Update: + +```txt +test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts +``` + +Add/adjust: + +```ts +it('does not show a member advisory for recent generic OpenCode proof failure', async () => {}); +it('shows protocol proof missing after generic proof failure grace expires', async () => {}); +it('keeps hard OpenCode quota advisory immediate inside the grace window', async () => {}); +it('does not cache deferred null past a member-advisory recheck event', async () => {}); +it('invalidates team batch advisory cache when a single member advisory is invalidated', async () => {}); +``` + +Existing test `classifies terminal OpenCode protocol proof failures as warnings` must be changed to use an old `failedAt`, not `new Date()`. + +### Provisioning notification tests + +Update: + +```txt +test/main/services/team/TeamProvisioningService.test.ts +test/main/services/team/TeamProvisioningServiceRelay.test.ts +``` + +Add: + +```ts +it('does not fire OpenCode runtime notification for recent terminal empty assistant turn', async () => {}); +it('does not notify lead for recent terminal generic proof failure', async () => {}); +it('emits member-advisory refresh for deferred generic proof failure to clear stale card cache', async () => {}); +it('does not mark deferred member-advisory refresh as surfaced for delayed-warning dedupe', async () => {}); +it('fires hard OpenCode runtime notification for insufficient credits immediately', async () => {}); +it('does not use hard unavailable lead copy for protocol proof missing', async () => {}); +it('schedules a member advisory recheck for deferred generic proof failure', async () => {}); +it('clears delayed advisory recheck timers when the team run is cleaned up', async () => {}); +it('does not emit a delayed advisory for a removed member or replaced lane', async () => {}); +it('does not fire desktop or lead notification from delayed advisory recheck', async () => {}); +it('does not broaden notification scope for attachment payload terminal records', async () => {}); +``` + +Use fake timers for delayed recheck: + +```ts +vi.useFakeTimers(); +// terminal generic record at t0 +// assert no notification +await vi.advanceTimersByTimeAsync(120_000); +// assert member-advisory event emitted, notification still not emitted +``` + +### Renderer tests + +Most renderer tests should remain unchanged for cards if they already treat `protocol_proof_missing` as warning. + +Expected affected tests: + +- `test/renderer/utils/memberHelpers.test.ts` +- `test/renderer/components/team/members/MemberCard.test.tsx` +- `test/renderer/components/team/members/MemberHoverCard.test.tsx` +- `test/renderer/components/team/members/MemberDetailHeader.test.tsx` + +No Phase 1.2 change should be required for: + +- `test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts` +- `test/renderer/store/teamSlice.test.ts` +- `OpenCodeDeliveryWarning.test.tsx` + +Those belong to Phase 1.3. + +### Worker invalidation tests + +Update or add: + +```txt +test/main/services/team/TeamDataWorkerClient.test.ts +test/main/workers/team-data-worker.test.ts +``` + +Cases: + +```ts +it('posts invalidateMemberRuntimeAdvisory to the worker', async () => {}); +it('clears worker-side member runtime advisory cache on invalidateMemberRuntimeAdvisory', async () => {}); +it('clears worker-side advisory cache during invalidateTeamConfig', async () => {}); +it('invalidates worker advisory cache before forwarding member-advisory events to the renderer', async () => {}); +it('summarizes invalidateMemberRuntimeAdvisory worker requests in diagnostics', async () => {}); +``` + +If there is no worker harness for this path, cover the public invalidator on `TeamDataService` and `TeamMemberRuntimeAdvisoryService`, then add an integration test around `forwardTeamChange` or the provisioning invalidator wiring. + +## Verification + +Focused: + +```bash +pnpm vitest run test/main/services/team/OpenCodeRuntimeDeliveryAdvisoryPolicy.test.ts +pnpm vitest run test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts +pnpm vitest run test/main/services/team/TeamProvisioningService.test.ts --testNamePattern "OpenCode runtime" +pnpm vitest run test/main/services/team/TeamProvisioningServiceRelay.test.ts --testNamePattern "OpenCode" +``` + +Broader: + +```bash +pnpm vitest run test/main/services/team/TeamProvisioningService.test.ts +pnpm vitest run test/main/services/team/TeamProvisioningServiceRelay.test.ts +pnpm vitest run test/main/services/team/TeamDataService.test.ts +pnpm vitest run test/renderer/utils/memberHelpers.test.ts +pnpm typecheck --pretty false +git diff --check +``` + +## Rollout Notes + +- Do not change `failed_terminal` semantics. +- Do not mark failed OpenCode inbox rows read. +- Do not synthesize teammate replies. +- Do not treat generic proof failure as participant unavailable. +- Do not add heuristic proof matching by time or summary. +- Keep the policy pure and unit tested. +- Keep notification side effects outside the policy. + +## Acceptance Criteria + +- A recent `failed_terminal / empty_assistant_turn` record no longer creates a member card error. +- The same record with `Insufficient credits` still creates an immediate error. +- A late `runtime_delivery` reply suppresses the advisory. +- A late same-task progress event suppresses the advisory. +- A generic proof failure older than grace appears as warning `protocol_proof_missing`, not backend error. +- Generic proof failure does not create a desktop notification or hard lead notice. +- Hard OpenCode runtime errors still notify as before. diff --git a/docs/team-management/opencode-runtime-delivery-status-ux-phase-1-3-plan.md b/docs/team-management/opencode-runtime-delivery-status-ux-phase-1-3-plan.md new file mode 100644 index 00000000..45230e39 --- /dev/null +++ b/docs/team-management/opencode-runtime-delivery-status-ux-phase-1-3-plan.md @@ -0,0 +1,1159 @@ +# OpenCode Runtime Delivery User-Visible Status - Phase 1.3 Plan + +## Summary + +Extend the Phase 1.2 advisory policy into the direct send/composer runtime delivery status path. + +Phase 1.2 fixes member cards, snapshots, notifications, and lead notices. Phase 1.3 makes the direct user send UX use the same user-impact classification, so a temporary generic proof gap does not show as `OpenCode runtime delivery failed` while the app is still inside the late-proof window. + +Recommended scope: 🎯 8 🛡️ 8 🧠 8 - roughly `650-900` changed lines including tests. + +## Why This Is Separate + +The direct send path has an existing public-ish shared type: + +```ts +SendMessageResult.runtimeDelivery +OpenCodeRuntimeDeliveryStatus +``` + +Renderer code currently maps: + +```ts +runtimeDelivery.delivered === false -> failed warning +runtimeDelivery.responsePending === true -> pending warning +``` + +Changing `delivered` semantics directly would be risky because it is already used by store actions and tests. Phase 1.3 should add a user-visible impact field while preserving the old ledger fact fields for compatibility. + +## Current Direct Send Paths + +There are two runtime status entry points: + +1. Immediate send result path: + +```txt +src/main/ipc/teams.ts +handleSendMessage() + -> provisioning.relayOpenCodeMemberInboxMessages() + -> result.runtimeDelivery = relay.lastDelivery +``` + +2. Later polling path: + +```txt +src/main/services/team/TeamProvisioningService.ts +getOpenCodeRuntimeDeliveryStatus() + -> toOpenCodeRuntimeDeliveryStatus(record) +``` + +Renderer consumers: + +```txt +src/renderer/store/slices/teamSlice.ts +src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts +src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx +src/renderer/components/team/messages/MessageComposer.tsx +src/renderer/components/team/dialogs/SendMessageDialog.tsx +``` + +Important: both backend paths must use the same user-impact contract. If only polling is fixed, the initial composer warning can still flash the old failure text. + +## Shared Type Extension + +Extend `SendMessageResult.runtimeDelivery` in `src/shared/types/team.ts`. + +Recommended additive fields: + +```ts +export type OpenCodeRuntimeDeliveryUserVisibleState = + | 'none' + | 'checking' + | 'warning' + | 'error'; + +export interface OpenCodeRuntimeDeliveryUserVisibleImpact { + state: OpenCodeRuntimeDeliveryUserVisibleState; + reasonCode?: MemberRuntimeAdvisory['reasonCode']; + message?: string; + observedAt?: string; + nextReviewAt?: string; +} +``` + +Inside `SendMessageResult.runtimeDelivery`: + +```ts +userVisibleImpact?: OpenCodeRuntimeDeliveryUserVisibleImpact; +``` + +Why a nested object: + +- avoids overloading `delivered`; +- keeps old fields readable for debugging; +- allows renderer to prefer the new impact but fall back to old behavior; +- makes Phase 1.3 backwards compatible with older IPC payloads during development. + +## State Semantics + +### `none` + +No user warning should be shown. + +Examples: + +- successful visible reply proof; +- newer success suppressed older failure; +- late visible reply or task progress proof exists. + +### `checking` + +The ledger may already say terminal, but the user-facing policy is still in the grace window for generic proof. + +Examples: + +- recent `failed_terminal / empty_assistant_turn`; +- recent `failed_terminal / prompt_delivered_no_assistant_message`; +- recent `failed_terminal / visible_reply_still_required`; +- UI timeout pending; +- queued behind older OpenCode delivery. + +Renderer copy should be non-scary: + +```txt +OpenCode delivery is still being checked. Message was saved and will be observed before retry if needed. +``` + +### `warning` + +Grace expired and proof is still missing, but this is not a hard provider/runtime error. + +Examples: + +- old `failed_terminal / empty_assistant_turn` with no late proof; +- old `failed_terminal / non_visible_tool_without_task_progress` with no same-task progress. + +Renderer copy: + +```txt +OpenCode reply could not be verified. Message was saved to inbox, but the app did not find a correlated reply or progress proof. +``` + +If there is a specific message: + +```txt +OpenCode reply could not be verified. Message was saved to inbox, but the app did not find a correlated reply or progress proof. Detail: OpenCode returned an empty assistant turn. +``` + +### `error` + +Hard delivery error. This is the old scary warning path and remains valid. + +Examples: + +- insufficient credits; +- invalid API key; +- runtime bridge unavailable; +- payload mismatch; +- attachment payload unavailable; +- recipient unavailable/removed for the direct send recovery path. + +Recipient unavailable/removed should not create a member-card runtime advisory or notification by itself. For direct send UX it can still be an `error` impact so the draft is preserved and the user can choose another recipient. + +Renderer copy: + +```txt +OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. +``` + +## Backend Mapping + +Add a converter from Phase 1.2 decision to shared `userVisibleImpact`. + +There are two backend mapping modes: + +1. **Full status mapping** for explicit status reads and polling. This may read proof sources with a small budget. +2. **Immediate result mapping** for `sendMessage` IPC. This must stay cheap and should not scan tasks/inboxes before returning the send result. + +Example: + +```ts +function toOpenCodeRuntimeDeliveryUserVisibleImpact( + decision: OpenCodeRuntimeDeliveryAdvisoryDecision +): OpenCodeRuntimeDeliveryUserVisibleImpact { + if (decision.action === 'suppress') { + return { state: 'none' }; + } + if (decision.action === 'defer') { + return { + state: 'checking', + observedAt: decision.observedAt, + nextReviewAt: decision.nextReviewAt, + }; + } + return { + state: decision.severity === 'error' ? 'error' : 'warning', + reasonCode: decision.reasonCode, + message: decision.message, + observedAt: decision.observedAt, + }; +} +``` + +Special immediate statuses that may not have a ledger record: + +```ts +function getImmediateOpenCodeRuntimeDeliveryImpact(input: { + delivered?: boolean; + responsePending?: boolean; + reason?: string; + diagnostics?: string[]; + queuedBehindMessageId?: string; +}): OpenCodeRuntimeDeliveryUserVisibleImpact { + const observedAt = new Date().toISOString(); + if (input.responsePending === true) { + return { state: 'checking', observedAt }; + } + if (input.queuedBehindMessageId) { + return { state: 'checking', observedAt }; + } + if (input.reason === 'opencode_runtime_delivery_ui_timeout_pending') { + return { state: 'checking', observedAt }; + } + if (input.reason === 'opencode_delivery_response_pending') { + return { state: 'checking', observedAt }; + } + if ( + input.reason === 'opencode_runtime_not_active' && + (input.diagnostics ?? []).some((line) => + line.toLowerCase().includes('will be retried after runtime check-in') + ) + ) { + return { state: 'checking', observedAt }; + } + if (input.delivered === false) { + return { state: 'error', message: input.reason, observedAt }; + } + return { state: 'none' }; +} +``` + +Do not use the immediate fallback when a full ledger status read is available. Ledger plus policy is authoritative there. + +For immediate `sendMessage` responses, use cheap ledger-fact classification first and leave full proof reconciliation to the later status poll or member-advisory refresh. + +## Service Changes + +### `TeamProvisioningService.getOpenCodeRuntimeDeliveryStatus` + +Current: + +```ts +if (record) { + return this.toOpenCodeRuntimeDeliveryStatus(record); +} +``` + +Recommended: + +```ts +if (record) { + return await this.toOpenCodeRuntimeDeliveryStatus(record); +} +``` + +Make `toOpenCodeRuntimeDeliveryStatus` async, or pass in a precomputed impact. + +Example: + +```ts +private async toOpenCodeRuntimeDeliveryStatus( + record: OpenCodePromptDeliveryLedgerRecord +): Promise { + const base = this.toOpenCodeRuntimeDeliveryStatusFacts(record); + const decision = await this.decideOpenCodeRuntimeDeliveryAdvisoryForRecord(record, { + proofReadBudgetMs: 750, + }); + return { + ...base, + userVisibleImpact: toOpenCodeRuntimeDeliveryUserVisibleImpact(decision), + }; +} +``` + +If proof reading exceeds the budget, fall back conservatively: + +- hard/action-required reason -> `error`; +- recent generic terminal proof gap -> `checking`; +- old generic terminal proof gap -> `warning`; +- responded or newer success visible in the ledger -> `none`. + +Keep fact semantics unchanged: + +```ts +const failed = record.status === 'failed_terminal'; +return { + delivered: !failed, + responsePending: !failed && !responded, + ledgerStatus: record.status, + responseState: record.responseState, + reason: record.lastReason ?? undefined, + diagnostics: record.diagnostics, +}; +``` + +The renderer should no longer interpret these fact fields directly when `userVisibleImpact` exists. + +### Immediate `sendMessage` IPC result + +In `src/main/ipc/teams.ts`, after relay: + +```ts +result.runtimeDelivery = { + providerId: 'opencode', + attempted: true, + delivered: delivery.delivered, + responsePending: delivery.responsePending, + acceptanceUnknown: delivery.acceptanceUnknown, + responseState: delivery.responseState, + ledgerStatus: delivery.ledgerStatus, + visibleReplyMessageId: delivery.visibleReplyMessageId, + visibleReplyCorrelation: delivery.visibleReplyCorrelation, + reason: delivery.reason, + diagnostics: delivery.diagnostics, +}; +``` + +This path should ask provisioning to decorate the delivery with impact, but it should not run the full proof reader. The user is waiting for the send call to return, and the relay path may already have done substantial I/O. + +Important timestamp caveat: `OpenCodeMemberInboxDelivery` does not currently carry `failedAt` or `updatedAt`. If immediate classification needs exact grace-age, either: + +- read the single ledger record by `ledgerRecordId` and `laneId` without scanning inboxes/tasks; or +- conservatively classify generic terminal proof failures as `checking` and let `getOpenCodeRuntimeDeliveryStatus()` correct the state on the next poll. + +Prefer the conservative fallback unless a single-record ledger read is already cheap in the call site. + +Recommended helper: + +```ts +async getOpenCodeRuntimeDeliveryImpactForResult(input: { + teamName: string; + delivery: OpenCodeMemberInboxDelivery; +}): Promise +``` + +Implementation: + +```ts +async getOpenCodeRuntimeDeliveryImpactForResult(input: { + teamName: string; + delivery: OpenCodeMemberInboxDelivery; +}): Promise { + if (input.delivery.responsePending === true) { + return { state: 'checking' }; + } + + const factImpact = getImmediateOpenCodeRuntimeDeliveryImpact({ + delivered: input.delivery.delivered, + responsePending: input.delivery.responsePending, + reason: input.delivery.reason, + }); + + if (factImpact.state !== 'error') { + return factImpact; + } + + // If the immediate delivery result carries generic terminal proof failure facts, + // report checking during the grace window rather than hard failure. + const deliveryRecordFacts = { + ledgerStatus: input.delivery.ledgerStatus, + responseState: input.delivery.responseState, + reason: input.delivery.reason, + diagnostics: input.delivery.diagnostics ?? [], + }; + // Without record timestamps, generic terminal proof gaps should become checking, + // not warning. The next explicit status poll can use the full record time. + return classifyOpenCodeRuntimeDeliveryFactsForImmediateUx(deliveryRecordFacts); +} +``` + +If a caller needs exact suppression because a late proof already exists, it should call `getOpenCodeRuntimeDeliveryStatus()` after the send result. The immediate result can temporarily say `checking`; it should not temporarily say hard failed for generic proof gaps. + +Then IPC: + +```ts +const userVisibleImpact = await provisioning.getOpenCodeRuntimeDeliveryImpactForResult({ + teamName: tn, + delivery, +}); + +result.runtimeDelivery = { + ...oldFacts, + userVisibleImpact, +}; +``` + +## Renderer Diagnostics + +Update: + +```txt +src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts +``` + +Preferred logic: + +```ts +export function buildOpenCodeRuntimeDeliveryDiagnostics( + result: SendMessageResult +): OpenCodeRuntimeDeliveryDiagnostics { + const runtimeDelivery = result.runtimeDelivery; + if (runtimeDelivery?.attempted !== true) { + return { warning: null, debugDetails: null }; + } + + const impact = runtimeDelivery.userVisibleImpact; + if (impact) { + return buildDiagnosticsFromUserVisibleImpact(result, impact); + } + + return buildLegacyDiagnostics(result); +} +``` + +Impact mapping: + +```ts +function buildDiagnosticsFromUserVisibleImpact( + result: SendMessageResult, + impact: OpenCodeRuntimeDeliveryUserVisibleImpact +): OpenCodeRuntimeDeliveryDiagnostics { + if (impact.state === 'none') { + return { warning: null, debugDetails: null }; + } + + if (impact.state === 'checking') { + return { + warning: CHECKING_WARNING, + debugDetails: buildDebugDetails(result), + }; + } + + if (impact.state === 'warning') { + const detail = formatOpenCodeRuntimeDeliveryFailureReason(impact.message); + return { + warning: detail ? `${PROOF_WARNING} Detail: ${detail}` : PROOF_WARNING, + debugDetails: buildDebugDetails(result), + }; + } + + const detail = formatOpenCodeRuntimeDeliveryFailureReason( + impact.message ?? result.runtimeDelivery?.reason + ); + return { + warning: detail ? `${FAILED_WARNING} Reason: ${detail}` : FAILED_WARNING, + debugDetails: buildDebugDetails(result), + }; +} +``` + +Recommended copy constants: + +```ts +const CHECKING_WARNING = + 'OpenCode delivery is still being checked. Message was saved and will be observed before retry if needed.'; + +const PROOF_WARNING = + 'OpenCode reply could not be verified. Message was saved to inbox, but the app did not find a correlated reply or progress proof.'; +``` + +Keep the old fallback for safety: + +```ts +function buildLegacyDiagnostics(result: SendMessageResult): OpenCodeRuntimeDeliveryDiagnostics { + // Current logic. +} +``` + +## Renderer Success/Failure Semantics + +Update renderer code that currently treats `delivered === false` as a terminal UX failure. + +Current examples: + +```ts +// teamSlice.ts +const runtimeDeliveryFailed = + result.runtimeDelivery?.attempted === true && result.runtimeDelivery.delivered === false; + +// SendMessageDialog.tsx +if ( + result?.runtimeDelivery?.attempted === true && + result.runtimeDelivery.delivered === false +) { + return; +} +``` + +After Phase 1.3, `delivered === false` is a ledger fact, not a UX decision. Use `userVisibleImpact` first: + +```ts +function isOpenCodeRuntimeDeliveryHardUxFailure( + runtimeDelivery: SendMessageResult['runtimeDelivery'] | undefined +): boolean { + if (runtimeDelivery?.attempted !== true) { + return false; + } + if (runtimeDelivery.userVisibleImpact) { + return runtimeDelivery.userVisibleImpact.state === 'error'; + } + return runtimeDelivery.delivered === false; +} +``` + +Use this helper for: + +- `lastSendMessageResult` decision in `teamSlice.ts`; +- draft-clearing decision in `SendMessageDialog.tsx`; +- pending-send restore/finalize decision in `MessageComposer.tsx`; +- any test that currently uses `delivered === false` as "do not clear draft". + +Expected UX: + +- `checking` clears the draft like a saved send, because the message is persisted and still being observed; +- `warning` also should not invite blind duplicate resend; +- `error` preserves the draft for user recovery, preserving the existing hard-failure behavior; +- legacy payloads without `userVisibleImpact` keep the old `delivered === false` behavior. + +`MessageComposer.tsx` currently computes pending-send failure from: + +```ts +const failed = sendError !== null || sendDebugDetails?.delivered === false; +``` + +That must become user-visible-impact aware. Otherwise a terminal generic proof gap with `userVisibleState: "checking"` and `delivered: false` will restore the draft even though the message was saved and is still being observed. + +Recommended helper: + +```ts +function isOpenCodeRuntimeDeliveryHardUxFailureFromDebugDetails( + debugDetails: OpenCodeRuntimeDeliveryDebugDetails | null | undefined +): boolean { + if (!debugDetails) return false; + if (debugDetails.userVisibleState) { + return debugDetails.userVisibleState === 'error'; + } + return debugDetails.delivered === false; +} +``` + +Use it only for the optimistic draft restore path. If a delayed status poll later changes from `checking` to `error`, do not resurrect an old draft automatically; by then the saved user message is already in the conversation and restoring a stale composer draft would look like a duplicate-send prompt. + +Pending-reply clearing needs a different helper. + +Current paths: + +- `src/renderer/components/team/messages/MessagesPanel.tsx` +- `src/renderer/components/team/TeamDetailView.tsx` + +They currently clear `pendingRepliesByMember` when `runtimeDelivery.delivered === false`. After Phase 1.3: + +```ts +function shouldClearPendingReplyForOpenCodeRuntimeDelivery( + runtimeDelivery: SendMessageResult['runtimeDelivery'] | undefined +): boolean { + if (runtimeDelivery?.attempted !== true) { + return false; + } + if (runtimeDelivery.userVisibleImpact) { + return ( + runtimeDelivery.userVisibleImpact.state === 'warning' || + runtimeDelivery.userVisibleImpact.state === 'error' + ); + } + return runtimeDelivery.delivered === false; +} +``` + +Expected pending behavior: + +- `checking` keeps pending reply, because a real reply can still arrive; +- `none` clears only through the existing visible-reply reconciliation; +- `warning` clears pending reply because the live reply could not be verified after grace; +- `error` clears pending reply because live delivery failed; +- legacy payloads keep current `delivered === false` clearing behavior. + +## Debug Details + +Extend renderer debug details to include impact: + +```ts +export interface OpenCodeRuntimeDeliveryDebugDetails { + messageId: string; + statusMessageId: string | null; + ledgerRecordId: string | null; + laneId: string | null; + queuedBehindMessageId: string | null; + providerId: string; + delivered: boolean | null; + responsePending: boolean | null; + responseState: string | null; + ledgerStatus: string | null; + acceptanceUnknown: boolean | null; + reason: string | null; + diagnostics: string[]; + userVisibleState: string | null; + userVisibleReasonCode: string | null; + userVisibleMessage: string | null; + userVisibleObservedAt: string | null; + userVisibleNextReviewAt: string | null; +} +``` + +This keeps support/debugging transparent for visible warning/checking/error states. For `userVisibleImpact.state === 'none'`, return `debugDetails: null` so the store clears stale hidden runtime diagnostics. + +Update `formatOpenCodeRuntimeDeliveryDebugDetails()` as well as the debug details builder. The expandable JSON/details view should include the user-visible impact fields when a warning/checking/error is visible, otherwise support logs will show `ledgerStatus: failed_terminal` without the reason the UI chose not to render it as a hard failure. + +Also update the expanded details grid in `OpenCodeDeliveryWarning.tsx` to render: + +- `statusMessageId`; +- `ledgerRecordId`; +- `laneId`; +- `queuedBehindMessageId`; +- `userVisibleState`; +- `userVisibleReasonCode`; +- `userVisibleMessage`; +- `userVisibleObservedAt`; +- `userVisibleNextReviewAt`. + +Do not attach debug details for `state: "none"`: + +```ts +if (runtimeDelivery.userVisibleImpact?.state === 'none') { + return { warning: null, debugDetails: null }; +} +``` + +This matters for stale UI cleanup. A hidden `none` state with non-null debug details can keep `OpenCodeDeliveryWarning` mounted, keep polling dependencies alive, or leave a collapsed "delivery details" affordance with no user-facing warning. + +`messageId` should remain the original user-sent inbox row id because `clearSendMessageRuntimeDiagnostics(messageId)` and visible-reply reconciliation use it. `statusMessageId` is the id to poll: + +```ts +const statusMessageId = + runtimeDelivery.queuedBehindMessageId?.trim() || + result.messageId; +``` + +Do not replace `debugDetails.messageId` with `queuedBehindMessageId`; that breaks clearing the warning for the original send row. Use `statusMessageId` only for `getOpenCodeRuntimeDeliveryStatus()`. + +## Warning Delay Logic + +Update: + +```txt +src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx +``` + +Current delay logic only delays when: + +```ts +debugDetails?.responsePending === true && debugDetails.delivered !== false +``` + +That will not work for Phase 1.3 because a generic terminal ledger fact can be: + +```ts +delivered: false +ledgerStatus: 'failed_terminal' +userVisibleState: 'checking' +``` + +Change the delay condition to prefer user-visible state: + +```ts +const delayPendingWarning = + debugDetails?.userVisibleState === 'checking' || + (debugDetails?.userVisibleState == null && + debugDetails?.responsePending === true && + debugDetails.delivered !== false); +``` + +This keeps the existing legacy behavior and prevents a terminal-generic proof gap from flashing even as a non-scary checking warning. + +## Status Polling Logic + +Update: + +```txt +src/renderer/components/team/messages/MessagesPanel.tsx +``` + +Current polling starts only when: + +```ts +debugDetails?.responsePending === true +``` + +That is insufficient after Phase 1.3. A terminal generic proof gap should have: + +```ts +responsePending: false +ledgerStatus: 'failed_terminal' +userVisibleState: 'checking' +``` + +Change the polling gate: + +```ts +const messageId = debugDetails?.messageId; +const statusMessageId = debugDetails?.statusMessageId || messageId; +const shouldPollRuntimeDeliveryStatus = + debugDetails?.responsePending === true || + debugDetails?.userVisibleState === 'checking'; + +if (!messageId || !statusMessageId || sendMessageRuntimeReplyVisible || !shouldPollRuntimeDeliveryStatus) { + return; +} +``` + +Update the effect dependencies to include: + +- `sendMessageDebugDetails?.statusMessageId`; +- `sendMessageDebugDetails?.userVisibleState`; +- `sendMessageDebugDetails?.userVisibleNextReviewAt`. + +Without this, the composer can show a checking warning forever after the immediate send result, because the follow-up `getOpenCodeRuntimeDeliveryStatus()` call never runs. + +When calling `refreshSendMessageRuntimeDeliveryStatus`, pass both ids or add a small wrapper: + +```ts +void refreshSendMessageRuntimeDeliveryStatus(teamName, { + messageId, + statusMessageId, +}); +``` + +The store should still update/clear only if `state.sendMessageDebugDetails?.messageId === messageId`. The IPC status lookup uses `statusMessageId`. + +If `statusMessageId !== messageId`, treat the returned status as blocker status, not as the final status of the original send: + +- `checking` keeps the original send in checking; +- `none` means the blocker cleared, so schedule or attempt a follow-up status read for the original `messageId`; +- `warning` or `error` on the blocker should not be copied as the new message's warning/error; +- if the original `messageId` still has no ledger record, keep checking until the stale-check window expires. + +This prevents a hard failure from an older active delivery row being shown as the failure for a newly queued message. + +Stop polling when impact becomes terminal from a UX perspective: + +- `none` clears warning/debug details; +- `warning` stops polling because the proof grace window has expired; +- `error` stops polling because delivery is a hard failure; +- legacy `responsePending: false` keeps current behavior. + +Do not keep polling a `warning` forever waiting for a late reply. Late runtime replies already reach the renderer through message-feed/member-advisory refresh paths; status polling should only cover the short "checking" window. + +## Polling And Refresh Behavior + +Current store behavior: + +- send action stores warning/debug details from immediate `runtimeDelivery`; +- `refreshSendMessageRuntimeDeliveryStatus()` polls `getOpenCodeRuntimeDeliveryStatus`; +- `OpenCodeDeliveryWarning` delays pending warning display. + +Phase 1.3 expected behavior: + +1. Direct send returns terminal generic proof inside grace: + - ledger facts: `delivered: false`, `ledgerStatus: "failed_terminal"` + - impact: `{ state: "checking", nextReviewAt }` + - renderer warning: checking copy, not failed copy. + +2. Poll before grace expires: + - still checking. + +3. Late reply arrives: + - impact becomes `none`; + - warning clears. + +4. Grace expires without proof: + - impact becomes `warning`; + - copy changes to proof warning. + +The polling caller must not rely only on `responsePending`. + +If `nextReviewAt` is present, add one extra status refresh just after that time in addition to the existing short poll cadence. The current fixed delays are `[15s, 45s, 90s]`; if the backend grace is around 120s, those fixed timers can all fire before the proof window closes and leave `checking` visible forever. + +Clamp the extra delay to a sane range. Do not schedule an arbitrary long timer from IPC data: + +```ts +const nextReviewDelayMs = Number.isFinite(Date.parse(nextReviewAt ?? '')) + ? Math.max(1_000, Math.min(Date.parse(nextReviewAt!) - Date.now() + 500, 180_000)) + : null; +``` + +Schedule it only when `userVisibleState === 'checking'`. De-dupe it against existing fixed delays if it is within roughly 500ms of one of them. + +This is an optimization only. Correctness still comes from explicit status calls and from team/message refresh events, but this timer prevents the common "checking never transitions to proof warning" case. + +Handle status failures inside `refreshSendMessageRuntimeDeliveryStatus()`: + +```ts +try { + const status = await unwrapIpc(...); + if (!status) { + maybeClearStaleCheckingDiagnostics(normalizedMessageId); + return; + } + // existing diagnostic update +} catch (error) { + logger.debug('OpenCode runtime delivery status refresh failed', error); + maybeClearStaleCheckingDiagnostics(normalizedMessageId); +} +``` + +`maybeClearStaleCheckingDiagnostics()` should only clear non-terminal `checking` after the backend review window is already stale, for example `now > userVisibleNextReviewAt + 60s`. If `userVisibleNextReviewAt` is absent, fall back to a conservative max checking age from `userVisibleObservedAt`, for example three minutes. It must not convert a transient status miss into a hard delivery error. + +## Edge Cases + +### Backward-compatible IPC + +If `userVisibleImpact` is absent: + +- use current legacy behavior; +- do not crash browser mode or tests with older fixtures. + +### Immediate hard error without ledger + +Example: + +```json +{ + "attempted": true, + "delivered": false, + "reason": "opencode_runtime_message_bridge_unavailable" +} +``` + +Expected: + +- impact fallback `error`; +- old failed warning still appears. + +### Runtime not active but bootstrap still checking in + +Example: + +```json +{ + "attempted": true, + "delivered": false, + "reason": "opencode_runtime_not_active", + "diagnostics": [ + "OpenCode runtime bootstrap is not confirmed for jack. Message was saved and will be retried after runtime check-in." + ] +} +``` + +Expected: + +- impact `checking`; +- draft clears like a saved send; +- pending reply remains; +- no hard failed copy. + +If `opencode_runtime_not_active` has no retry/check-in diagnostic and the lane is stopped/deleted, keep `error` for direct-send recovery. + +### UI timeout pending + +Example: + +```json +{ + "attempted": true, + "delivered": true, + "responsePending": true, + "reason": "opencode_runtime_delivery_ui_timeout_pending" +} +``` + +Expected: + +- impact `checking`; +- pending/checking warning; +- no failed copy. + +### Terminal generic proof inside grace + +Example: + +```json +{ + "attempted": true, + "delivered": false, + "responsePending": false, + "responseState": "empty_assistant_turn", + "ledgerStatus": "failed_terminal", + "reason": "empty_assistant_turn", + "userVisibleImpact": { + "state": "checking", + "nextReviewAt": "2026-05-09T07:54:30.998Z" + } +} +``` + +Expected: + +- renderer shows checking, not failed; +- debug details still show `ledgerStatus: "failed_terminal"`. + +### Terminal generic proof after grace + +Expected: + +- impact `warning`; +- renderer shows proof warning; +- not hard failed warning. + +### Hard diagnostic mixed with generic state + +Expected: + +- impact `error`; +- renderer shows failed warning with provider/auth/quota reason; +- no checking state. + +### Late proof between immediate send and polling + +Expected: + +- immediate result may show checking; +- next poll returns `none`; +- warning clears. + +### Terminal facts with none impact + +Example: + +```json +{ + "attempted": true, + "delivered": false, + "ledgerStatus": "failed_terminal", + "userVisibleImpact": { + "state": "none" + } +} +``` + +Expected: + +- renderer clears warning; +- renderer clears debug details; +- draft remains cleared because the send was saved; +- pending reply clears only through existing visible-reply reconciliation, not through a hard-failure path. + +### Message queued behind older active delivery + +Expected: + +- impact `checking`; +- copy should not say failed; +- debug reason can include older message id. +- `debugDetails.messageId` remains the newly sent user message id; +- `debugDetails.statusMessageId` uses `queuedBehindMessageId` while the older active delivery is blocking; +- status polling must not clear the original send warning only because the queued-behind active record is not the same message id. + +### Acceptance unknown + +Expected: + +- impact `checking` while observe-first watchdog can still recover; +- if later hard failure, impact `error`; +- if later generic terminal and inside grace, still `checking`. + +### Status request fails during checking + +Expected: + +- keep the current checking state for one retry window; +- do not convert a transient IPC/status error into `OpenCode delivery error`; +- surface hard failure only when backend status returns `error` or legacy hard facts without `userVisibleImpact`. +- if the backend status remains unavailable past `userVisibleNextReviewAt + 60s`, clear the checking diagnostic instead of leaving a permanent warning; +- if `userVisibleNextReviewAt` is missing, use a conservative max age from `userVisibleObservedAt`. + +### Warning after grace with late reply later + +Expected: + +- proof warning can appear after grace; +- a later visible correlated reply still clears advisory through the normal message/member refresh path; +- no desktop notification or lead notice is retro-fired for the previous proof warning. + +## Tests + +### Shared type tests + +No runtime test required for type-only additions, but compile must pass: + +```bash +pnpm typecheck --pretty false +``` + +### Backend status tests + +Update: + +```txt +test/main/services/team/TeamProvisioningService.test.ts +test/main/services/team/TeamProvisioningServiceRelay.test.ts +``` + +Add: + +```ts +it('decorates getOpenCodeRuntimeDeliveryStatus with checking impact for recent generic terminal proof failure', async () => {}); +it('decorates getOpenCodeRuntimeDeliveryStatus with warning impact after proof grace expires', async () => {}); +it('decorates getOpenCodeRuntimeDeliveryStatus with error impact for quota diagnostics', async () => {}); +it('decorates immediate sendMessage runtimeDelivery with checking impact for generic terminal proof failure', async () => {}); +it('decorates immediate runtime_not_active bootstrap check-in retry as checking', async () => {}); +it('decorates stopped runtime_not_active without retry diagnostic as error', async () => {}); +it('returns none impact when late visible runtime reply supersedes terminal proof failure', async () => {}); +it('returns none impact when late visible reply is recovered by observed message id or taskRefs', async () => {}); +it('returns none impact when ledger already has plain_assistant_text visible reply proof', async () => {}); +``` + +### Renderer diagnostics tests + +Update: + +```txt +test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts +``` + +Existing tests that expect failed copy for terminal generic states should split: + +```ts +it('shows checking copy for terminal empty assistant turn while impact is checking', () => {}); +it('shows proof warning for terminal empty assistant turn when impact is warning', () => {}); +it('keeps legacy failed copy when impact is absent', () => {}); +it('shows hard failed copy when impact is error', () => {}); +it('returns null warning and null debug details when impact is none', () => {}); +it('formats user-visible impact fields in debug details for checking and warning states', () => {}); +it('preserves original messageId and stores statusMessageId for queued-behind delivery', () => {}); +``` + +### Store tests + +Update: + +```txt +test/renderer/store/teamSlice.test.ts +``` + +Cases: + +```ts +it('updates pending OpenCode diagnostics to checking when terminal generic proof is still in grace', async () => {}); +it('updates checking OpenCode diagnostics to proof warning after grace impact is warning', async () => {}); +it('clears OpenCode diagnostics when status impact becomes none', async () => {}); +it('does not retain hidden debug details when impact becomes none', async () => {}); +it('keeps failed warning for hard OpenCode runtime error impact', async () => {}); +it('keeps polling while userVisibleState is checking even when responsePending is false', async () => {}); +it('polls statusMessageId while updating diagnostics for the original messageId', async () => {}); +it('does not copy a queued-behind blocker hard error onto the newly sent message', async () => {}); +it('rechecks the original messageId after queued-behind blocker status becomes none', async () => {}); +it('schedules an extra status refresh at userVisibleNextReviewAt for checking impact', async () => {}); +it('clears stale checking diagnostics after the review window is stale and status stays unavailable', async () => {}); +it('does not treat checking impact as lastSendMessageResult failure when delivered is false', async () => {}); +it('keeps pending reply for checking impact even when delivered is false', async () => {}); +it('clears pending reply for warning and error impacts', async () => {}); +it('stops status polling when checking becomes warning', async () => {}); +it('does not convert a transient status request failure into a hard delivery error', async () => {}); +``` + +### Component tests + +Update: + +```txt +test/renderer/components/team/messages/OpenCodeDeliveryWarning.test.tsx +test/renderer/components/team/messages/MessagesPanel.test.tsx +test/renderer/components/team/messages/MessageComposer.pendingSend.test.tsx +test/renderer/components/team/dialogs/SendMessageDialog.test.tsx +``` + +Expected: + +- checking warning respects the delay even when ledger facts say `delivered: false`; +- proof warning appears immediately once impact is `warning`; +- hard error appears immediately; +- debug details include both ledger fact and impact. + +Add a focused component case: + +```ts +it('delays checking impact even when ledger facts are terminal failed', async () => {}); +``` + +Dialog draft cases: + +```ts +it('does not restore the composer draft for checking impact even when delivered is false', async () => {}); +it('clears the send dialog draft for checking impact even when delivered is false', async () => {}); +it('preserves the send dialog draft for hard error impact', async () => {}); +it('keeps legacy delivered-false draft preservation when userVisibleImpact is absent', async () => {}); +``` + +## Verification + +Focused: + +```bash +pnpm vitest run test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts +pnpm vitest run test/renderer/store/teamSlice.test.ts --testNamePattern "OpenCode" +pnpm vitest run test/renderer/components/team/messages/OpenCodeDeliveryWarning.test.tsx +pnpm vitest run test/renderer/components/team/messages/MessageComposer.pendingSend.test.tsx +pnpm vitest run test/main/services/team/TeamProvisioningService.test.ts --testNamePattern "OpenCode runtime" +pnpm vitest run test/main/services/team/TeamProvisioningServiceRelay.test.ts --testNamePattern "OpenCode" +``` + +Broader: + +```bash +pnpm vitest run test/renderer/components/team/messages/MessagesPanel.test.tsx +pnpm vitest run test/renderer/components/team/dialogs/SendMessageDialog.test.tsx +pnpm vitest run test/main/services/team/TeamProvisioningService.test.ts +pnpm vitest run test/main/services/team/TeamProvisioningServiceRelay.test.ts +pnpm typecheck --pretty false +git diff --check +``` + +Manual smoke: + +1. Send a direct message to an OpenCode teammate. +2. Force or simulate a recent `failed_terminal / empty_assistant_turn`. +3. Confirm composer/dialog shows checking copy. +4. Add a correlated runtime reply. +5. Confirm warning clears after refresh. +6. Simulate old proof missing record. +7. Confirm proof warning copy, not hard failed copy. +8. Simulate quota/auth diagnostic. +9. Confirm hard failed copy. + +## Rollout Notes + +- Do not remove old `delivered`, `responsePending`, `ledgerStatus`, or `responseState` fields. +- Renderer must prefer `userVisibleImpact` when present and fall back to legacy fields when absent. +- Do not hide debug details. +- Do not make `failed_terminal` mean success. +- Do not mark failed inbox rows read. +- Do not delay hard provider/runtime failures. + +## Acceptance Criteria + +- Direct send no longer flashes `OpenCode runtime delivery failed` for recent generic proof gaps. +- The same status still exposes `failed_terminal` in debug details. +- Hard errors still show failed copy. +- Confirmed proof gaps after grace show a warning, not hard error. +- Late proof clears the warning. +- Existing legacy payloads without `userVisibleImpact` still render with old behavior. diff --git a/landing/product-docs/guide/agent-workflow.md b/landing/product-docs/guide/agent-workflow.md index eafcb247..7e63c6f4 100644 --- a/landing/product-docs/guide/agent-workflow.md +++ b/landing/product-docs/guide/agent-workflow.md @@ -2,55 +2,84 @@ Agent Teams makes agent work visible as task state, messages, logs, and reviewable code changes. -## Lifecycle +## Modes -| Stage | What happens | -|-------|--------------| -| Provisioning | The app starts the team and confirms runtime readiness | -| Planning | The lead creates tasks and may assign teammates | -| In progress | Agents work in parallel and update task state | -| Review | Changes are reviewed by agents or by you | -| Done | Accepted work stays linked to its task history | +| Mode | Description | +| --- | --- | +| Solo | One teammate with self-managed tasks | +| Team | Many teammates working in parallel, reviewing each other | + +Both modes share the same kanban, task logs, and code review surfaces. + +## Task lifecycle + +| Stage | What happens | Owner | +| --- | --- | --- | +| Provisioning | The app starts the runtime, confirms the process is alive, and waits for bootstrap confirmation | App | +| Planning | The lead creates tasks, optionally assigns teammates, and sets dependencies | Lead or user | +| In progress | Agents work in parallel and update task state via board MCP tools | Teammates | +| Review | Changes are reviewed by agents or by you before final acceptance | Team lead or user | +| Done | Accepted work stays linked to its task history and can still be inspected later | User | + +### Planning → In progress + +When a teammate starts a task, the board status becomes `in_progress`. The agent creates a task comment with its plan and continues working. All native tool actions (read, bash, edit, write) are streamed into a task log. + +### In progress → Review + +When the teammate finishes work, it posts a result comment and marks the task `completed`. The lead can then decide whether to accept it immediately or move it into review. + +### Review → Done + +If the review surface shows acceptable changes, approve the review. The task is finalized and linked to its diff. + +::: warning Fix-first review +If a teammate is asked for changes during review, it should post a follow-up comment with the fixes, then the lead can approve. +::: ## Kanban board -The board is the primary operating surface. It lets you scan work, spot blocked tasks, open task detail, inspect logs, and review changes without reading raw session files. +The board is the primary operating surface. It lets you: + +- Scan open, blocked, and in-review work +- Open task detail and inspect runtime logs +- Review changes without reading raw session files +- Assign or reassign owners + +::: tip +Use quick action buttons on cards to start, complete, or request review without opening the detail panel. +::: ## Messages and comments -Use **direct messages** when you need to redirect an agent or ask a quick question. Use **task comments** when the note belongs to a specific piece of work. Comments preserve context for later review. +| Channel | When to use | +| --- | --- | +| Direct message | Redirect an agent, ask a quick question | +| Task comment | Notes that belong to a specific task | -::: tip -Task comments are the durable delivery channel. Agents should post findings, decisions, and blockers in comments so the whole team can see them on the board. -::: +Comments preserve context for later review and appear in the task timeline. -## Work-sync protocol - -Agents follow a strict status cycle: - -1. **Start** — mark the task `in_progress` when beginning real work. -2. **Comment** — post a short note before doing follow-up fixes. -3. **Reopen** — move the task back to `in_progress` for additional work. -4. **Result comment** — post a summary of changes. -5. **Complete** — mark the task `completed`. - -::: warning -Never skip the comment-and-status cycle. The board depends on accurate state to show what is actually happening. +::: tip Prefer task comments +If the remark is about a specific task, add it as a comment on that task rather than sending a direct message. It keeps the history linked to the work. ::: ## Task logs -Task-specific logs isolate runtime output, actions, and messages for one assignment. Use them when you need to answer: +Task-specific logs isolate runtime output, actions, and messages for one assignment. Use them to answer: - What did this agent run? - Why did it change this file? - Did it ask another teammate for help? - Which task produced this diff? +## Parallel work patterns + +Teammates can work on independent tasks at the same time. You can also create dependency links (`blocked-by`) so that one task waits until another is complete. Watch the board for blocked lanes and reassign owners if one teammate is idle while another is overloaded. + ## Live processes -The live process section shows URLs and running processes when agents start local servers or tools. Open URLs directly from the app to inspect results. +The live process section shows URLs and running processes when agents start local servers or tools. Open URLs directly from the app to inspect results. Processes remain registered until they are explicitly stopped or the runtime exits. ## Cross-team communication -Teams can send messages to each other. Use this to share findings, request reviews, or coordinate work across team boundaries without leaving the board. +Agents can send messages to other teams when teams are linked. Use this for handoffs, shared libraries, or status checks between squads. diff --git a/landing/product-docs/guide/code-review.md b/landing/product-docs/guide/code-review.md index c8bf6364..48771c1c 100644 --- a/landing/product-docs/guide/code-review.md +++ b/landing/product-docs/guide/code-review.md @@ -4,45 +4,60 @@ Code review in Agent Teams is task-centered. You inspect what changed for a spec ## Review surface -Use the review UI to: +For each completed task that touched files, the review UI lets you: -- Inspect changed files +- Inspect changed files with before/after context - Accept or reject individual hunks -- Leave comments -- Connect the diff back to the task and agent logs - -## Review lifecycle - -When a task is ready for review: - -1. The author marks it `completed`. -2. A reviewer calls `review_start` to move the task into the **REVIEW** column. -3. The reviewer inspects hunks and logs. -4. If accepted, the reviewer calls `review_approve` to move the task to **APPROVED**. -5. If changes are needed, the reviewer calls `review_request_changes` with a comment describing what to fix. - -::: tip -Approve the **work task** itself (e.g. `#1234`), not a separate "review task". The task ends in APPROVED, not DONE. -::: +- Leave inline comments +- Connect the diff back to the task description and agent logs ## Hunk-level decisions Accept small correct changes and reject isolated mistakes without throwing away the whole task. This is useful when an agent mostly solved the task but overreached in one file. +::: tip Accept incrementally +If a diff is mostly correct, accept the good hunks first and request changes only for the parts that need fixing. This keeps the board moving. +::: + +## Initiating review + +1. Open a completed task +2. Look at the **Changes** tab +3. If the diff looks reasonable, click **Request Review** to move the task into the review column + +During review the task is not yet considered done, so other teammates or the lead can still comment on it. + +## Review states + +| State | Meaning | +| --- | --- | +| `none` | Task is new, in progress, or completed but not yet in review | +| `review` | The task is actively under review | +| `needsFix` | Changes were requested; the owner must update before re-approval | +| `approved` | The review was accepted and the task is finalized | + ## Agent review workflow Teams can review each other's work before you make the final call. This catches obvious regressions and keeps the board honest, but you should still review risky areas yourself. +## Review participants + +The team lead is the default reviewer. You can configure additional reviewers in the Kanban settings if you want peers to review each other's work. + ## What to check manually -Prioritize: +Prioritize these areas when reviewing: -- Provider auth and runtime detection -- IPC, preload, and filesystem boundaries -- Git and worktree behavior -- Parsing and task lifecycle logic -- Persistence and code review flows +- **Provider auth and runtime detection** — did the agent change runtime setup in a way that would break other paths? +- **IPC, preload, and filesystem boundaries** — keep Electron responsibilities separated +- **Git and worktree behavior** — verify branch naming, commits, and pushes +- **Parsing and task lifecycle logic** — changes to task references, chunking, or filtering can break message delivery +- **Persistence and code review flows** — changes to task storage or review state must stay consistent across IPC layers ## Verification Prefer focused verification commands. Broad formatting or lint-fix commands should not be used unless the task explicitly intends broad formatting churn. + +::: warning Do not auto-format across the whole project +Unless the task is specifically about formatting, avoid running `pnpm lint:fix` on unrelated files. It creates noise in the review surface. +::: diff --git a/landing/product-docs/guide/create-team.md b/landing/product-docs/guide/create-team.md index 85ffe963..838ad85f 100644 --- a/landing/product-docs/guide/create-team.md +++ b/landing/product-docs/guide/create-team.md @@ -6,14 +6,28 @@ A team is a named group of agents with roles, a lead, a target project, and a co Start with a small team: -| Role | Purpose | -| --- | --- | -| Lead | Splits work, creates tasks, coordinates teammates | -| Builder | Implements scoped tasks | +| Role | Purpose | +| -------- | --------------------------------------------------- | +| Lead | Splits work, creates tasks, coordinates teammates | +| Builder | Implements scoped tasks | | Reviewer | Reviews output, catches regressions, asks for fixes | This shape gives you enough coordination to see the product value without making the first launch noisy. +::: tip +You can add more members later. Start small, validate the workflow, then scale up. +::: + +## Assign providers and models + +Each team member runs on a provider backend. In the team editor, pick a provider (Claude, Codex, or OpenCode) and a model for every member. The app shows only providers you have already authenticated. + +Mixing providers in one team is supported — for example, a Claude lead with OpenCode builders. + +::: info +Gemini support is in development and will appear in the provider list when available. +::: + ## Write a good team brief The team brief should include: @@ -30,10 +44,40 @@ Example: Build a focused improvement to the download flow. Keep changes inside the landing app unless a shared helper is clearly needed. Create tasks before implementation, review each task diff, and run landing lint/build checks. ``` +## Worktree isolation + +OpenCode members can use **worktree isolation** to work in a separate Git worktree instead of the main working directory. This prevents file conflicts when multiple agents edit the same project. + +::: warning +Worktree isolation requires a Git-tracked project and is currently limited to OpenCode members. +::: + +To enable it, toggle the **Worktree isolation** option when adding or editing an OpenCode team member. + ## Choose autonomy Agent Teams supports different levels of control. Use more autonomy for routine changes and tighter review for risky areas like provider auth, IPC, persistence, Git workflows, and release tooling. +### Effort level + +Each team member has an **effort** setting that controls how much reasoning the provider invests before responding. Higher effort produces more thorough output at the cost of time and tokens. + +| Level | When to use | +| ------ | ---------------------------------------------------------- | +| Low | Quick lookups, small formatting changes, routine edits | +| Medium | Default for most implementation tasks | +| High | Complex refactors, cross-cutting changes, risky code paths | + +The app offers additional levels (minimal, xhigh, max) for providers that support them. If a model does not support configurable effort, the selector is disabled and the provider default is used. + +### Fast mode + +Toggle **Fast mode** per member to prioritize speed over depth. This maps to the provider's native fast/speed mode when available. Set it to **On** for routine tasks, **Off** for careful work, or **Inherit** to follow the team-level default. + +### Limit context + +Enable **Limit context** to reduce the context window for a member. This is useful for Claude models that support extended context (e.g. 1M tokens) — limiting context avoids unnecessary token usage and can improve latency for tasks that do not need large context. + ## Add context Attach files, screenshots, or specific notes when they materially change the task. Agents can use task descriptions, comments, and attachments as durable context. @@ -49,3 +93,8 @@ Good teams create tasks that are: If the lead creates vague tasks, send a direct message asking for smaller, testable tasks. +## Next steps + +- [Runtime setup](/guide/runtime-setup) — configure provider auth and models +- [Code review](/guide/code-review) — accept, reject, or comment on agent changes +- [Troubleshooting](/guide/troubleshooting) — common issues and fixes diff --git a/landing/product-docs/guide/installation.md b/landing/product-docs/guide/installation.md index 4835810d..8d536585 100644 --- a/landing/product-docs/guide/installation.md +++ b/landing/product-docs/guide/installation.md @@ -4,7 +4,7 @@ Agent Teams is distributed as a desktop app for macOS, Windows, and Linux. ## Download builds -Use the latest GitHub release when you want the packaged app: +Use the download page or the latest [GitHub release](https://github.com/777genius/agent-teams-ai/releases) when you want the packaged app: - macOS Apple Silicon: `.dmg` - macOS Intel: `.dmg` @@ -17,14 +17,27 @@ Unsigned or newly published open-source apps can trigger SmartScreen. If you tru ## Requirements -The packaged app is designed for zero-setup onboarding. It can guide runtime detection and provider authentication from the UI. +The packaged app is designed for zero-setup onboarding. It guides you through runtime detection and provider authentication from the UI — no manual CLI configuration needed. -For source development, use: +To use agent runtimes, you need access to at least one provider: -| Tool | Version | -| --- | --- | -| Node.js | 20+ | -| pnpm | 10+ | +| Provider | Access method | +| ------------------ | ------------------------------------------------- | +| Claude (Anthropic) | Claude Code CLI login or API key | +| Codex (OpenAI) | Codex CLI login or API key | +| Gemini (Google) | _In development_ | +| OpenCode | API key for a supported backend (e.g. OpenRouter) | + +::: info +Gemini provider support is in development. You can prepare access now, but it will not appear in the team editor until it is ready. +::: + +For source development, you also need: + +| Tool | Version | +| ------- | ------- | +| Node.js | 20+ | +| pnpm | 10+ | ## Run from source @@ -37,9 +50,27 @@ pnpm install pnpm dev ``` -If you want the freshest local version, use the repository branch that currently carries active development. +The `main` branch carries the latest stable development. Switch to feature branches only if you need a specific unreleased change. -## Updating +## Auto-updates -Use the latest release for packaged builds. If you run from source, pull the branch you use and rerun install when dependencies change. +The packaged app checks for updates automatically on launch and periodically while running. When an update is available, the app prompts you to download and install it. You can also check manually from the app menu. +::: tip +Auto-updates are not available when running from source. Pull the latest changes and rerun `pnpm install` when dependencies change. +::: + +## Updating from source + +If you run from source, pull the `main` branch and rerun install when dependencies change: + +```bash +git pull +pnpm install +``` + +## Next steps + +- [Quickstart](/guide/quickstart) — from install to first running team +- [Runtime setup](/guide/runtime-setup) — provider auth and model selection per runtime +- [Create a team](/guide/create-team) — recommended team shapes and brief writing diff --git a/landing/product-docs/guide/quickstart.md b/landing/product-docs/guide/quickstart.md index 119ade51..8d466f6a 100644 --- a/landing/product-docs/guide/quickstart.md +++ b/landing/product-docs/guide/quickstart.md @@ -1,33 +1,45 @@ # Quickstart -This guide gets you from a fresh install to a running team. +This guide gets you from a fresh install to a running team in a few minutes. ## 1. Install Agent Teams -Download the latest release for your platform from the landing page or GitHub releases. +Download the latest release for your platform from the download page or [GitHub releases](https://github.com/777genius/agent-teams-ai/releases). ::: tip -The app is free and open source. The agent runtime you choose may still require provider access, such as Claude, Codex, OpenCode, or API-key based providers. +The app is free and open source. The agent runtime you choose may still require provider access — see [Installation](/guide/installation) for details. ::: ## 2. Open or create a project Launch the app and select the project directory you want agents to work in. Agent Teams reads local project files and runtime/session state so the UI can show tasks, logs, diffs, and teammate activity. +::: tip +Pick a Git-tracked project for the best experience. Worktree isolation and diff-based review both rely on Git. +::: + ## 3. Choose a runtime path -Use the setup flow to detect available runtimes. A common first setup is: +The setup flow auto-detects installed runtimes on your machine. A common first setup is: -| Runtime | Good for | -| --- | --- | -| Claude | Claude Code users and existing Anthropic access | -| Codex | Codex-native workflows and OpenAI access | -| OpenCode | Multimodel teams and many provider backends | +| Runtime | Good for | +| -------- | ----------------------------------------------- | +| Claude | Claude Code users and existing Anthropic access | +| Codex | Codex-native workflows and OpenAI access | +| OpenCode | Multi-model teams and many provider backends | + +::: info +Gemini support is in development and will appear in the runtime list when available. +::: + +See [Runtime setup](/guide/runtime-setup) for detailed configuration per provider. ## 4. Create your first team Create a team with a lead and one or more specialists. Keep the first team small: one lead, one implementation agent, and one review-oriented agent is enough to validate the workflow. +See [Create a team](/guide/create-team) for the recommended structure and tips. + ## 5. Give the lead a concrete goal Write the goal like you would brief an engineering lead: @@ -36,15 +48,16 @@ Write the goal like you would brief an engineering lead: Improve the onboarding flow. Split the work into tasks, keep changes small, and ask for review before broad refactors. ``` -The lead should create tasks, assign work, and coordinate teammates. You can watch progress on the kanban board and intervene with comments or direct messages. +The lead creates tasks, assigns work, and coordinates teammates. You can watch progress on the kanban board and intervene with comments or direct messages at any time. ## 6. Review results Open completed or review-ready tasks, inspect the diff, and accept, reject, or comment on individual changes. Use task logs when you need to understand why an agent made a choice. +See [Code review](/guide/code-review) for the full review workflow. + ## Next steps -- [Create a team](/guide/create-team) -- [Runtime setup](/guide/runtime-setup) -- [Code review](/guide/code-review) - +- [Create a team](/guide/create-team) — recommended team shapes and brief writing +- [Runtime setup](/guide/runtime-setup) — provider auth and model selection +- [Code review](/guide/code-review) — review, approve, or request changes diff --git a/landing/product-docs/guide/runtime-setup.md b/landing/product-docs/guide/runtime-setup.md index bca517d5..0d92a793 100644 --- a/landing/product-docs/guide/runtime-setup.md +++ b/landing/product-docs/guide/runtime-setup.md @@ -2,13 +2,25 @@ Agent Teams is a coordination layer. The actual model work runs through supported local runtimes and providers. +## Prerequisites + +Before launching a team, make sure: + +- The runtime binary is installed and on your `PATH`. +- Your provider account has active access to the model you intend to use. +- The project path exists and is readable. + +::: tip +Start with a single teammate and one provider. Confirm one launch works before adding multimodel lanes. +::: + ## Supported paths -| Path | Use when | -|------|----------| -| Claude | You already use Claude Code or Anthropic-backed workflows | -| Codex | You want Codex-native runtime integration | -| OpenCode | You want multimodel routing and broad provider coverage | +| Path | Default CLI | Typical providers | Use when | +| --- | --- | --- | --- | +| Claude | `claude` | Anthropic | You already use Claude Code or Anthropic-backed workflows | +| Codex | `codex` | OpenAI | You want Codex-native runtime integration | +| OpenCode | `opencode` | OpenRouter and many backends | You want multimodel routing and broad provider coverage | The app detects supported runtimes and guides setup from the UI when possible. @@ -16,48 +28,71 @@ The app detects supported runtimes and guides setup from the UI when possible. Agent Teams has no paid tier of its own. You bring the provider access you already have: subscriptions, local runtime auth, or API keys depending on the path you choose. -::: tip -If you are new to Claude Code, the app includes a built-in installer and authentication helper. Look for the "Install Claude Code" button in the runtime settings. -::: +- **Claude** and **Codex** paths rely on their respective CLI auth tools. +- **OpenCode** needs provider-specific API keys in a config file (e.g., `openrouter`, `openai`, `anthropic`). + +## Auth configuration + +### Claude Code + +Run the standard auth flow in a terminal: + +```bash +claude login +``` + +Then verify the CLI is reachable: + +```bash +claude --version +``` + +### Codex + +Install and authenticate via OpenAI's CLI flow: + +```bash +codex login +``` + +### OpenCode + +Create or edit `~/.opencode/config.json` (or the equivalent path on your platform) with the provider key you want: + +```json +{ + "providers": { + "openrouter": { + "apiKey": "sk-or-..." + } + } +} +``` + +Use the exact provider name that OpenCode expects. If you set a custom provider name, double-check it against the provider ID you use in the model string (for example `openrouter/moonshotai/kimi-k2.6` would use the `openrouter` block). ## Multimodel mode Multimodel mode can route work through many provider backends via OpenCode-compatible configuration. Use it when you need provider flexibility or want teammates to use different model lanes. -Example `~/.opencode/config.json`: -```json -{ - "providers": { - "anthropic": { "apiKey": "" }, - "openai": { "apiKey": "" } - } -} -``` - -## Pre-flight checklist - -Before creating your first team: - -- [ ] The chosen runtime is installed and available in your shell `PATH`. -- [ ] You have authenticated with the provider (Claude Code `claude login`, OpenCode `opencode auth`, etc.). -- [ ] The provider has access to the model you plan to assign. -- [ ] The project path exists and is readable. - -::: warning -Do not add many providers or multimodel lanes until you have confirmed that a single teammate can launch successfully. Keep the first setup minimal. +::: info Model lanes +Each teammate can use a different `providerId` + `model` pair. In the team edit UI, expand member options to override the global defaults. ::: -## Operational advice +## Prelaunch checklist -- Keep the first runtime setup simple. -- Confirm one team can launch before adding many providers. -- Treat auth, provider model names, and runtime PATH issues as setup problems, not team-prompt problems. -- If launch hangs, check the [Troubleshooting](./troubleshooting.md) page before changing team prompts. +Before launching a team: + +1. The selected runtime is installed +2. The runtime binary is in the environment `PATH` +3. Provider auth is configured for the chosen backend +4. The provider has access to the exact model string you specify +5. The project path exists and is readable ## When to switch runtime paths Switch when the current path is blocked by model availability, rate limits, provider capabilities, or team role needs. Keep the same project and team workflow, but validate one small task after switching. -::: tip -You can mix paths in the same team: for example, assign the lead to Claude while secondary teammates run in OpenCode lanes for multimodel flexibility. +::: warning Treat setup errors as setup problems +If auth fails, a model name is rejected, or the runtime binary cannot be found, fix the setup first. Do not change team prompts or project code to work around a runtime configuration issue. ::: diff --git a/landing/product-docs/guide/troubleshooting.md b/landing/product-docs/guide/troubleshooting.md index 734c3c8a..835c7223 100644 --- a/landing/product-docs/guide/troubleshooting.md +++ b/landing/product-docs/guide/troubleshooting.md @@ -1,45 +1,55 @@ # Troubleshooting -Most team issues fall into one of four buckets: runtime setup, launch confirmation, task parsing, or provider limits. +Most team issues fall into one of five buckets: runtime setup, launch confirmation, task parsing, provider limits, and review state gaps. ## Team does not launch -Check: +Check each item in order: -- The selected runtime is installed or authenticated -- The runtime is available in the environment `PATH` -- The provider has access to the requested model -- The project path exists and is readable +1. **Runtime available** — the selected CLI (`claude`, `codex`, `opencode`) is installed +2. **PATH reachable** — the binary is available in the environment `PATH` +3. **Model access** — the provider has access to the requested model string (especially for OpenCode, exact provider/model names matter) +4. **Project path** — the project directory exists and is readable +5. **Network / VPN** — some providers drop traffic when a VPN is active -::: tip -Run the runtime binary directly in a terminal to verify it is on PATH and authenticated. For example: `claude --version` or `opencode --version`. -::: +### OpenCode: registered but bootstrap unconfirmed -### OpenCode bootstrap unconfirmed +If OpenCode shows `registered` but bootstrap is unconfirmed, inspect artifacts first before changing team prompts. -If OpenCode shows `registered` but bootstrap is unconfirmed: +Look at the newest launch failure artifact: -1. Inspect the launch logs in the UI. -2. Check `~/.claude/teams//launch-state.json` for the member state. -3. Look at `~/.claude/teams//.opencode-runtime/lanes//manifest.json` for evidence. -4. Do not change team prompts until you confirm whether the lane started but failed to commit evidence. +```bash +~/.claude/teams//launch-failure-artifacts/latest.json +``` -::: warning -A missing OpenCode inbox during primary launch is normal. Secondary lanes start after primary filesystem readiness. Do not treat primary hang as an OpenCode bug unless the UI explicitly shows `Y` members waiting with `Y` incorrectly including OpenCode lanes. +The manifest inside includes: + +- `classification` — why the launch was considered a failure +- `bootstrapTransportBreadcrumb` — delivery path used +- Member spawn statuses +- Redacted logs and traces + +Also check the lane manifest: + +```bash +jq '.lanes' ~/.claude/teams//.opencode-runtime/lanes.json +jq '.activeRunId, .entries' ~/.claude/teams//.opencode-runtime/lanes//manifest.json +``` + +::: tip Do not guess from the UI +Always correlate UI diagnostics with persisted files (`launch-state.json`, `bootstrap-journal.jsonl`) and runtime-specific evidence. ::: ## Agent replies are missing Open task logs and teammate messages. Missing replies often come from: -- Runtime delivery gaps -- Parsing or task filtering issues -- The agent is still processing (large tasks may take minutes) +- **Runtime delivery retry** — the agent may have answered, but the message was not delivered to the app. Check the delivery ledger. +- **Parsing or filtering** — the agent output did not include expected markers or task references. +- **Task attribution** — the work happened during the session but was not linked to the task because the correct task id was missing from the output. +::: warning Do not assume silence means ignoring Do not assume the model ignored the message until logs confirm it. - -::: tip -For OpenCode teammates, check that `agent-teams_message_send` was called with the correct `from`, `to`, and `taskRefs`. OpenCode replies must be sent via MCP tools, not plain text. ::: ## Tasks are not linked to changes @@ -50,64 +60,50 @@ Use task-specific logs and code review links. If a diff appears detached: - Verify the agent called `task_add_comment` before making edits. - Ensure the agent called `task_start` so the board knows work began. +For OpenCode teammates, the authoritative proof that a session belongs to a task is in `opencode-sessions.json` and the lane manifest entry, not only the UI message stream. + ## Rate limits If a provider reports a known reset time, Agent Teams can nudge the lead to continue after cooldown. If reset time is unknown, wait or switch provider/runtime path. -## Common member states +| Provider behavior | Suggested action | +| --- | --- | +| Known reset time displayed | Wait for cooldown and continue | +| No reset time shown | Switch provider or runtime path | +| Repeated 429s | Lower concurrency or use a different model lane | -| State | Meaning | -|-------|---------| -| `confirmed_alive` + `bootstrapConfirmed` | Healthy and usable | -| `registered` / `runtime_pending_bootstrap` | Process or lane exists, but bootstrap proof is not committed yet | -| `failed_to_start` + `runtime_process` | A process exists but the launch gate failed. Inspect diagnostics | -| `failed_to_start` + `stale_metadata` | Persisted pid/session is old or dead | +## CLI auth issues -::: warning -`member_briefing` alone is NOT runtime evidence. For OpenCode, the authoritative proof is committed runtime evidence such as `opencode-sessions.json` and its manifest entry. -::: +### `claude login` not persist -## Teammate runtime debug mode +If the CLI is authenticated in one terminal but the app says it is not, verify the auth is saved to the expected config path and that the app process sees the same `$HOME`. -For local debugging, you can force pane-backed teammates through `tmux`: +### OpenCode provider key rejected -```bash -# Terminal launch -CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev +- Double-check the provider name in `config.json` matches the provider prefix in the model string +- Ensure the key is not expired or revoked in the provider dashboard -# Or add to custom CLI args ---teammate-mode tmux -``` +## Lane bootstrap stuck -Use this to inspect interactive CLI behavior. Do not treat it as equivalent to the process backend for recovery semantics. +For OpenCode secondary lanes: -## CLI auth diagnostic +- A missing `inboxes/.json` is not automatically a bug. OpenCode lanes do not have to be primary-inbox-created before they start. +- If the UI shows the team still launching while primary members are already usable, "all teammates joined" is waiting for secondary lanes. +- If `Prepared communication channels for X/Y members` hangs, verify whether `Y` incorrectly includes secondary OpenCode members. -Each run of `CliInstallerService.getStatus()` appends one line to `claude-cli-auth-diag.ndjson` inside the Electron logs folder (typically `~/Library/Logs//` on macOS). If the file exceeds **512 KiB**, it is truncated to empty before the next append. +### Lane manifest empty entries -Check this file if you see "Not logged in" or authentication errors in the packaged app. - -## Safe cleanup - -When cleaning up stale processes: - -1. Identify the pid and confirm it belongs to the current team/lane. -2. Stop only processes explicitly owned by the smoke test or the launch you are debugging. -3. Do **not** kill all OpenCode processes or shared hosts as a shortcut. +If the bridge says bootstrap succeeded but `manifest.json` shows `entries: []`, the issue is **evidence commit**, not model behavior. The member must not be considered deliverable until `opencode-sessions.json` and its manifest entry exist. ## When to collect evidence -Collect: +Before asking for help, collect: -- Task id +- Task id (short or full) - Team name -- Runtime path -- Launch log excerpt -- Provider/model -- Exact time window +- Runtime path (`claude`, `codex`, or `opencode`) +- Launch log excerpt (from `latest.json` or `bootstrap-journal.jsonl`) +- Provider / model string +- Exact time window when the issue occurred -This is enough to debug most launch and task lifecycle issues. - -::: tip -If the problem persists, open the team's persisted files under `~/.claude/teams//` and correlate UI diagnostics with live process state before changing code. -::: +This data is usually enough to debug launch and task lifecycle issues. diff --git a/landing/product-docs/reference/concepts.md b/landing/product-docs/reference/concepts.md index 63477792..1788d04a 100644 --- a/landing/product-docs/reference/concepts.md +++ b/landing/product-docs/reference/concepts.md @@ -1,32 +1,75 @@ # Concepts -This page defines the core terms used across Agent Teams. +This page defines the core terms used across Agent Teams. Use it as the shared vocabulary for the app, task board, messages, and review flow. ## Team -A team is a group of agents configured for a project. A team usually has a lead and one or more teammates with specialized roles. +A team is a named group of agents attached to one project path. It has a lead, optional teammates, runtime/provider settings, prompts, inboxes, tasks, and local launch state. ## Lead -The lead coordinates work. It should break goals into tasks, assign teammates, track blockers, and ask for review when needed. +The lead is the coordinator for the team. It turns a user goal into tasks, assigns or redirects teammates, tracks blockers, asks for review, and keeps work moving through the board. + +Lead messages use a different delivery path from teammate messages: the app relays lead inbox entries into the lead runtime, while teammates read their own inbox files between turns. + +## Teammate + +A teammate is a non-lead agent in the team. Teammates usually own focused roles such as builder, reviewer, researcher, or tester. A teammate can receive direct messages, task assignments, task comments, and review requests. ## Task -A task is the durable unit of work. It has status, description, comments, logs, attachments, and reviewable changes. +A task is the durable unit of work. It has an id, status, owner, description, comments, logs, attachments, task references, and reviewable changes. -## Solo mode +Common task states are `todo`, `in_progress`, `done`, `review`, and `approved`. Internally the task file stores the work state, while review and approval placement can also use kanban overlay state. -Solo mode runs a one-member team. It is useful for quick work, lower token usage, and validating a prompt before expanding to a full team. +## Kanban -## Cross-team communication +Kanban is the board view for team work. It lets you scan tasks by state, open task details, inspect logs, review diffs, approve finished work, or request changes. -Agents can message within and across teams. Use this when separate teams own related work and need to coordinate. +## Inbox -## Autonomy level +An inbox is a local message file for a team participant. Agent Teams uses inboxes for user messages, lead messages, teammate messages, runtime delivery metadata, cross-team messages, and some system notifications. -Autonomy controls how much agents can do before asking. Higher autonomy is faster; lower autonomy is safer for sensitive code paths. +Messages are durable local records. Delivery still depends on the selected runtime being alive and able to process its next turn. + +## Agent Block + +An agent block is hidden, agent-only instruction text wrapped with `...`. The UI strips these blocks from normal human-facing display, but agents and runtime delivery can use them for coordination details. + +The current canonical marker is `info_for_agent`; older documents may still contain legacy agent block formats. + +## Context Phase + +A context phase is one segment of a session context timeline. Compaction starts a new phase, so token and context usage can be analyzed before and after the reset. + +Context tracking separates categories such as project instructions, mentioned files, tool output, thinking text, team coordination, and user messages. These numbers are diagnostics, not provider billing statements. ## Runtime -A runtime is the local execution path that connects Agent Teams to a model/provider workflow, such as Claude, Codex, or OpenCode. +A runtime is the local execution path that runs an agent turn. Supported runtime paths include Claude Code, Codex, and OpenCode. +The runtime owns model execution behavior, auth details, tool execution semantics, rate limits, model availability, and some transcript/log formats. + +## Provider + +A provider is the model access path behind a runtime. Current provider ids include Anthropic, Codex, Gemini, and OpenCode. OpenCode can route to many model providers through its own configuration. + +Agent Teams orchestrates tasks and messages, but it does not replace provider authentication or provider policy. + +## Solo mode + +Solo mode runs a one-member team. It is useful for quick work, lower coordination overhead, and validating a prompt before expanding to a full team. + +## Cross-team communication + +Agents can message within and across teams. Use this when separate teams own related work and need to coordinate without collapsing everything into one large team. + +## Autonomy level + +Autonomy controls how much agents can do before asking. Higher autonomy is faster; lower autonomy is safer for sensitive code paths, persistence, provider auth, Git operations, and releases. + +## Review + +Review is the task-scoped acceptance flow. A task can move to review, receive comments or requested changes, and then move to approved when the result is accepted. + +Review is tied to local diffs and task history, so it works best when tasks stay narrow and agents mention the task they are working on. diff --git a/landing/product-docs/reference/faq.md b/landing/product-docs/reference/faq.md index c32c2722..dd85f710 100644 --- a/landing/product-docs/reference/faq.md +++ b/landing/product-docs/reference/faq.md @@ -4,26 +4,62 @@ Yes. The app is free and open source. Provider or runtime access may still cost money depending on what you use. -## Do I need to install Claude or Codex first? +## Does Agent Teams include model access? + +No. Agent Teams is the local orchestration and UI layer. Model access comes from the selected runtime/provider path, such as Claude Code, Codex, or OpenCode. + +## Which runtimes are supported? + +The supported runtime paths are Claude Code, Codex, and OpenCode. The app also tracks provider ids such as Anthropic, Codex, Gemini, and OpenCode when the runtime exposes them. + +## Do I need to install Claude Code or Codex first? Not always. The app guides runtime detection and setup from the UI. Some paths still require external runtime auth. +OpenCode setup is separate from Claude Code and Codex setup. If a launch fails, check runtime status and provider auth before changing the team prompt. + ## Does it upload my code to Agent Teams servers? No. Agent Teams is not a cloud code-sync service. Provider-backed model calls may receive prompt context depending on your selected runtime. +## Where are team files stored? + +Team coordination data is stored locally under `~/.claude/teams//`, task files under `~/.claude/tasks//`, and project session data under `~/.claude/projects//` when available. + +## What can leave my machine? + +Prompt context, selected file contents, tool results, command output, task text, comments, and attachments can leave your machine through the runtime/provider path when an agent uses a provider-backed model. The exact behavior depends on the runtime and provider. + ## Can agents talk to each other? -Yes. Agents can message teammates, comment on tasks, and coordinate across teams. +Yes. Agents can message teammates, comment on tasks, coordinate across teams, and use task references to keep conversations attached to work. ## Can I review code before accepting it? Yes. The review flow is built around task-scoped diffs and hunk-level decisions. +## What is an Agent Block? + +An Agent Block is hidden agent-only text wrapped in markers such as `...`. The app strips it from normal user-facing display but keeps it available for agent coordination. + ## What is solo mode? Solo mode is a one-agent team. It is useful for smaller tasks and lower coordination overhead. +## Can different teammates use different providers? + +Yes, provider/model settings can be carried per team member when the selected runtime path supports them. OpenCode is the main path for broad multi-provider routing. + +## Why does a task show review or approved separately from done? + +The work state and review state are related but not identical. A task can be done from the agent's perspective, then move through review and approval in the kanban UI. + ## What should I do when a launch hangs? -Open troubleshooting, collect runtime logs, and verify provider auth before changing prompts. +Open troubleshooting, collect launch diagnostics, check `~/.claude/teams//`, and verify runtime/provider auth before changing prompts. + +For OpenCode, check lane/session evidence before assuming a teammate is online but ignoring messages. + +## Why are logs different across runtimes? + +Claude Code, Codex, and OpenCode expose different transcript formats and runtime evidence. Agent Teams normalizes what it can, but log completeness and attribution can differ by runtime. diff --git a/landing/product-docs/reference/privacy-local-data.md b/landing/product-docs/reference/privacy-local-data.md index feacac2e..8772a9e4 100644 --- a/landing/product-docs/reference/privacy-local-data.md +++ b/landing/product-docs/reference/privacy-local-data.md @@ -1,30 +1,56 @@ # Privacy and Local Data -Agent Teams is local-first, but the selected provider path still matters. +Agent Teams is local-first, but the selected runtime/provider path still matters. This page describes what the desktop app stores locally and what may leave your machine when agents call provider-backed models. ## What stays local -The desktop app runs on your machine and reads local project/runtime data to power the UI: +The desktop app runs on your machine and reads local project/runtime data to power the UI. Typical local data includes: - project files -- task metadata +- team configuration and member metadata +- task metadata, task comments, and task references +- inbox messages - runtime/session logs +- launch state and bootstrap diagnostics - review state - local app settings +Important local locations include: + +| Location | Purpose | +| --- | --- | +| `~/.claude/teams//` | Team config, member metadata, inboxes, launch state, bootstrap evidence, runtime diagnostics, sent-message records, kanban state, and review-related team files. | +| `~/.claude/tasks//` | Durable task JSON files for the team board. | +| `~/.claude/projects//` | Claude/Codex-style project session files used for session history, context analysis, and transcript-backed UI. | + +Exact files can vary by runtime and app version. For launch debugging, the newest evidence is usually under the relevant `~/.claude/teams//` folder. + ## What can leave your machine -When an agent asks a provider-backed model to work, prompt context and tool results may be sent through that provider/runtime path. This depends on the runtime and provider you choose. +Agent Teams itself is not a cloud code-sync service for your repository. It does not need to upload your whole project to an Agent Teams server to show the board, inbox, logs, or review UI. + +However, when an agent asks a provider-backed model to work, prompt context, selected file contents, task text, comments, tool results, command output, and other runtime-provided context may be sent through the selected runtime/provider path. What is sent depends on the runtime, model, tool calls, prompt, and provider configuration. + +Provider authentication, provider-side retention, training, logging, regional processing, and billing are governed by the provider/runtime you choose. Review those policies for sensitive projects. + +## What the app does not guarantee + +- It cannot guarantee that provider-backed model calls never receive private code. +- It cannot override provider retention or billing policies. +- It cannot make a remote provider behave like a fully local model. +- It cannot protect secrets that an agent is instructed to paste into prompts, task comments, files, or commands. +- It cannot make every runtime expose the same transcript or audit detail. ## Practical guidance -- Do not attach secrets to tasks. +- Do not attach secrets to tasks, comments, or direct messages. - Review provider policies for sensitive projects. - Use lower autonomy for risky repositories. - Keep task scope narrow when working with private code. - Prefer local evidence and logs when debugging. +- Check generated prompts, task descriptions, and attached files before asking agents to work on confidential material. +- Use provider/model paths that match your privacy requirements. ## Open source model -The app itself is open source and free. You can inspect how local orchestration, task tracking, and review flows work in the repository. - +The app itself is open source and free. You can inspect how local orchestration, task tracking, inboxes, runtime diagnostics, and review flows work in the repository. diff --git a/landing/product-docs/reference/providers-runtimes.md b/landing/product-docs/reference/providers-runtimes.md index 5044ccda..c9a38e14 100644 --- a/landing/product-docs/reference/providers-runtimes.md +++ b/landing/product-docs/reference/providers-runtimes.md @@ -1,6 +1,6 @@ # Providers and Runtimes -Agent Teams separates orchestration from model access. +Agent Teams separates orchestration from model access. The app manages teams, tasks, messages, launch state, and review UI; the selected runtime/provider path performs the actual model work. ## What the app provides @@ -12,6 +12,8 @@ Agent Teams provides: - task logs - review UI - local project integration +- runtime detection and capability checks +- local logs and diagnostics ## What the runtime provides @@ -21,20 +23,52 @@ The runtime provides: - provider authentication - tool execution behavior - model-specific rate limits and capabilities +- runtime-specific transcripts and delivery evidence -## Common choices +## Supported runtime paths -| Runtime | Notes | +| Runtime path | Provider/model path | Best fit | Notes | | --- | --- | -| Claude | Good for Claude Code users and Anthropic access | -| Codex | Good for Codex-native workflows and OpenAI access | -| OpenCode | Good for multimodel routing and broad provider coverage | +| Claude Code | Anthropic / Claude models | Claude Code users and Anthropic-backed workflows | Default local-first path for Claude teams. Requires the runtime and account access to be available locally. | +| Codex | Codex / OpenAI-backed models | Codex-native workflows | Uses Codex runtime integration and Codex auth/account state where available. Some diagnostics are different from Claude transcripts. | +| OpenCode | OpenCode-managed model routing | Multi-provider teams and broad model coverage | OpenCode can route through many model providers. Agent Teams treats OpenCode lanes as runtime-specific evidence and avoids guessing when lane identity is ambiguous. | + +## Provider ids + +The app currently recognizes these provider ids in team/runtime configuration: + +| Provider id | Display intent | +| --- | --- | +| `anthropic` | Anthropic / Claude Code path | +| `codex` | Codex path | +| `gemini` | Gemini provider path when exposed by the runtime | +| `opencode` | OpenCode path, including OpenCode-managed provider routing | + +Do not read this table as a guarantee that every provider is authenticated, installed, or available for every model on every machine. The runtime status and capability checks are the source of truth for a given launch. + +## Multi-provider strategy + +Agent Teams keeps orchestration provider-aware but not provider-owned: + +- teams, tasks, inboxes, comments, review state, and launch diagnostics stay in local Agent Teams storage +- each member can carry provider/model settings through team launch metadata +- model availability, auth, rate limits, and tool behavior remain runtime/provider responsibilities +- OpenCode is the broadest routing path when you want one team to use multiple provider/model lanes ## Provider costs -Agent Teams is free. Provider usage is governed by the runtime/provider you select. +Agent Teams is free and open source. Provider usage is governed by the runtime/provider you select: subscription limits, API keys, account auth, rate limits, and provider policies all remain external to the app. ## Capability checks During setup, the app may perform access and capability checks. This helps detect missing runtime auth before a team launch fails halfway through provisioning. +Capability checks can report that a provider exists but is not authenticated, that a model list is unavailable, that a runtime path is missing, or that a specific extension capability is unsupported. Treat those results as setup diagnostics, not task failures. + +## Limits to expect + +- Runtime support does not mean equal feature parity across Claude Code, Codex, and OpenCode. +- Log and transcript coverage differs by runtime. +- OpenCode lanes need stable lane/session evidence before the app can attribute runtime logs safely. +- Provider model names and availability can change outside the app. +- A team prompt cannot fix missing auth, missing PATH entries, provider outages, or exhausted rate limits. diff --git a/landing/product-docs/ru/guide/agent-workflow.md b/landing/product-docs/ru/guide/agent-workflow.md index 984bcba1..0f525cd8 100644 --- a/landing/product-docs/ru/guide/agent-workflow.md +++ b/landing/product-docs/ru/guide/agent-workflow.md @@ -5,65 +5,65 @@ Agent Teams делает работу агентов видимой через t ## Режимы | Режим | Описание | -| --- | --- | -| Solo | Один teammate с самоуправляемыми задачами | +|-------|----------| +| Solo | Один teammate с самостоятельным управлением задачами | | Team | Несколько teammates, работающих параллельно и ревьюящих друг друга | -В обоих режимах используются одни и те же канбан, task logs и surface для код-ревью. +Оба режима используют одну и ту же канбан-доску, логи задач и поверхность код-ревью. ## Жизненный цикл задачи -| Этап | Что происходит | Владелец | -| --- | --- | --- | -| Provisioning | Приложение запускает runtime, проверяет, что процесс жив, и ждёт подтверждения bootstrap | App | -| Planning | Lead создаёт задачи, опционально назначает teammates и ставит зависимости | Lead или пользователь | +| Этап | Что происходит | Ответственный | +|------|---------------|---------------| +| Provisioning | Приложение запускает runtime, проверяет, что процесс жив, и ждёт подтверждения bootstrap | Приложение | +| Planning | Lead создаёт задачи, назначает teammates и задаёт зависимости | Lead или пользователь | | In progress | Агенты работают параллельно и обновляют статус задач через board MCP tools | Teammates | -| Review | Изменения проверяют агенты или вы перед финальным принятием | Lead или пользователь | -| Done | Принятая работа остаётся связанной с историей задачи и доступна для просмотра | Пользователь | +| Review | Изменения проверяют агенты или вы перед финальным принятием | Team lead или пользователь | +| Done | Принятая работа остаётся связанной с историей задачи и доступна для инспекции | Пользователь | ### Planning → In progress -Когда teammate начинает задачу, статус на доске меняется на `in_progress`. Агент создаёт task comment с планом и продолжает работу. Все native tool actions (read, bash, edit, write) попадают в task log. +Когда teammate берёт задачу, статус на доске меняется на `in_progress`. Агент создаёт task comment с планом работы и продолжает. Все нативные инструменты (read, bash, edit, write) попадают в task log. ### In progress → Review -Когда агент завершает работу, он публикует result comment и помечает задачу как `completed`. Lead может принять задачу сразу или перевести её в review. +Когда teammate завершает работу, он публикует result comment и помечает задачу `completed`. Lead затем решает — принять сразу или отправить на ревью. ### Review → Done -Если изменения в review выглядят корректно, одобрите review. Задача финализируется и связывается с diff. +Если изменения в review surface выглядят приемлемо, approve the review. Задача финализируется и связывается со своим diff. -::: warning Review с правками -Если во время review агенту запрошены изменения, он должен оставить follow-up comment с исправлениями, после чего lead может одобрить задачу. +::: warning Ревью с правками +Если teammate попросили внести правки во время ревью, он должен добавить follow-up comment с исправлениями, после чего lead может approve. ::: ## Канбан-доска -Доска - основной рабочий экран. Через неё удобно: +Доска — основной рабочий экран. Через неё удобно: - Смотреть открытые, заблокированные и на ревью задачи -- Открывать task detail и читать runtime logs -- Ревьюить changes без ручного чтения session files +- Открывать task detail и инспектировать runtime logs +- Ревьюить изменения без чтения raw session files - Назначать или переназначать владельцев ::: tip -Используйте quick action buttons на карточках для старта, завершения или запроса ревью без открытия detail panel. +Используйте quick action buttons на карточках для старта, завершения или запроса ревью, не открывая detail panel. ::: ## Сообщения и комментарии | Канал | Когда использовать | -| --- | --- | +|-------|-------------------| | Direct message | Перенаправить агента, задать быстрый вопрос | | Task comment | Заметки, относящиеся к конкретной задаче | -Комментарии сохраняют контекст для review и появляются в таймлайне задачи. +Комментарии сохраняют контекст для последующего ревью и появляются в timeline задачи. ::: tip Предпочитайте task comments -Если замечание относится к конкретной задаче, добавьте его как комментарий к задаче, а не direct message. Это сохраняет историю, привязанную к работе. +Если заметка касается конкретной задачи, добавьте её как комментарий к задаче, а не как direct message. Это сохраняет историю, привязанную к работе. ::: -## Task logs +## Логи задач Task-specific logs изолируют runtime output, actions и messages по одному assignment. Они помогают понять: @@ -72,14 +72,14 @@ Task-specific logs изолируют runtime output, actions и messages по - Просил ли он помощи у teammate? - Какая задача породила diff? -## Параллельная работа +## Параллельные паттерны работы -Teammates могут работать над независимыми задачами одновременно. Вы также можете создавать зависимости (`blocked-by`), чтобы одна задача ждала завершения другой. Следите за заблокированными колонками на доске и переназначайте владельцев, если один teammate простаивает, а другой перегружен. +Teammates могут работать над независимыми задачами одновременно. Вы также можете создавать dependency links (`blocked-by`), чтобы одна задача ждала завершения другой. Следите за blocked lanes на доске и переназначайте владельцев, если один teammate простаивает, а другой перегружен. -## Live processes +## Процессы в реальном времени -Live process section показывает URLs и running processes, когда агенты поднимают локальные servers или tools. Открывайте URL прямо из приложения. Процессы остаются зарегистрированными до явной остановки или выхода runtime. +Live process section показывает URLs и running processes, когда агенты поднимают локальные servers или tools. Открывайте URL прямо из приложения. Процессы остаются зарегистрированными, пока не будут явно остановлены или runtime не завершится. ## Межкомандное взаимодействие -Агенты могут отправлять сообщения в другие команды, если команды связаны. Используйте это для handoffs, shared libraries или status checks между командами. +Агенты могут отправлять сообщения другим командам, когда команды связаны. Используйте это для handoffs, shared libraries или проверки статуса между squad. diff --git a/landing/product-docs/ru/guide/code-review.md b/landing/product-docs/ru/guide/code-review.md index bf8ae877..a49485f5 100644 --- a/landing/product-docs/ru/guide/code-review.md +++ b/landing/product-docs/ru/guide/code-review.md @@ -1,63 +1,63 @@ # Код-ревью -Code review в Agent Teams строится вокруг задачи. Вы смотрите изменения конкретной задачи, а не огромный неструктурированный diff. +Код-ревью в Agent Teams строится вокруг задачи. Вы смотрите изменения конкретной задачи, а не огромный неструктурированный diff. -## Review surface +## Поверхность ревью Для каждой завершённой задачи, затронувшей файлы, review UI позволяет: - Смотреть changed files с контекстом до/после - Принимать или отклонять отдельные hunks - Оставлять inline comments -- Связывать diff с описанием задачи и agent logs +- Связывать diff с описанием задачи и логами агента -## Hunk-level decisions +## Решения на уровне hunk Принимайте маленькие правильные изменения и отклоняйте отдельные ошибки без удаления всей работы. Это полезно, когда агент в целом решил задачу, но переборщил в одном файле. ::: tip Принимайте по частям -Если diff в основном корректен, сначала примите хорошие hunks и запросите изменения только для тех частей, которые нуждаются в правке. Это не даёт доске застопориться. +Если diff в основном верен, сначала примите хорошие hunks и запросите правки только для проблемных частей. Это не даёт доске застопориться. ::: -## Запуск review +## Инициирование ревью 1. Откройте завершённую задачу 2. Перейдите на вкладку **Changes** 3. Если diff выглядит разумно, нажмите **Request Review**, чтобы переместить задачу в колонку review -Во время review задача ещё не считается завершённой, поэтому другие teammates или lead могут оставлять к ней комментарии. +Во время ревью задача ещё не считается завершённой, поэтому другие teammates или lead могут всё ещё комментировать её. -## Состояния review +## Состояния ревью | Состояние | Значение | -| --- | --- | -| `none` | Задача новая, в работе или завершена, но ещё не на review | -| `review` | Задача активно на review | -| `needsFix` | Запрошены изменения; владелец должен обновить до повторного одобрения | -| `approved` | Review принят, задача финализирована | +|-----------|---------| +| `none` | Задача новая, в работе или завершена, но ещё не на ревью | +| `review` | Задача активно на ревью | +| `needsFix` | Запрошены правки; владелец должен обновить до повторного approve | +| `approved` | Ревью принято, задача финализирована | -## Agent review workflow +## Рабочий процесс ревью агентами Команды могут ревьюить работу друг друга до вашего финального решения. Это ловит очевидные регрессии, но risky areas всё равно стоит проверять вручную. -## Участники review +## Участники ревью -Team lead - reviewer по умолчанию. Вы можете настроить дополнительных reviewers в настройках Kanban, если хотите, чтобы peers ревьюили работу друг друга. +Team lead — ревьюер по умолчанию. Вы можете настроить дополнительных ревьюеров в настройках Kanban, если хотите, чтобы peers ревьюили работу друг друга. ## Что проверять вручную -Приоритет при review: +Приоритетные области при ревью: -- **Provider auth и runtime detection** — изменил ли агент setup так, что сломались другие пути? -- **IPC, preload и filesystem boundaries** — сохраняется ли разделение ответственности в Electron -- **Git и worktree behavior** — проверьте naming веток, коммиты и пуши -- **Parsing и task lifecycle logic** — изменения task references, chunking или filtering могут сломать доставку сообщений -- **Persistence и code review flows** — изменения хранилища задач или review state должны оставаться консистентными через IPC layers +- **Provider auth и runtime detection** — не сломает ли агент настройку runtime для других путей? +- **IPC, preload и filesystem boundaries** — сохраняйте разделение ответственности Electron +- **Git и worktree behavior** — проверяйте имена веток, коммиты и push +- **Parsing и task lifecycle logic** — изменения в task references, chunking или filtering могут сломать доставку сообщений +- **Persistence и code review flows** — изменения в хранении задач или review state должны оставаться консистентными через IPC layers -## Verification +## Верификация Лучше запускать focused verification commands. Broad formatting или lint-fix команды не стоит использовать, если задача явно не про форматирование. ::: warning Не запускайте автоформатирование по всему проекту -Если задача не про форматирование, избегайте `pnpm lint:fix` на нерелевантных файлах. Это создаёт шум в review surface. +Если задача не специфически про форматирование, избегайте `pnpm lint:fix` на несвязанных файлах. Это создаёт шум в review surface. ::: diff --git a/landing/product-docs/ru/guide/create-team.md b/landing/product-docs/ru/guide/create-team.md index c99a382e..42448438 100644 --- a/landing/product-docs/ru/guide/create-team.md +++ b/landing/product-docs/ru/guide/create-team.md @@ -1,26 +1,40 @@ # Создание команды -Команда - это группа агентов с ролями, lead-агентом, целевым проектом и coordination prompt. +Команда — это группа агентов с ролями, lead-агентом, целевым проектом и coordination prompt. ## Первая команда Начните с малого: -| Роль | Задача | -| --- | --- | -| Lead | Делит работу, создаёт задачи, координирует teammates | -| Builder | Реализует scoped tasks | -| Reviewer | Проверяет результат, ловит регрессии, просит fixes | +| Роль | Задача | +| -------- | ---------------------------------------------------- | +| Lead | Делит работу, создаёт задачи, координирует teammates | +| Builder | Реализует scoped tasks | +| Reviewer | Проверяет результат, ловит регрессии, просит fixes | Такая форма даёт достаточно координации, но не создаёт лишний шум на первом запуске. +::: tip +Команду можно расширить позже. Начните с малого, проверьте workflow, затем масштабируйте. +::: + +## Назначение провайдеров и моделей + +Каждый участник команды работает через провайдер-бэкенд. В редакторе команды выберите провайдер (Claude, Codex или OpenCode) и модель для каждого участника. Приложение показывает только провайдеров, которые вы уже авторизовали. + +Микс провайдеров в одной команде поддерживается — например, Claude lead с OpenCode builder-ами. + +::: info +Поддержка Gemini в разработке и появится в списке провайдеров, когда будет готова. +::: + ## Хороший team brief В brief стоит указать: - нужный outcome - важные files или feature areas -- границы риска, например "не refactor unrelated modules" +- границы риска, например «не refactor unrelated modules» - ожидания по review - verification commands, если они известны @@ -30,9 +44,39 @@ Улучши download flow. Держи изменения внутри landing app, если shared helper явно не нужен. Создай задачи до реализации, проверь diff каждой задачи и запусти landing lint/build checks. ``` +## Изоляция через worktree + +Участники на OpenCode могут использовать **изоляцию через worktree** — работать в отдельном Git worktree вместо основного рабочего каталога. Это предотвращает конфликты файлов, когда несколько агентов редактируют один проект. + +::: warning +Изоляция через worktree требует Git-репозиторий и пока доступна только для участников на OpenCode. +::: + +Чтобы включить, переключите опцию **Worktree isolation** при добавлении или редактировании участника на OpenCode. + ## Уровень автономности -Agent Teams поддерживает разные уровни контроля. Больше автономности подходит для рутинных изменений, меньше - для provider auth, IPC, persistence, Git workflows и release tooling. +Agent Teams поддерживает разные уровни контроля. Больше автономности подходит для рутинных изменений, меньше — для рискованных областей: provider auth, IPC, персистентность, Git-операции и release tooling. + +### Уровень усилия (effort) + +У каждого участника есть настройка **effort** — она определяет, сколько reasoning провайдер вкладывает перед ответом. Выше effort — тщательнее результат, но больше времени и токенов. + +| Уровень | Когда использовать | +| ------- | ------------------------------------------------------------------------- | +| Low | Быстрые запросы, мелкие правки форматирования, рутинные изменения | +| Medium | По умолчанию для большинства задач по реализации | +| High | Сложные рефакторинги, кросс-модульные изменения, рискованные участки кода | + +Приложение предлагает дополнительные уровни (minimal, xhigh, max) для провайдеров, которые их поддерживают. Если модель не поддерживает настройку effort, селектор отключён и используется значение по умолчанию провайдера. + +### Быстрый режим (Fast mode) + +Переключите **Fast mode** для отдельного участника, чтобы приоритизировать скорость над глубиной. Это использует нативный быстрый режим провайдера, когда он доступен. Установите **On** для рутинных задач, **Off** для аккуратной работы или **Inherit**, чтобы следовать командному значению по умолчанию. + +### Ограничение контекста (Limit context) + +Включите **Limit context**, чтобы уменьшить контекстное окно для участника. Это полезно для моделей Claude с расширенным контекстом (например, 1M токенов) — ограничение контекста избегает лишних токенов и улучшает задержку для задач, не требующих большого контекста. ## Контекст @@ -49,3 +93,8 @@ Agent Teams поддерживает разные уровни контроля. Если lead создаёт размытые задачи, напишите ему direct message и попросите сделать задачи меньше и проверяемее. +## Дальше + +- [Настройка рантайма](/ru/guide/runtime-setup) — авторизация провайдеров и выбор моделей +- [Код-ревью](/ru/guide/code-review) — принять, отклонить или прокомментировать изменения агентов +- [Диагностика](/ru/guide/troubleshooting) — частые проблемы и решения diff --git a/landing/product-docs/ru/guide/installation.md b/landing/product-docs/ru/guide/installation.md index 64993c7f..8a63d8b9 100644 --- a/landing/product-docs/ru/guide/installation.md +++ b/landing/product-docs/ru/guide/installation.md @@ -4,7 +4,7 @@ Agent Teams распространяется как desktop-приложение ## Готовые сборки -Берите последний GitHub release: +Скачайте приложение на странице загрузок или из последнего [GitHub release](https://github.com/777genius/agent-teams-ai/releases): - macOS Apple Silicon: `.dmg` - macOS Intel: `.dmg` @@ -17,14 +17,27 @@ Agent Teams распространяется как desktop-приложение ## Требования -Пакетная сборка рассчитана на zero-setup onboarding. Приложение само помогает с runtime detection и provider authentication. +Пакетная сборка рассчитана на zero-setup onboarding. Приложение само помогает с runtime detection и provider authentication — ручная настройка CLI не нужна. -Для запуска из исходников: +Для работы агентных рантаймов нужен доступ хотя бы к одному провайдеру: + +| Провайдер | Способ доступа | +| ------------------ | ---------------------------------------------------------- | +| Claude (Anthropic) | Claude Code CLI login или API key | +| Codex (OpenAI) | Codex CLI login или API key | +| Gemini (Google) | _В разработке_ | +| OpenCode | API key для поддерживаемого бэкенда (например, OpenRouter) | + +::: info +Поддержка провайдера Gemini в разработке. Вы можете подготовить доступ сейчас, но он не появится в редакторе команды, пока не будет готов. +::: + +Для запуска из исходников также нужны: | Инструмент | Версия | -| --- | --- | -| Node.js | 20+ | -| pnpm | 10+ | +| ---------- | ------ | +| Node.js | 20+ | +| pnpm | 10+ | ## Запуск из исходников @@ -37,9 +50,27 @@ pnpm install pnpm dev ``` -Если нужна самая свежая локальная версия, используйте ветку репозитория, где сейчас идёт активная разработка. +Ветка `main` содержит актуальную стабильную разработку. Переключайтесь на feature-ветки, только если нужна конкретная неопубликованная правка. -## Обновления +## Автообновления -Для packaged builds берите последний release. Для запуска из исходников подтяните нужную ветку и повторите install, если поменялись зависимости. +Пакетная сборка автоматически проверяет обновления при запуске и периодически во время работы. Когда обновление доступно, приложение предложит скачать и установить его. Проверить вручную можно через меню приложения. +::: tip +При запуске из исходников автообновления недоступны. Подтягивайте свежие изменения и запускайте `pnpm install`, если зависимости изменились. +::: + +## Обновление из исходников + +Подтяните ветку `main` и повторите install, если поменялись зависимости: + +```bash +git pull +pnpm install +``` + +## Дальше + +- [Быстрый старт](/ru/guide/quickstart) — от установки до первой запущенной команды +- [Настройка рантайма](/ru/guide/runtime-setup) — авторизация провайдеров и выбор моделей +- [Создание команды](/ru/guide/create-team) — рекомендованные структуры и написание brief diff --git a/landing/product-docs/ru/guide/quickstart.md b/landing/product-docs/ru/guide/quickstart.md index d1ac7dd1..6a289824 100644 --- a/landing/product-docs/ru/guide/quickstart.md +++ b/landing/product-docs/ru/guide/quickstart.md @@ -1,50 +1,63 @@ # Быстрый старт -Этот гайд проводит от свежей установки до первой запущенной команды. +Этот гайд проводит от свежей установки до первой запущенной команды за несколько минут. ## 1. Установите Agent Teams -Скачайте последний релиз под вашу платформу на лендинге или в GitHub releases. +Скачайте последний релиз под вашу платформу на странице загрузок или в [GitHub releases](https://github.com/777genius/agent-teams-ai/releases). ::: tip -Приложение бесплатное и с открытым кодом. Выбранный runtime может требовать доступ к провайдеру, например Claude, Codex, OpenCode или API-key based providers. +Приложение бесплатное и с открытым кодом. Выбранный runtime может требовать доступ к провайдеру — подробности в разделе [Установка](/ru/guide/installation). ::: ## 2. Откройте проект -Запустите приложение и выберите директорию проекта, где агенты будут работать. Agent Teams читает локальные project files и runtime/session state, чтобы показывать задачи, логи, diffs и активность команды. +Запустите приложение и выберите директорию проекта, где агенты будут работать. Agent Teams читает локальные файлы проекта и runtime/session state, чтобы показывать задачи, логи, diffs и активность команды. -## 3. Выберите runtime path +::: tip +Выберите проект под Git — так вы получите лучший опыт. Изоляция через worktree и ревью по diff зависят от Git. +::: -Стандартные варианты: +## 3. Выберите runtime -| Runtime | Когда подходит | -| --- | --- | -| Claude | Если вы уже используете Claude Code или Anthropic access | -| Codex | Для Codex-native workflows и OpenAI access | -| OpenCode | Для multimodel teams и большого числа provider backends | +Мастер настройки автоматически определит установленные рантаймы на вашей машине. Стандартные варианты: + +| Runtime | Когда подходит | +| -------- | ------------------------------------------------------------------- | +| Claude | Если вы уже используете Claude Code или у вас есть Anthropic access | +| Codex | Для Codex-native workflows и OpenAI access | +| OpenCode | Для multi-model команд и большого числа provider backends | + +::: info +Поддержка Gemini в разработке и появится в списке рантаймов, когда будет готова. +::: + +Подробная настройка каждого провайдера — в разделе [Настройка рантайма](/ru/guide/runtime-setup). ## 4. Создайте первую команду Начните с маленькой команды: lead, implementation agent и review-oriented agent. Этого достаточно, чтобы проверить workflow без лишнего шума. +Рекомендованная структура и советы — в разделе [Создание команды](/ru/guide/create-team). + ## 5. Дайте lead-агенту конкретную цель Пишите задачу как инженерному лиду: ```text -Улучши onboarding flow. Разбей работу на задачи, держи изменения маленькими и проси review перед широкими refactor. +Улучши onboarding flow. Разбей работу на задачи, держи изменения маленькими и проси review перед широкими рефакторингами. ``` -Lead должен создать задачи, назначить работу и координировать teammates. Вы следите за прогрессом на канбан-доске и вмешиваетесь через комментарии или direct messages. +Lead создаёт задачи, назначает работу и координирует teammates. Вы следите за прогрессом на канбан-доске и вмешиваетесь через комментарии или direct messages в любой момент. ## 6. Проверьте результат Откройте задачи в review/done, посмотрите diff, примите или отклоните изменения. Если нужно понять мотивацию агента, откройте task logs. +Полный процесс ревью — в разделе [Код-ревью](/ru/guide/code-review). + ## Дальше -- [Создание команды](/ru/guide/create-team) -- [Настройка рантайма](/ru/guide/runtime-setup) -- [Код-ревью](/ru/guide/code-review) - +- [Создание команды](/ru/guide/create-team) — рекомендованные структуры и написание brief +- [Настройка рантайма](/ru/guide/runtime-setup) — авторизация провайдеров и выбор моделей +- [Код-ревью](/ru/guide/code-review) — ревью, одобрение и запрос правок diff --git a/landing/product-docs/ru/guide/runtime-setup.md b/landing/product-docs/ru/guide/runtime-setup.md index 40798606..7729cf20 100644 --- a/landing/product-docs/ru/guide/runtime-setup.md +++ b/landing/product-docs/ru/guide/runtime-setup.md @@ -1,6 +1,6 @@ # Настройка рантайма -Agent Teams - coordination layer. Model work выполняется через локальные runtimes и providers. +Agent Teams — coordination layer. Model work выполняется через локальные runtimes и providers. ## Предварительные требования @@ -17,16 +17,16 @@ Agent Teams - coordination layer. Model work выполняется через ## Поддерживаемые пути | Путь | CLI по умолчанию | Типичные провайдеры | Когда использовать | -| --- | --- | --- | --- | +|------|-------------------|---------------------|-------------------| | Claude | `claude` | Anthropic | Если вы уже используете Claude Code или Anthropic access | | Codex | `codex` | OpenAI | Для Codex-native workflows и OpenAI access | | OpenCode | `opencode` | OpenRouter и многие другие | Для multimodel routing и широкой provider coverage | Приложение по возможности определяет доступные runtimes и ведёт настройку через UI. -## Provider access +## Доступ к провайдеру -У Agent Teams нет своего платного тарифа. Вы используете доступ к провайдеру, который у вас уже есть: subscription, local runtime auth или API keys в зависимости от выбранного пути. +У Agent Teams нет своего платного тарифа. Вы используете доступ к провайдеру, который у вас уже есть: подписка, локальная авторизация рантайма или API-ключи в зависимости от выбранного пути. - Для **Claude** и **Codex** используется auth соответствующего CLI. - Для **OpenCode** требуются provider-specific API keys в файле конфигурации (например, `openrouter`, `openai`, `anthropic`). @@ -71,9 +71,9 @@ codex login Используйте точное имя провайдера, которое ожидает OpenCode. Если вы используете кастомное имя, убедитесь, что оно совпадает с provider ID в строке модели (например, `openrouter/moonshotai/kimi-k2.6` использует блок `openrouter`). -## Multimodel mode +## Multimodel-режим -Multimodel mode может направлять работу через разные provider backends в OpenCode-compatible конфигурации. Используйте его, когда нужна гибкость провайдеров или разные model lanes для teammates. +Multimodel-режим может направлять работу через разные provider backends в OpenCode-совместимой конфигурации. Используйте его, когда нужна гибкость провайдеров или разные model lanes для teammates. ::: info Model lanes Каждый teammate может использовать свою пару `providerId` + `model`. В UI редактирования команды разверните опции member, чтобы переопределить глобальные значения. diff --git a/landing/product-docs/ru/guide/troubleshooting.md b/landing/product-docs/ru/guide/troubleshooting.md index c9128dd4..eb1285e4 100644 --- a/landing/product-docs/ru/guide/troubleshooting.md +++ b/landing/product-docs/ru/guide/troubleshooting.md @@ -1,105 +1,113 @@ # Диагностика -Большинство проблем команды попадает в пять групп: runtime setup, launch confirmation, task parsing, provider limits и review state gaps. +Большинство проблем команды попадает в четыре группы: runtime setup, launch confirmation, task parsing или provider limits. ## Команда не запускается -Проверьте по порядку: +Проверьте: -1. **Runtime доступен** — выбранный CLI (`claude`, `codex`, `opencode`) установлен -2. **PATH reachable** — binary доступен в environment `PATH` -3. **Доступ к модели** — у провайдера есть доступ к запрошенной строке модели (особенно для OpenCode точные имена провайдера/модели важны) -4. **Путь к проекту** — директория проекта существует и доступна для чтения -5. **Network / VPN** — некоторые провайдеры режут трафик при активном VPN +- Выбранный runtime установлен или авторизован +- Runtime доступен в environment `PATH` +- У провайдера есть доступ к нужной модели +- Project path существует и читается -### OpenCode: registered, но bootstrap не подтверждён +::: tip +Запустите бинарник рантайма в терминале, чтобы проверить PATH и авторизацию. Например: `claude --version` или `opencode --version`. +::: -Если OpenCode показывает `registered`, но bootstrap не подтверждён, сначала смотрите артефакты, а не меняйте team prompts. +### OpenCode: bootstrap не подтверждён -Посмотрите на свежий artifact неудачного запуска: +Если OpenCode показывает `registered`, но bootstrap не подтверждён: -```bash -~/.claude/teams//launch-failure-artifacts/latest.json -``` +1. Откройте launch logs в UI. +2. Проверьте `~/.claude/teams//launch-state.json` — состояние member. +3. Посмотрите `~/.claude/teams//.opencode-runtime/lanes//manifest.json` на наличие evidence. +4. Не меняйте team prompts, пока не убедитесь, что lane стартовал, но не смог закоммитить evidence. -Манифест внутри включает: - -- `classification` — почему запуск считался неудачным -- `bootstrapTransportBreadcrumb` — использованный путь доставки -- Статусы spawn members -- Редактированные логи и traces - -Также проверьте lane manifest: - -```bash -jq '.lanes' ~/.claude/teams//.opencode-runtime/lanes.json -jq '.activeRunId, .entries' ~/.claude/teams//.opencode-runtime/lanes//manifest.json -``` - -::: tip Не гадайте по UI -Всегда коррелируйте UI-диагностику с persisted файлами (`launch-state.json`, `bootstrap-journal.jsonl`) и runtime-specific evidence. +::: warning +Отсутствие OpenCode inbox во время primary launch — норма. Secondary lanes стартуют после готовности primary filesystem. Не считайте primary hang багом OpenCode, пока UI явно не показывает, что `Y` членов ждёт и `Y` некорректно включает OpenCode lanes. ::: ## Не видны ответы агента Откройте task logs и teammate messages. Пропавшие replies часто связаны с: -- **Runtime delivery retry** — агент мог ответить, но сообщение не было доставлено в приложение. Проверьте delivery ledger. -- **Parsing или filtering** — вывод агента не содержал ожидаемых маркеров или task references. -- **Task attribution** — работа выполнялась в сессии, но не была привязана к задаче, так как в выводе отсутствовал корректный task id. +- Runtime delivery gaps +- Parsing или task filtering issues +- Агент всё ещё обрабатывает (большие задачи могут занимать минуты) -::: warning Не считайте молчание игнорированием Не считайте, что модель проигнорировала сообщение, пока это не подтверждено логами. + +::: tip +Для OpenCode teammates проверьте, что вызван `agent-teams_message_send` с правильными `from`, `to` и `taskRefs`. Ответы OpenCode должны отправляться через MCP tools, а не обычным текстом. ::: ## Changes не связаны с tasks -Используйте task-specific logs и code review links. Если diff выглядит detached, проверьте, был ли task id или task reference в output агента. +Используйте task-specific logs и code review links. Если diff выглядит detached: -Для OpenCode teammates авторитетным доказательством принадлежности сессии задаче является `opencode-sessions.json` и запись в lane manifest, а не только UI message stream. +- Проверьте, был ли task id или task reference в output агента. +- Убедитесь, что агент вызвал `task_add_comment` перед правками. +- Убедитесь, что агент вызвал `task_start`, чтобы доска знала о начале работы. ## Rate limits Если провайдер сообщает reset time, Agent Teams может подтолкнуть lead продолжить после cooldown. Если reset time неизвестен, подождите или смените provider/runtime path. -| Поведение провайдера | Рекомендуемое действие | -| --- | --- | -| Показан known reset time | Дождитесь cooldown и продолжите | -| Reset time неизвестен | Смените провайдера или runtime path | -| Повторяющиеся 429 | Снизьте concurrency или используйте другой model lane | +## Распространённые состояния member -## Проблемы CLI auth +| Состояние | Значение | +|-----------|---------| +| `confirmed_alive` + `bootstrapConfirmed` | Здоров и готов к работе | +| `registered` / `runtime_pending_bootstrap` | Процесс или lane существует, но bootstrap proof ещё не закоммичен | +| `failed_to_start` + `runtime_process` | Процесс есть, но launch gate не прошёл. Смотрите diagnostics | +| `failed_to_start` + `stale_metadata` | Сохранённый pid/session устарел или мёртв | -### `claude login` не сохраняется +::: warning +`member_briefing` сам по себе НЕ является runtime evidence. Для OpenCode авторитетным доказательством служит committed runtime evidence, такая как `opencode-sessions.json` и запись в manifest. +::: -Если CLI авторизован в одном терминале, но приложение говорит, что нет — проверьте, что auth сохранён в ожидаемый config path и что процесс приложения видит тот же `$HOME`. +## Режим отладки рантайма -### OpenCode provider key отклонён +Для локальной отладки можно принудительно запускать teammates в tmux-панелях: -- Дважды проверьте, что имя провайдера в `config.json` совпадает с префиксом в строке модели -- Убедитесь, что ключ не истёк и не отозван в dashboard провайдера +```bash +# Запуск из терминала +CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev -## Lane bootstrap завис +# Или добавьте в custom CLI args +--teammate-mode tmux +``` -Для OpenCode secondary lanes: +Используйте это для инспекции интерактивного поведения CLI. Не считайте поведение полностью эквивалентным process backend. -- Отсутствие `inboxes/.json` — не автоматически баг. OpenCode lanes не обязаны быть primary-inbox-created перед стартом. -- Если UI показывает, что команда всё ещё запускается, а primary members уже usable, "all teammates joined" ждёт secondary lanes. -- Если `Prepared communication channels for X/Y members` зависло, проверьте, что `Y` некорректно включает secondary OpenCode members. +## CLI auth diagnostic -### Пустые entries в lane manifest +Каждый запуск `CliInstallerService.getStatus()` дописывает одну строку в `claude-cli-auth-diag.ndjson` в папке логов Electron (обычно `~/Library/Logs//` на macOS). Если файл превышает **512 KiB**, он обнуляется перед следующей записью. -Если bridge говорит, что bootstrap успешен, но `manifest.json` показывает `entries: []`, проблема в **evidence commit**, а не в поведении модели. Member не должен считаться deliverable до тех пор, пока не существуют `opencode-sessions.json` и его запись в manifest. +Проверьте этот файл, если видите «Not logged in» или ошибки авторизации в упакованном приложении. + +## Безопасная очистка + +При очистке stale processes: + +1. Определите pid и убедитесь, что он принадлежит текущей команде/lane. +2. Останавливайте только процессы, явно принадлежащие smoke test или отлаживаемому launch. +3. **Не убивайте** все процессы OpenCode или shared hosts в качестве shortcut. ## Какие данные собрать -Прежде чем обращаться за помощью, соберите: +Соберите: -- Task id (короткий или полный) -- Team name -- Runtime path (`claude`, `codex` или `opencode`) -- Excerpt launch logs (из `latest.json` или `bootstrap-journal.jsonl`) -- Provider / model string -- Точный time window, когда произошла проблема +- task id +- team name +- runtime path +- launch log excerpt +- provider/model +- точный time window -Этих данных обычно достаточно для диагностики launch и task lifecycle issues. +Этого обычно хватает для диагностики launch и task lifecycle issues. + +::: tip +Если проблема не устраняется, откройте persisted files команды под `~/.claude/teams//` и сопоставьте UI diagnostics с live process state, прежде чем менять код. +::: diff --git a/landing/product-docs/ru/index.md b/landing/product-docs/ru/index.md index 2143cde3..8ffa18e9 100644 --- a/landing/product-docs/ru/index.md +++ b/landing/product-docs/ru/index.md @@ -31,7 +31,7 @@ features: link: /ru/guide/code-review linkText: Ревью изменений - icon: "04" - title: Runtime-aware setup + title: Настройка рантайма details: Используйте Claude, Codex, OpenCode или multimodel-провайдеры через доступ, который у вас уже есть. link: /ru/guide/runtime-setup linkText: Настроить рантаймы diff --git a/landing/product-docs/ru/reference/concepts.md b/landing/product-docs/ru/reference/concepts.md index 0ee1b06c..421cef40 100644 --- a/landing/product-docs/ru/reference/concepts.md +++ b/landing/product-docs/ru/reference/concepts.md @@ -1,32 +1,75 @@ # Концепции -Основные термины Agent Teams. +Основные термины Agent Teams. Эта страница задаёт общий словарь для приложения, доски задач, сообщений и review flow. ## Team -Team - группа агентов, настроенная для проекта. Обычно есть lead и один или несколько teammates со специализированными ролями. +Team - именованная группа агентов, привязанная к одному project path. У команды есть lead, опциональные teammates, настройки runtime/provider, prompts, inboxes, tasks и локальное состояние запуска. ## Lead -Lead координирует работу: разбивает цель на tasks, назначает teammates, отслеживает blockers и просит review. +Lead - координатор команды. Он превращает цель пользователя в tasks, назначает или перенаправляет teammates, отслеживает blockers, запрашивает review и двигает работу по board. + +Сообщения lead доставляются иначе, чем сообщения teammate: приложение ретранслирует записи inbox в lead runtime, а teammates читают свои inbox-файлы между turns. + +## Teammate + +Teammate - не-lead агент в команде. Обычно teammate отвечает за сфокусированную роль: builder, reviewer, researcher или tester. Teammate может получать direct messages, task assignments, task comments и review requests. ## Task -Task - устойчивая единица работы. У неё есть status, description, comments, logs, attachments и reviewable changes. +Task - долговечная единица работы. У неё есть id, status, owner, description, comments, logs, attachments, task references и reviewable changes. -## Solo mode +Типичные состояния task: `todo`, `in_progress`, `done`, `review`, `approved`. Файл task хранит рабочее состояние, а review/approval позиция может дополнительно храниться в kanban overlay state. -Solo mode запускает команду из одного агента. Полезно для маленьких задач, меньшего token usage и проверки prompt перед расширением до команды. +## Kanban -## Cross-team communication +Kanban - board view для командной работы. Он помогает смотреть tasks по состояниям, открывать детали, читать logs, ревьюить diffs, approve finished work или request changes. -Агенты могут писать внутри и между командами. Это нужно, когда разные teams владеют связанными частями работы. +## Inbox -## Autonomy level +Inbox - локальный message-файл участника команды. Agent Teams использует inboxes для user messages, lead messages, teammate messages, runtime delivery metadata, cross-team messages и части system notifications. -Autonomy определяет, сколько агент может делать до запроса подтверждения. Больше автономности быстрее, меньше - безопаснее для sensitive code paths. +Messages - долговечные локальные записи. Но доставка всё равно зависит от того, жив ли выбранный runtime и сможет ли он обработать следующий turn. + +## Agent Block + +Agent Block - скрытый agent-only instruction text, обёрнутый в `...`. UI убирает такие блоки из обычного human-facing display, но agents и runtime delivery могут использовать их для coordination details. + +Текущий canonical marker - `info_for_agent`; в старых документах могут встречаться legacy agent block formats. + +## Context Phase + +Context Phase - сегмент session context timeline. Compaction начинает новую phase, поэтому token/context usage можно анализировать до и после reset. + +Context tracking разделяет категории: project instructions, mentioned files, tool output, thinking text, team coordination и user messages. Эти числа нужны для диагностики, а не как provider billing statement. ## Runtime -Runtime - локальный execution path, который соединяет Agent Teams с model/provider workflow, например Claude, Codex или OpenCode. +Runtime - локальный execution path, который выполняет agent turn. Поддерживаемые runtime paths: Claude Code, Codex и OpenCode. +Runtime отвечает за model execution behavior, auth details, tool execution semantics, rate limits, model availability и часть transcript/log formats. + +## Provider + +Provider - путь доступа к модели за runtime. Текущие provider ids: Anthropic, Codex, Gemini и OpenCode. OpenCode может маршрутизировать к множеству model providers через собственную конфигурацию. + +Agent Teams orchestrates tasks and messages, но не заменяет provider authentication или provider policy. + +## Solo mode + +Solo mode запускает команду из одного агента. Полезно для небольших задач, меньшего coordination overhead и проверки prompt перед расширением до команды. + +## Cross-team communication + +Агенты могут писать внутри и между командами. Это нужно, когда разные teams владеют связанными частями работы, но их не хочется объединять в одну большую команду. + +## Autonomy level + +Autonomy определяет, сколько агент может делать до запроса подтверждения. Больше autonomy быстрее, меньше - безопаснее для sensitive code paths, persistence, provider auth, Git operations и releases. + +## Review + +Review - task-scoped acceptance flow. Task может перейти в review, получить comments или requested changes, а затем перейти в approved, когда результат принят. + +Review привязан к local diffs и task history, поэтому лучше работает с узкими tasks и явным упоминанием task, над которой агент работает. diff --git a/landing/product-docs/ru/reference/faq.md b/landing/product-docs/ru/reference/faq.md index dcebc8b8..087ff69a 100644 --- a/landing/product-docs/ru/reference/faq.md +++ b/landing/product-docs/ru/reference/faq.md @@ -4,26 +4,62 @@ Да. Приложение бесплатное и open source. Provider или runtime access может стоить денег в зависимости от выбранного пути. -## Нужно ли заранее ставить Claude или Codex? +## Agent Teams включает доступ к моделям? + +Нет. Agent Teams - локальный orchestration и UI layer. Model access приходит через выбранный runtime/provider path, например Claude Code, Codex или OpenCode. + +## Какие runtimes поддерживаются? + +Поддерживаемые runtime paths: Claude Code, Codex и OpenCode. App также отслеживает provider ids вроде Anthropic, Codex, Gemini и OpenCode, когда runtime их отдаёт. + +## Нужно ли заранее ставить Claude Code или Codex? Не всегда. Приложение ведёт runtime detection и setup через UI. Некоторые пути всё равно требуют внешнюю авторизацию runtime. +OpenCode setup отделён от Claude Code и Codex setup. Если launch fails, сначала проверьте runtime status и provider auth, а не меняйте team prompt. + ## Приложение загружает мой код на серверы Agent Teams? Нет. Agent Teams не является cloud code-sync сервисом. Но provider-backed model calls могут получать prompt context в зависимости от выбранного runtime. +## Где хранятся team files? + +Team coordination data хранится локально в `~/.claude/teams//`, task files - в `~/.claude/tasks//`, а project session data - в `~/.claude/projects//`, когда она доступна. + +## Что может выйти с моей машины? + +Prompt context, selected file contents, tool results, command output, task text, comments и attachments могут уйти через runtime/provider path, когда агент использует provider-backed model. Точное поведение зависит от runtime и provider. + ## Агенты могут общаться друг с другом? -Да. Агенты могут писать teammates, комментировать tasks и координироваться между teams. +Да. Агенты могут писать teammates, комментировать tasks, координироваться между teams и использовать task references, чтобы разговор оставался привязанным к работе. ## Можно ревьюить код перед принятием? Да. Review flow построен вокруг task-scoped diffs и hunk-level decisions. +## Что такое Agent Block? + +Agent Block - скрытый agent-only text в маркерах вроде `...`. App убирает его из обычного user-facing display, но сохраняет для agent coordination. + ## Что такое solo mode? Solo mode - команда из одного агента. Подходит для небольших задач и меньшего coordination overhead. +## Могут ли разные teammates использовать разных providers? + +Да, provider/model settings могут задаваться per team member, если выбранный runtime path это поддерживает. OpenCode - основной путь для широкой multi-provider routing. + +## Почему task может быть review или approved отдельно от done? + +Work state и review state связаны, но не идентичны. Task может быть done с точки зрения агента, а затем пройти review и approval в kanban UI. + ## Что делать, если launch завис? -Откройте диагностику, соберите runtime logs и проверьте provider auth до изменения prompts. +Откройте troubleshooting, соберите launch diagnostics, проверьте `~/.claude/teams//` и runtime/provider auth до изменения prompts. + +Для OpenCode проверьте lane/session evidence, прежде чем считать, что teammate online, но игнорирует messages. + +## Почему logs отличаются между runtimes? + +Claude Code, Codex и OpenCode отдают разные transcript formats и runtime evidence. Agent Teams нормализует то, что может, но log completeness и attribution могут отличаться по runtime. diff --git a/landing/product-docs/ru/reference/privacy-local-data.md b/landing/product-docs/ru/reference/privacy-local-data.md index 52f0b9f5..a709d85b 100644 --- a/landing/product-docs/ru/reference/privacy-local-data.md +++ b/landing/product-docs/ru/reference/privacy-local-data.md @@ -1,30 +1,56 @@ # Приватность и локальные данные -Agent Teams local-first, но выбранный provider path всё равно важен. +Agent Teams local-first, но выбранный runtime/provider path всё равно важен. Эта страница описывает, что desktop app хранит локально и что может покинуть машину, когда agents вызывают provider-backed models. ## Что остаётся локально -Desktop app работает на вашей машине и читает локальные project/runtime data для UI: +Desktop app работает на вашей машине и читает local project/runtime data для UI. Обычно локально есть: - project files -- task metadata +- team configuration и member metadata +- task metadata, task comments и task references +- inbox messages - runtime/session logs +- launch state и bootstrap diagnostics - review state - local app settings +Важные local locations: + +| Location | Purpose | +| --- | --- | +| `~/.claude/teams//` | Team config, member metadata, inboxes, launch state, bootstrap evidence, runtime diagnostics, sent-message records, kanban state и review-related team files. | +| `~/.claude/tasks//` | Durable task JSON files для team board. | +| `~/.claude/projects//` | Claude/Codex-style project session files для session history, context analysis и transcript-backed UI. | + +Точные файлы зависят от runtime и версии app. Для launch debugging самые свежие evidence обычно лежат в соответствующей папке `~/.claude/teams//`. + ## Что может выйти с машины -Когда агент обращается к provider-backed model, prompt context и tool results могут отправляться через выбранный provider/runtime path. Это зависит от runtime и provider. +Agent Teams сам по себе не является cloud code-sync сервисом для репозитория. Ему не нужно загружать весь project на Agent Teams server, чтобы показывать board, inbox, logs или review UI. + +Но когда агент обращается к provider-backed model, prompt context, selected file contents, task text, comments, tool results, command output и другой runtime-provided context могут отправляться через выбранный runtime/provider path. Что именно отправится, зависит от runtime, model, tool calls, prompt и provider configuration. + +Provider authentication, provider-side retention, training, logging, regional processing и billing регулируются выбранным provider/runtime. Для sensitive projects проверяйте их policies. + +## Чего app не гарантирует + +- App не может гарантировать, что provider-backed model calls никогда не получат private code. +- App не может переопределить provider retention или billing policies. +- App не может сделать remote provider полностью local model. +- App не защитит secrets, если агенту поручили вставить их в prompts, task comments, files или commands. +- App не может заставить все runtimes отдавать одинаковый transcript или audit detail. ## Практические правила -- Не прикладывайте secrets к tasks. +- Не прикладывайте secrets к tasks, comments или direct messages. - Проверяйте provider policies для sensitive projects. - Используйте меньшую autonomy для risky repositories. - Держите task scope узким при работе с private code. - Для диагностики опирайтесь на local evidence и logs. +- Проверяйте generated prompts, task descriptions и attached files перед работой с confidential material. +- Выбирайте provider/model paths, которые соответствуют вашим privacy requirements. ## Open source -Само приложение open source и бесплатное. В репозитории можно посмотреть, как устроены local orchestration, task tracking и review flows. - +Само приложение open source и бесплатное. В репозитории можно посмотреть, как устроены local orchestration, task tracking, inboxes, runtime diagnostics и review flows. diff --git a/landing/product-docs/ru/reference/providers-runtimes.md b/landing/product-docs/ru/reference/providers-runtimes.md index 31783d3e..c32f0f85 100644 --- a/landing/product-docs/ru/reference/providers-runtimes.md +++ b/landing/product-docs/ru/reference/providers-runtimes.md @@ -1,6 +1,6 @@ # Провайдеры и рантаймы -Agent Teams отделяет orchestration от model access. +Agent Teams отделяет orchestration от model access. Приложение управляет teams, tasks, messages, launch state и review UI; выбранный runtime/provider path выполняет реальную model work. ## Что даёт приложение @@ -12,6 +12,8 @@ Agent Teams даёт: - task logs - review UI - local project integration +- runtime detection и capability checks +- local logs и diagnostics ## Что даёт runtime @@ -21,20 +23,52 @@ Runtime отвечает за: - provider authentication - tool execution behavior - rate limits и capabilities конкретной модели +- runtime-specific transcripts и delivery evidence -## Частые варианты +## Поддерживаемые runtime paths -| Runtime | Заметки | +| Runtime path | Provider/model path | Когда подходит | Заметки | | --- | --- | -| Claude | Хорошо для Claude Code users и Anthropic access | -| Codex | Хорошо для Codex-native workflows и OpenAI access | -| OpenCode | Хорошо для multimodel routing и широкой provider coverage | +| Claude Code | Anthropic / Claude models | Для Claude Code users и Anthropic-backed workflows | Базовый local-first путь для Claude teams. Нужен локально доступный runtime и account access. | +| Codex | Codex / OpenAI-backed models | Для Codex-native workflows | Использует Codex runtime integration и Codex auth/account state, когда они доступны. Часть diagnostics отличается от Claude transcripts. | +| OpenCode | OpenCode-managed model routing | Для multi-provider teams и широкой model coverage | OpenCode может маршрутизировать через множество model providers. Agent Teams считает OpenCode lanes runtime-specific evidence и не угадывает attribution при ambiguous lane identity. | + +## Provider ids + +В team/runtime configuration приложение сейчас распознаёт такие provider ids: + +| Provider id | Смысл | +| --- | --- | +| `anthropic` | Anthropic / Claude Code path | +| `codex` | Codex path | +| `gemini` | Gemini provider path, когда его отдаёт runtime | +| `opencode` | OpenCode path, включая OpenCode-managed provider routing | + +Эта таблица не гарантирует, что каждый provider authenticated, installed или доступен для каждой модели на каждой машине. Runtime status и capability checks - source of truth для конкретного launch. + +## Multi-provider strategy + +Agent Teams остаётся provider-aware, но не provider-owned: + +- teams, tasks, inboxes, comments, review state и launch diagnostics хранятся в local Agent Teams storage +- каждый member может нести provider/model settings через team launch metadata +- model availability, auth, rate limits и tool behavior остаются ответственностью runtime/provider +- OpenCode - основной путь, когда одной team нужны разные provider/model lanes ## Стоимость providers -Agent Teams бесплатен. Стоимость provider usage зависит от выбранного runtime/provider. +Agent Teams бесплатен и open source. Provider usage зависит от выбранного runtime/provider: subscription limits, API keys, account auth, rate limits и provider policies остаются внешними для приложения. ## Capability checks Во время setup приложение может выполнять access и capability checks. Это помогает найти отсутствующую авторизацию до того, как team launch застрянет в provisioning. +Capability checks могут показать, что provider существует, но не authenticated; model list недоступен; runtime path отсутствует; или конкретная extension capability unsupported. Считайте это setup diagnostics, а не task failures. + +## Ожидаемые ограничения + +- Runtime support не означает одинаковый feature parity для Claude Code, Codex и OpenCode. +- Log и transcript coverage отличаются по runtime. +- Для OpenCode lanes нужна стабильная lane/session evidence, прежде чем app сможет безопасно attribute runtime logs. +- Provider model names и availability могут меняться вне приложения. +- Team prompt не исправит missing auth, missing PATH entries, provider outages или exhausted rate limits. diff --git a/src/features/codex-account/main/composition/createCodexAccountFeature.ts b/src/features/codex-account/main/composition/createCodexAccountFeature.ts index 4cf43dd5..ee29604f 100644 --- a/src/features/codex-account/main/composition/createCodexAccountFeature.ts +++ b/src/features/codex-account/main/composition/createCodexAccountFeature.ts @@ -159,6 +159,10 @@ function asRateLimits( }; } +function hasVisibleRateLimitData(snapshot: CodexRateLimitSnapshotDto | null): boolean { + return Boolean(snapshot?.primary || snapshot?.secondary || snapshot?.credits); +} + function createRuntimeContext( binaryPath: string | null | undefined, codexHome: string | null | undefined @@ -507,6 +511,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { const shouldRequestRateLimits = options?.includeRateLimits === true && !cachedRateLimitsAreFresh; let rateLimitsReadFailure: unknown | null = null; + let rateLimitsReadReturnedEmpty = false; try { const accountResult = await this.appServerClient.readAccountSnapshot({ @@ -542,11 +547,18 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { }; } if (accountResult.rateLimits?.ok) { - this.lastKnownRateLimits = { - payload: accountResult.rateLimits.payload, - observedAt: now, - accountSignature: getCodexAccountSignature(accountResult.account.account), - }; + const nextRateLimits = asRateLimits(accountResult.rateLimits.payload.rateLimits); + if (hasVisibleRateLimitData(nextRateLimits)) { + this.lastKnownRateLimits = { + payload: accountResult.rateLimits.payload, + observedAt: now, + accountSignature: + getCodexAccountSignature(accountResult.account.account) ?? + getCodexAccountSignature(accountPayload?.account ?? null), + }; + } else { + rateLimitsReadReturnedEmpty = true; + } } else if (accountResult.rateLimits) { rateLimitsReadFailure = accountResult.rateLimits.error; } @@ -587,13 +599,15 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { if (shouldLoadRateLimits) { if (this.hasFreshRateLimits(now) && reusableLastKnownRateLimits) { rateLimits = asRateLimits(reusableLastKnownRateLimits.payload.rateLimits); - } else if (rateLimitsReadFailure) { - this.logger.warn('codex account rate limits refresh failed', { - error: - rateLimitsReadFailure instanceof Error - ? rateLimitsReadFailure.message - : String(rateLimitsReadFailure), - }); + } else if (rateLimitsReadFailure || rateLimitsReadReturnedEmpty) { + if (rateLimitsReadFailure) { + this.logger.warn('codex account rate limits refresh failed', { + error: + rateLimitsReadFailure instanceof Error + ? rateLimitsReadFailure.message + : String(rateLimitsReadFailure), + }); + } if (reusableLastKnownRateLimits) { rateLimits = asRateLimits(reusableLastKnownRateLimits.payload.rateLimits); } diff --git a/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts b/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts index 98ba295f..b9b18c11 100644 --- a/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts +++ b/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts @@ -44,6 +44,7 @@ export function useCodexAccountSnapshot(options: { }): { snapshot: CodexAccountSnapshotDto | null; loading: boolean; + rateLimitsLoading: boolean; error: string | null; refresh: (options?: { includeRateLimits?: boolean; @@ -57,6 +58,7 @@ export function useCodexAccountSnapshot(options: { const electronMode = isElectronMode(); const [snapshot, setSnapshot] = useState(null); const [loading, setLoading] = useState(false); + const [rateLimitsLoading, setRateLimitsLoading] = useState(false); const [error, setError] = useState(null); const [visible, setVisible] = useState(() => isDocumentVisible()); const lastUpdatedAtRef = useRef(null); @@ -78,13 +80,17 @@ export function useCodexAccountSnapshot(options: { } const silent = refreshOptions?.silent === true; + const includeRateLimits = refreshOptions?.includeRateLimits ?? options.includeRateLimits; if (!silent) { setLoading(true); setError(null); } + if (includeRateLimits) { + setRateLimitsLoading(true); + } try { const nextSnapshot = await api.refreshCodexAccountSnapshot({ - includeRateLimits: refreshOptions?.includeRateLimits ?? options.includeRateLimits, + includeRateLimits, forceRefreshToken: refreshOptions?.forceRefreshToken, }); applySnapshot(nextSnapshot); @@ -98,6 +104,9 @@ export function useCodexAccountSnapshot(options: { if (!silent) { setLoading(false); } + if (includeRateLimits) { + setRateLimitsLoading(false); + } } }, [applySnapshot, electronMode, options.enabled, options.includeRateLimits] @@ -109,6 +118,9 @@ export function useCodexAccountSnapshot(options: { } setLoading(true); + if (options.includeRateLimits) { + setRateLimitsLoading(true); + } setError(null); const initialSnapshotRequest = options.includeRateLimits @@ -126,6 +138,9 @@ export function useCodexAccountSnapshot(options: { }) .finally(() => { setLoading(false); + if (options.includeRateLimits) { + setRateLimitsLoading(false); + } }); const unsubscribe = api.onCodexAccountSnapshotChanged((_event, nextSnapshot) => { @@ -224,12 +239,13 @@ export function useCodexAccountSnapshot(options: { () => ({ snapshot, loading, + rateLimitsLoading, error, refresh, startChatgptLogin: (mode) => runAction(() => api.startCodexChatgptLogin({ mode })), cancelChatgptLogin: () => runAction(() => api.cancelCodexChatgptLogin()), logout: () => runAction(() => api.logoutCodexAccount()), }), - [error, loading, refresh, runAction, snapshot] + [error, loading, rateLimitsLoading, refresh, runAction, snapshot] ); } diff --git a/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx b/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx index 67f742ac..f6eb8a3f 100644 --- a/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx +++ b/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from 'react'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; +import { ActivePulseIndicator } from '@renderer/components/ui/ActivePulseIndicator'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { projectColor } from '@renderer/utils/projectColor'; import { FolderGit2, FolderOpen, GitBranch, Terminal } from 'lucide-react'; @@ -33,10 +34,7 @@ export const RecentProjectCard = ({ }} > {card.activeTeams && card.activeTeams.length > 0 && ( - - - - + )}
diff --git a/src/features/running-teams/core/domain/__tests__/buildRunningTeamsDashboard.test.ts b/src/features/running-teams/core/domain/__tests__/buildRunningTeamsDashboard.test.ts new file mode 100644 index 00000000..47ebeb86 --- /dev/null +++ b/src/features/running-teams/core/domain/__tests__/buildRunningTeamsDashboard.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildRunningTeamsDashboard, + type RunningTeamCandidate, +} from '../policies/buildRunningTeamsDashboard'; + +function candidate(overrides: Partial): RunningTeamCandidate { + return { + teamName: 'team-a', + displayName: 'Team A', + projectPath: '/workspace/a', + lastActivity: null, + status: 'offline', + taskCounts: { pending: 0, inProgress: 0, completed: 0 }, + ...overrides, + }; +} + +describe('buildRunningTeamsDashboard', () => { + it('keeps only active, running, and provisioning teams', () => { + const result = buildRunningTeamsDashboard({ + teams: [ + candidate({ teamName: 'active', displayName: 'Active', status: 'active' }), + candidate({ teamName: 'idle', displayName: 'Idle', status: 'idle' }), + candidate({ teamName: 'launching', displayName: 'Launching', status: 'provisioning' }), + candidate({ teamName: 'offline', displayName: 'Offline', status: 'offline' }), + candidate({ teamName: 'failed', displayName: 'Failed', status: 'partial_failure' }), + candidate({ teamName: 'pending', displayName: 'Pending', status: 'partial_pending' }), + candidate({ teamName: 'skipped', displayName: 'Skipped', status: 'partial_skipped' }), + ], + }); + + expect(result.map((team) => team.teamName)).toEqual(['active', 'launching', 'idle']); + }); + + it('merges synthetic provisioning teams and sorts by status, work, activity, then name', () => { + const result = buildRunningTeamsDashboard({ + teams: [ + candidate({ + teamName: 'active-low', + displayName: 'Active Low', + status: 'active', + lastActivity: '2026-05-01T00:00:00.000Z', + taskCounts: { pending: 0, inProgress: 1, completed: 0 }, + }), + candidate({ + teamName: 'active-high', + displayName: 'Active High', + status: 'active', + lastActivity: '2026-04-01T00:00:00.000Z', + taskCounts: { pending: 0, inProgress: 3, completed: 0 }, + }), + candidate({ + teamName: 'idle-new', + displayName: 'Idle New', + status: 'idle', + lastActivity: '2026-05-03T00:00:00.000Z', + }), + ], + provisioningTeams: [ + candidate({ + teamName: 'launching', + displayName: 'Launching', + status: 'provisioning', + lastActivity: '2026-05-04T00:00:00.000Z', + taskCounts: { pending: 0, inProgress: 9, completed: 0 }, + }), + candidate({ + teamName: 'active-low', + displayName: 'Duplicate Active Low', + status: 'provisioning', + }), + ], + }); + + expect(result.map((team) => team.teamName)).toEqual([ + 'active-high', + 'active-low', + 'launching', + 'idle-new', + ]); + }); +}); diff --git a/src/features/running-teams/core/domain/policies/buildRunningTeamsDashboard.ts b/src/features/running-teams/core/domain/policies/buildRunningTeamsDashboard.ts new file mode 100644 index 00000000..8f40082a --- /dev/null +++ b/src/features/running-teams/core/domain/policies/buildRunningTeamsDashboard.ts @@ -0,0 +1,99 @@ +export type RunningTeamsCandidateStatus = + | 'active' + | 'idle' + | 'provisioning' + | 'offline' + | 'partial_failure' + | 'partial_skipped' + | 'partial_pending'; + +export type RunningTeamDashboardStatus = 'active' | 'idle' | 'provisioning'; + +export interface RunningTeamTaskCounts { + pending: number; + inProgress: number; + completed: number; +} + +export interface RunningTeamCandidate { + teamName: string; + displayName: string; + color?: string; + projectPath?: string; + lastActivity: string | null; + status: RunningTeamsCandidateStatus; + taskCounts?: RunningTeamTaskCounts; +} + +export interface BuildRunningTeamsDashboardInput { + teams: RunningTeamCandidate[]; + provisioningTeams?: RunningTeamCandidate[]; +} + +export interface RunningTeamDashboardEntry extends RunningTeamCandidate { + status: RunningTeamDashboardStatus; +} + +const RUNNING_STATUS_PRIORITY: Record = { + active: 0, + provisioning: 1, + idle: 2, +}; + +function isRunningDashboardStatus( + status: RunningTeamsCandidateStatus +): status is RunningTeamDashboardStatus { + return status === 'active' || status === 'idle' || status === 'provisioning'; +} + +function getInProgressTaskCount(team: RunningTeamCandidate): number { + return team.taskCounts?.inProgress ?? 0; +} + +function getLastActivityMs(team: RunningTeamCandidate): number { + if (!team.lastActivity) { + return 0; + } + + const parsed = Date.parse(team.lastActivity); + return Number.isFinite(parsed) ? parsed : 0; +} + +function mergeTeams( + teams: RunningTeamCandidate[], + provisioningTeams: RunningTeamCandidate[] +): RunningTeamCandidate[] { + if (provisioningTeams.length === 0) { + return teams; + } + + const existing = new Set(teams.map((team) => team.teamName)); + return [...teams, ...provisioningTeams.filter((team) => !existing.has(team.teamName))]; +} + +export function buildRunningTeamsDashboard({ + teams, + provisioningTeams = [], +}: BuildRunningTeamsDashboardInput): RunningTeamDashboardEntry[] { + return mergeTeams(teams, provisioningTeams) + .filter((team): team is RunningTeamDashboardEntry => isRunningDashboardStatus(team.status)) + .sort((left, right) => { + const statusDelta = + RUNNING_STATUS_PRIORITY[left.status] - RUNNING_STATUS_PRIORITY[right.status]; + if (statusDelta !== 0) { + return statusDelta; + } + + const inProgressDelta = getInProgressTaskCount(right) - getInProgressTaskCount(left); + if (inProgressDelta !== 0) { + return inProgressDelta; + } + + const activityDelta = getLastActivityMs(right) - getLastActivityMs(left); + if (activityDelta !== 0) { + return activityDelta; + } + + return left.displayName.localeCompare(right.displayName); + }); +} diff --git a/src/features/running-teams/renderer/adapters/RunningTeamsSectionAdapter.ts b/src/features/running-teams/renderer/adapters/RunningTeamsSectionAdapter.ts new file mode 100644 index 00000000..327d0dc1 --- /dev/null +++ b/src/features/running-teams/renderer/adapters/RunningTeamsSectionAdapter.ts @@ -0,0 +1,55 @@ +import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { getBaseName } from '@renderer/utils/pathUtils'; +import { nameColorSet } from '@renderer/utils/projectColor'; + +import type { RunningTeamDashboardEntry } from '../../core/domain/policies/buildRunningTeamsDashboard'; +import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; + +export interface RunningTeamRowModel { + id: string; + teamName: string; + displayName: string; + projectPath?: string; + projectLabel: string; + status: RunningTeamDashboardEntry['status']; + statusLabel: string; + accentColor: string; + taskCounts?: TaskStatusCounts; +} + +function getStatusLabel(status: RunningTeamDashboardEntry['status']): string { + switch (status) { + case 'active': + return 'Active'; + case 'provisioning': + return 'Launching'; + case 'idle': + return 'Running'; + } +} + +function getProjectLabel(projectPath?: string): string { + if (!projectPath) { + return 'No project'; + } + + return getBaseName(projectPath) || projectPath; +} + +export function adaptRunningTeamsSection( + teams: RunningTeamDashboardEntry[] +): RunningTeamRowModel[] { + return teams.map((team) => ({ + id: team.teamName, + teamName: team.teamName, + displayName: team.displayName, + projectPath: team.projectPath, + projectLabel: getProjectLabel(team.projectPath), + status: team.status, + statusLabel: getStatusLabel(team.status), + accentColor: team.color + ? getTeamColorSet(team.color).border + : nameColorSet(team.displayName).border, + taskCounts: team.taskCounts, + })); +} diff --git a/src/features/running-teams/renderer/hooks/useRunningTeamsSection.ts b/src/features/running-teams/renderer/hooks/useRunningTeamsSection.ts new file mode 100644 index 00000000..7f64e91f --- /dev/null +++ b/src/features/running-teams/renderer/hooks/useRunningTeamsSection.ts @@ -0,0 +1,199 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { api } from '@renderer/api'; +import { useStore } from '@renderer/store'; +import { + getCurrentProvisioningProgressForTeam, + isTeamProvisioningActive, +} from '@renderer/store/slices/teamSlice'; +import { buildTaskCountsByTeam } from '@renderer/utils/pathNormalize'; +import { resolveTeamStatus } from '@renderer/utils/teamListStatus'; +import { useShallow } from 'zustand/react/shallow'; + +import { buildRunningTeamsDashboard } from '../../core/domain/policies/buildRunningTeamsDashboard'; +import { adaptRunningTeamsSection } from '../adapters/RunningTeamsSectionAdapter'; + +import type { + RunningTeamCandidate, + RunningTeamsCandidateStatus, +} from '../../core/domain/policies/buildRunningTeamsDashboard'; +import type { RunningTeamRowModel } from '../adapters/RunningTeamsSectionAdapter'; +import type { LeadActivityState, TeamProvisioningProgress, TeamSummary } from '@shared/types'; + +interface RunningTeamsSectionState { + rows: RunningTeamRowModel[]; + hidden: boolean; + openRunningTeam: (row: RunningTeamRowModel) => void; +} + +function toCandidate(input: { + team: TeamSummary; + aliveTeams: string[]; + provisioningState: { + currentProvisioningRunIdByTeam: Record; + provisioningRuns: Record; + }; + leadActivityByTeam: Record; + taskCountsByTeam: ReturnType; + nowMs: number; +}): RunningTeamCandidate { + const status = resolveTeamStatus( + input.team, + input.team.teamName, + input.aliveTeams, + getCurrentProvisioningProgressForTeam(input.provisioningState, input.team.teamName), + input.leadActivityByTeam, + input.nowMs + ) as RunningTeamsCandidateStatus; + + return { + teamName: input.team.teamName, + displayName: input.team.displayName, + color: input.team.color, + projectPath: input.team.projectPath, + lastActivity: input.team.lastActivity, + status, + taskCounts: input.taskCountsByTeam.get(input.team.teamName), + }; +} + +export function useRunningTeamsSection(searchQuery: string): RunningTeamsSectionState { + const { + teams, + globalTasks, + globalTasksInitialized, + globalTasksLoading, + fetchAllTasks, + openTeamTab, + provisioningRuns, + currentProvisioningRunIdByTeam, + provisioningSnapshotByTeam, + leadActivityByTeam, + } = useStore( + useShallow((state) => ({ + teams: state.teams, + globalTasks: state.globalTasks, + globalTasksInitialized: state.globalTasksInitialized, + globalTasksLoading: state.globalTasksLoading, + fetchAllTasks: state.fetchAllTasks, + openTeamTab: state.openTeamTab, + provisioningRuns: state.provisioningRuns, + currentProvisioningRunIdByTeam: state.currentProvisioningRunIdByTeam, + provisioningSnapshotByTeam: state.provisioningSnapshotByTeam, + leadActivityByTeam: state.leadActivityByTeam, + })) + ); + const [aliveTeams, setAliveTeams] = useState([]); + const searchActive = searchQuery.trim().length > 0; + 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((left, right) => left.localeCompare(right)).join('\u0000'), + [provisioningTeamNames] + ); + + useEffect(() => { + if (searchActive) { + return; + } + + let cancelled = false; + void api.teams + .aliveList() + .then((teamNames) => { + if (!cancelled) { + setAliveTeams(teamNames); + } + }) + .catch(() => { + if (!cancelled) { + setAliveTeams([]); + } + }); + + return () => { + cancelled = true; + }; + }, [provisioningTeamNamesKey, searchActive, teams]); + + useEffect(() => { + if ( + searchActive || + globalTasksInitialized || + globalTasksLoading || + (teams.length === 0 && provisioningTeamNames.length === 0) + ) { + return; + } + + void fetchAllTasks(); + }, [ + fetchAllTasks, + globalTasksInitialized, + globalTasksLoading, + provisioningTeamNames.length, + searchActive, + teams.length, + ]); + + const rows = useMemo(() => { + if (searchActive) { + return []; + } + + const taskCountsByTeam = buildTaskCountsByTeam(globalTasks); + 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 nowMs = Date.now(); + const candidateInput = { + aliveTeams, + provisioningState, + leadActivityByTeam, + taskCountsByTeam, + nowMs, + }; + const runningTeams = buildRunningTeamsDashboard({ + teams: teams.map((team) => toCandidate({ ...candidateInput, team })), + provisioningTeams: syntheticProvisioningTeams.map((team) => + toCandidate({ ...candidateInput, team }) + ), + }); + + return adaptRunningTeamsSection(runningTeams); + }, [ + aliveTeams, + globalTasks, + leadActivityByTeam, + provisioningSnapshotByTeam, + provisioningState, + provisioningTeamNames, + searchActive, + teams, + ]); + + const openRunningTeam = useCallback( + (row: RunningTeamRowModel): void => { + openTeamTab(row.teamName, row.projectPath); + }, + [openTeamTab] + ); + + return { + rows, + hidden: searchActive || rows.length === 0, + openRunningTeam, + }; +} diff --git a/src/features/running-teams/renderer/index.ts b/src/features/running-teams/renderer/index.ts new file mode 100644 index 00000000..accc157f --- /dev/null +++ b/src/features/running-teams/renderer/index.ts @@ -0,0 +1 @@ +export { RunningTeamsSection } from './ui/RunningTeamsSection'; diff --git a/src/features/running-teams/renderer/ui/RunningTeamsSection.tsx b/src/features/running-teams/renderer/ui/RunningTeamsSection.tsx new file mode 100644 index 00000000..fa5ded3e --- /dev/null +++ b/src/features/running-teams/renderer/ui/RunningTeamsSection.tsx @@ -0,0 +1,69 @@ +import { TeamTaskStatusSummary } from '@renderer/components/team/TeamTaskStatusSummary'; +import { ActivePulseIndicator } from '@renderer/components/ui/ActivePulseIndicator'; +import { FolderOpen } from 'lucide-react'; + +import { useRunningTeamsSection } from '../hooks/useRunningTeamsSection'; + +import type { RunningTeamRowModel } from '../adapters/RunningTeamsSectionAdapter'; +import type React from 'react'; + +interface RunningTeamsSectionProps { + searchQuery: string; +} + +function getRowTitle(row: RunningTeamRowModel): string { + return row.projectPath ? `${row.displayName} - ${row.projectPath}` : row.displayName; +} + +export function RunningTeamsSection({ + searchQuery, +}: Readonly): React.JSX.Element | null { + const { rows, hidden, openRunningTeam } = useRunningTeamsSection(searchQuery); + + if (hidden) { + return null; + } + + return ( +
+
+

+ Running Teams + + {rows.length} + +

+
+
+ {rows.map((row) => ( + + ))} +
+
+ ); +} diff --git a/src/main/ipc/review.ts b/src/main/ipc/review.ts index 1f789fa1..108ecda9 100644 --- a/src/main/ipc/review.ts +++ b/src/main/ipc/review.ts @@ -19,6 +19,7 @@ import { REVIEW_GET_FILE_CONTENT, REVIEW_GET_GIT_FILE_LOG, REVIEW_GET_TASK_CHANGES, + REVIEW_GET_TEAM_TASK_CHANGE_SUMMARIES, REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES, REVIEW_LOAD_DECISIONS, REVIEW_PREVIEW_REJECT, @@ -49,7 +50,10 @@ import type { HunkDecision, RejectResult, SnippetDiff, + TaskChangeRequestOptions, TaskChangeSetV2, + TeamTaskChangeSummariesResponse, + TeamTaskChangeSummaryRequest, } from '@shared/types/review'; import type { BrowserWindow, IpcMain, IpcMainInvokeEvent } from 'electron'; @@ -102,6 +106,7 @@ export function registerReviewHandlers(ipcMain: IpcMain): void { // Phase 1 ipcMain.handle(REVIEW_GET_AGENT_CHANGES, handleGetAgentChanges); ipcMain.handle(REVIEW_GET_TASK_CHANGES, handleGetTaskChanges); + ipcMain.handle(REVIEW_GET_TEAM_TASK_CHANGE_SUMMARIES, handleGetTeamTaskChangeSummaries); ipcMain.handle(REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES, handleInvalidateTaskChangeSummaries); ipcMain.handle(REVIEW_GET_CHANGE_STATS, handleGetChangeStats); // Phase 2 @@ -127,6 +132,7 @@ export function removeReviewHandlers(ipcMain: IpcMain): void { // Phase 1 ipcMain.removeHandler(REVIEW_GET_AGENT_CHANGES); ipcMain.removeHandler(REVIEW_GET_TASK_CHANGES); + ipcMain.removeHandler(REVIEW_GET_TEAM_TASK_CHANGE_SUMMARIES); ipcMain.removeHandler(REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES); ipcMain.removeHandler(REVIEW_GET_CHANGE_STATS); // Phase 2 @@ -166,58 +172,79 @@ async function handleGetAgentChanges( ); } +function sanitizeTaskChangeOptions(options?: unknown): TaskChangeRequestOptions | undefined { + if (!options || typeof options !== 'object') { + return undefined; + } + + const raw = options as Record; + return { + owner: typeof raw.owner === 'string' ? raw.owner : undefined, + status: typeof raw.status === 'string' ? raw.status : undefined, + since: typeof raw.since === 'string' ? raw.since : undefined, + intervals: Array.isArray(raw.intervals) + ? (raw.intervals.filter( + (i): i is { startedAt: string; completedAt?: string } => + Boolean(i) && + typeof i === 'object' && + typeof (i as Record).startedAt === 'string' && + ((i as Record).completedAt === undefined || + typeof (i as Record).completedAt === 'string') + ) as { startedAt: string; completedAt?: string }[]) + : undefined, + stateBucket: + raw.stateBucket === 'approved' || + raw.stateBucket === 'review' || + raw.stateBucket === 'completed' || + raw.stateBucket === 'active' + ? raw.stateBucket + : undefined, + summaryOnly: raw.summaryOnly === true, + forceFresh: raw.forceFresh === true, + }; +} + async function handleGetTaskChanges( _event: IpcMainInvokeEvent, teamName: string, taskId: string, options?: unknown ): Promise> { - const opts = - options && typeof options === 'object' - ? { - owner: - typeof (options as Record).owner === 'string' - ? ((options as Record).owner as string) - : undefined, - status: - typeof (options as Record).status === 'string' - ? ((options as Record).status as string) - : undefined, - since: - typeof (options as Record).since === 'string' - ? ((options as Record).since as string) - : undefined, - intervals: Array.isArray((options as Record).intervals) - ? (((options as Record).intervals as unknown[]).filter( - (i): i is { startedAt: string; completedAt?: string } => - Boolean(i) && - typeof i === 'object' && - typeof (i as Record).startedAt === 'string' && - ((i as Record).completedAt === undefined || - typeof (i as Record).completedAt === 'string') - ) as { startedAt: string; completedAt?: string }[]) - : undefined, - stateBucket: - (options as Record).stateBucket === 'approved' || - (options as Record).stateBucket === 'review' || - (options as Record).stateBucket === 'completed' || - (options as Record).stateBucket === 'active' - ? ((options as Record).stateBucket as - | 'approved' - | 'review' - | 'completed' - | 'active') - : undefined, - summaryOnly: (options as Record).summaryOnly === true, - forceFresh: (options as Record).forceFresh === true, - } - : undefined; + const opts = sanitizeTaskChangeOptions(options); return wrapReviewHandler('getTaskChanges', () => getChangeExtractor().getTaskChanges(teamName, taskId, opts) ); } +async function handleGetTeamTaskChangeSummaries( + _event: IpcMainInvokeEvent, + teamName: string, + requests: unknown +): Promise> { + const sanitizedRequests: TeamTaskChangeSummaryRequest[] = Array.isArray(requests) + ? requests + .map((request): TeamTaskChangeSummaryRequest | null => { + if (!request || typeof request !== 'object') { + return null; + } + const raw = request as Record; + if (typeof raw.taskId !== 'string' || raw.taskId.trim().length === 0) { + return null; + } + return { + taskId: raw.taskId.trim(), + options: sanitizeTaskChangeOptions(raw.options), + }; + }) + .filter((request): request is TeamTaskChangeSummaryRequest => request !== null) + : []; + + return wrapReviewHandler('getTeamTaskChangeSummaries', () => + getChangeExtractor().getTeamTaskChangeSummaries(teamName, sanitizedRequests) + ); +} + async function handleInvalidateTaskChangeSummaries( _event: IpcMainInvokeEvent, teamName: string, diff --git a/src/main/services/infrastructure/UpdaterService.ts b/src/main/services/infrastructure/UpdaterService.ts index 2485a195..62545aa1 100644 --- a/src/main/services/infrastructure/UpdaterService.ts +++ b/src/main/services/infrastructure/UpdaterService.ts @@ -31,6 +31,13 @@ import type { BrowserWindow } from 'electron'; const logger = createLogger('UpdaterService'); +function shouldSkipDevUpdateCheck(): boolean { + return ( + !app.isPackaged && + (autoUpdater as { forceDevUpdateConfig?: boolean }).forceDevUpdateConfig !== true + ); +} + /** * Check if a remote URL exists using a HEAD request. * Follows redirects (GitHub releases use 302 → S3). @@ -93,6 +100,10 @@ export class UpdaterService { * Check for available updates. */ async checkForUpdates(): Promise { + if (shouldSkipDevUpdateCheck()) { + return; + } + try { await autoUpdater.checkForUpdates(); } catch (error) { diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.test.ts new file mode 100644 index 00000000..b64e97c7 --- /dev/null +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from 'vitest'; + +import { ClaudeMultimodelBridgeService } from './ClaudeMultimodelBridgeService'; + +import type { CliProviderId, CliProviderStatus } from '@shared/types'; + +type RuntimeStatusMapper = { + mapRuntimeProviderStatus: ( + providerId: CliProviderId, + runtimeStatus: unknown + ) => CliProviderStatus; +}; + +function mapRuntimeProviderStatus( + providerId: CliProviderId, + runtimeStatus: unknown +): CliProviderStatus { + const service = new ClaudeMultimodelBridgeService() as unknown as RuntimeStatusMapper; + return service.mapRuntimeProviderStatus(providerId, runtimeStatus); +} + +describe('ClaudeMultimodelBridgeService runtime status mapping', () => { + test('maps Anthropic subscription rate limits from orchestrator runtime status', () => { + const provider = mapRuntimeProviderStatus('anthropic', { + supported: true, + authenticated: true, + authMethod: 'claude.ai', + verificationState: 'verified', + canLoginFromUi: true, + models: ['haiku'], + capabilities: { + teamLaunch: true, + oneShot: true, + }, + subscriptionRateLimits: { + primary: { + usedPercent: 42.5, + windowDurationMins: 300, + resetsAt: 1_777_777_000, + }, + secondary: { + usedPercent: 150, + windowDurationMins: Number.NaN, + resetsAt: Number.NaN, + }, + }, + }); + + expect(provider.subscriptionRateLimits).toEqual({ + primary: { + usedPercent: 42.5, + windowDurationMins: 300, + resetsAt: 1_777_777_000, + }, + secondary: { + usedPercent: 100, + windowDurationMins: null, + resetsAt: null, + }, + }); + }); + + test('drops malformed Anthropic subscription rate limit windows', () => { + const provider = mapRuntimeProviderStatus('anthropic', { + supported: true, + authenticated: true, + authMethod: 'claude.ai', + verificationState: 'verified', + subscriptionRateLimits: { + primary: { + usedPercent: Number.NaN, + windowDurationMins: 300, + resetsAt: 1_777_777_000, + }, + secondary: { + usedPercent: 60, + windowDurationMins: 10_080, + resetsAt: 1_777_999_000, + }, + }, + }); + + expect(provider.subscriptionRateLimits).toEqual({ + primary: null, + secondary: { + usedPercent: 60, + windowDurationMins: 10_080, + resetsAt: 1_777_999_000, + }, + }); + }); + + test('ignores subscription rate limits for non-Anthropic providers', () => { + const provider = mapRuntimeProviderStatus('codex', { + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + subscriptionRateLimits: { + primary: { + usedPercent: 25, + windowDurationMins: 300, + resetsAt: 1_777_777_000, + }, + }, + }); + + expect(provider.subscriptionRateLimits).toBeNull(); + }); + + test('ignores Anthropic subscription rate limits for API key auth', () => { + const provider = mapRuntimeProviderStatus('anthropic', { + supported: true, + authenticated: true, + authMethod: 'api_key', + verificationState: 'verified', + subscriptionRateLimits: { + primary: { + usedPercent: 25, + windowDurationMins: 300, + resetsAt: 1_777_777_000, + }, + }, + }); + + expect(provider.subscriptionRateLimits).toBeNull(); + }); +}); diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index ceef49d8..a94dd019 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -13,7 +13,12 @@ import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth'; import { buildProviderAwareCliEnv } from './providerAwareCliEnv'; import { providerConnectionService } from './ProviderConnectionService'; -import type { CliProviderId, CliProviderReasoningEffort, CliProviderStatus } from '@shared/types'; +import type { + CliProviderId, + CliProviderReasoningEffort, + CliProviderStatus, + CliProviderSubscriptionRateLimitSnapshot, +} from '@shared/types'; const logger = createLogger('ClaudeMultimodelBridgeService'); @@ -51,6 +56,17 @@ interface RuntimeProviderCapabilitiesResponse { }; } +interface RuntimeSubscriptionRateLimitWindowResponse { + usedPercent?: number; + windowDurationMins?: number | null; + resetsAt?: number | null; +} + +interface RuntimeSubscriptionRateLimitSnapshotResponse { + primary?: RuntimeSubscriptionRateLimitWindowResponse | null; + secondary?: RuntimeSubscriptionRateLimitWindowResponse | null; +} + interface RuntimeProviderModelCatalogItemResponse { id?: string; launchModel?: string; @@ -111,6 +127,7 @@ interface ProviderStatusCommandResponse { authMethodDetail?: string | null; } | null; runtimeCapabilities?: RuntimeProviderCapabilitiesResponse; + subscriptionRateLimits?: RuntimeSubscriptionRateLimitSnapshotResponse | null; } >; } @@ -179,6 +196,7 @@ interface UnifiedRuntimeStatusResponse { authMethodDetail?: string | null; } | null; runtimeCapabilities?: RuntimeProviderCapabilitiesResponse; + subscriptionRateLimits?: RuntimeSubscriptionRateLimitSnapshotResponse | null; } >; } @@ -350,6 +368,7 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat connection: null, modelCatalog: null, runtimeCapabilities: null, + subscriptionRateLimits: null, }; } @@ -544,6 +563,44 @@ function mapRuntimeProviderModelCatalog( }; } +function mapRuntimeSubscriptionRateLimitWindow( + window: RuntimeSubscriptionRateLimitWindowResponse | null | undefined +): NonNullable | null { + if (!window || typeof window.usedPercent !== 'number' || !Number.isFinite(window.usedPercent)) { + return null; + } + + return { + usedPercent: Math.max(0, Math.min(100, window.usedPercent)), + windowDurationMins: + typeof window.windowDurationMins === 'number' && Number.isFinite(window.windowDurationMins) + ? window.windowDurationMins + : null, + resetsAt: + typeof window.resetsAt === 'number' && Number.isFinite(window.resetsAt) + ? window.resetsAt + : null, + }; +} + +function mapRuntimeSubscriptionRateLimits( + providerId: CliProviderId, + authMethod: string | null | undefined, + rateLimits: RuntimeSubscriptionRateLimitSnapshotResponse | null | undefined +): CliProviderSubscriptionRateLimitSnapshot | null { + if ( + providerId !== 'anthropic' || + (authMethod !== 'claude.ai' && authMethod !== 'oauth_token') || + !rateLimits + ) { + return null; + } + + const primary = mapRuntimeSubscriptionRateLimitWindow(rateLimits.primary); + const secondary = mapRuntimeSubscriptionRateLimitWindow(rateLimits.secondary); + return primary || secondary ? { primary, secondary } : null; +} + export class ClaudeMultimodelBridgeService { private async buildCliEnv( binaryPath: string @@ -621,6 +678,11 @@ export class ClaudeMultimodelBridgeService { })) ?? [], models: extractModelIds(runtimeStatus.models), modelCatalog: mapRuntimeProviderModelCatalog(providerId, runtimeStatus.modelCatalog), + subscriptionRateLimits: mapRuntimeSubscriptionRateLimits( + providerId, + runtimeStatus.authMethod, + runtimeStatus.subscriptionRateLimits + ), backend: runtimeStatus.backend?.kind ? { kind: runtimeStatus.backend.kind, diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index fb842d5a..cc20165d 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -631,6 +631,7 @@ export class ProviderConnectionService { ...provider, authenticated: true, authMethod: 'api_key', + subscriptionRateLimits: null, verificationState: provider.verificationState === 'error' ? provider.verificationState : 'verified', statusMessage: 'Connected via API key', @@ -641,6 +642,7 @@ export class ProviderConnectionService { ...provider, authenticated: false, authMethod: null, + subscriptionRateLimits: null, verificationState: provider.verificationState === 'error' ? 'error' : 'unknown', statusMessage: 'API key mode is selected, but no Anthropic API credential is available yet.', }; diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 143c76b7..91ae02b2 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -43,12 +43,21 @@ import type { TaskBoundaryParser } from './TaskBoundaryParser'; import type { TaskChangeWorkerClient } from './TaskChangeWorkerClient'; import type { TeamLogSourceTracker } from './TeamLogSourceTracker'; import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; -import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types'; +import type { + AgentChangeSet, + ChangeStats, + TaskChangeSetV2, + TeamTaskChangeSummariesResponse, + TeamTaskChangeSummaryItem, + TeamTaskChangeSummaryRequest, +} from '@shared/types'; const logger = createLogger('Service:ChangeExtractorService'); const OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE = 'strict-delivery' as const; const OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE = 'opencode-session-snapshot-v1' as const; const OPEN_CODE_MAX_DISCOVERED_LANES = 500; +const TEAM_TASK_CHANGE_SUMMARY_BATCH_LIMIT = 200; +const TEAM_TASK_CHANGE_SUMMARY_BATCH_CONCURRENCY = 3; /** Кеш-запись: данные + mtime файла + время протухания */ interface CacheEntry { @@ -322,6 +331,57 @@ export class ChangeExtractorService { return promise; } + async getTeamTaskChangeSummaries( + teamName: string, + requests: TeamTaskChangeSummaryRequest[] + ): Promise { + const cappedRequests = requests + .filter((request) => typeof request.taskId === 'string' && request.taskId.trim().length > 0) + .slice(0, TEAM_TASK_CHANGE_SUMMARY_BATCH_LIMIT); + const items: TeamTaskChangeSummaryItem[] = cappedRequests.map((request) => ({ + taskId: request.taskId.trim(), + changeSet: null, + })); + let cursor = 0; + + const runNext = async (): Promise => { + while (cursor < cappedRequests.length) { + const index = cursor; + cursor += 1; + const request = cappedRequests[index]; + const taskId = request.taskId.trim(); + try { + const changeSet = await this.getTaskChanges(teamName, taskId, { + ...request.options, + summaryOnly: true, + }); + items[index] = { taskId, changeSet }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + items[index] = { + taskId, + changeSet: null, + error: message, + }; + } + } + }; + + await Promise.all( + Array.from( + { length: Math.min(TEAM_TASK_CHANGE_SUMMARY_BATCH_CONCURRENCY, cappedRequests.length) }, + () => runNext() + ) + ); + + return { + teamName, + items, + computedAt: new Date().toISOString(), + truncated: requests.length > cappedRequests.length || undefined, + }; + } + async invalidateTaskChangeSummaries( teamName: string, taskIds: string[], diff --git a/src/main/services/team/MemberStatsComputer.ts b/src/main/services/team/MemberStatsComputer.ts index 96ce9994..5a868e3a 100644 --- a/src/main/services/team/MemberStatsComputer.ts +++ b/src/main/services/team/MemberStatsComputer.ts @@ -10,7 +10,8 @@ import type { FileLineStats, MemberFullStats } from '@shared/types'; const logger = createLogger('Service:MemberStatsComputer'); const TRAILING_PUNCT_CHARS = new Set([';', '.', ',']); -const INVALID_NAMES = new Set(['null', 'undefined', 'None', 'false', 'true', '']); +const INVALID_NAMES = new Set(['null', 'undefined', 'none', 'false', 'true', '']); +const WINDOWS_NULL_DEVICE_RE = /^[a-z]:\/nul$/; function stripTrailingPunct(s: string): string { let end = s.length; @@ -18,9 +19,26 @@ function stripTrailingPunct(s: string): string { return end === s.length ? s : s.slice(0, end); } +function isNullDevicePath(value: string): boolean { + const normalized = value.replace(/\\/g, '/').toLowerCase(); + return ( + normalized === '/dev/null' || + normalized === '//./nul' || + normalized === '//?/nul' || + WINDOWS_NULL_DEVICE_RE.test(normalized) + ); +} + export function isValidFilePath(value: string): boolean { const cleaned = stripTrailingPunct(value.trim()); - return cleaned.length > 1 && !INVALID_NAMES.has(cleaned) && cleaned.includes('/'); + const normalizedName = cleaned.toLowerCase(); + const hasPathSeparator = cleaned.includes('/') || cleaned.includes('\\'); + return ( + cleaned.length > 1 && + !INVALID_NAMES.has(normalizedName) && + hasPathSeparator && + !isNullDevicePath(cleaned) + ); } const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index c2c85f23..2f7aa5de 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1732,6 +1732,9 @@ interface ProvisioningRun { leadName: string; startedAt: string; textParts: string[]; + replyVisibility?: 'user' | 'internal_activity'; + hasVisibleSendMessage?: boolean; + hasUserVisibleSendMessage?: boolean; settled: boolean; idleHandle: NodeJS.Timeout | null; idleMs: number; @@ -4542,7 +4545,7 @@ ${AGENT_BLOCK_CLOSE} - instructions to run commands in terminal - task references without a leading # (for example write #abcd1234, not abcd1234) Instead, describe the action in human-friendly language (e.g. "Task #6 is complete." instead of showing a command to mark it complete). If you need to update task status, do it YOURSELF — never ask the user to run a command. -- CRITICAL: When processing relayed inbox messages, your text output is shown to the user. Do NOT wrap your entire response in an agent-only block. If you need agent-only instructions, put them in a separate block and include a brief human-readable summary outside of it (e.g. "Delegated task to carol." or "Acknowledged, no action needed.").`; +- CRITICAL: When processing relayed inbox messages, follow the relay prompt's reply visibility. Some relay turns record plain text only as internal lead activity. User-visible replies must be explicit when the relay prompt says the batch is internal. Do NOT wrap your entire response in an agent-only block. If you need agent-only instructions, put them in a separate block and include concise visible text only when the relay prompt allows or requests it.`; } function getSystemLocale(): string { @@ -10279,6 +10282,29 @@ export class TeamProvisioningService { } } + private clearLeadInboxFollowUpRelayTimer(teamName: string): void { + const key = `lead-inbox-follow-up:${teamName}`; + const timer = this.pendingTimeouts.get(key); + if (timer) { + clearTimeout(timer); + this.pendingTimeouts.delete(key); + } + } + + private scheduleLeadInboxFollowUpRelay(teamName: string): void { + const key = `lead-inbox-follow-up:${teamName}`; + if (this.pendingTimeouts.has(key)) return; + + const timer = setTimeout(() => { + this.pendingTimeouts.delete(key); + void this.relayLeadInboxMessages(teamName).catch((error: unknown) => + logger.warn(`[${teamName}] lead inbox follow-up relay failed: ${String(error)}`) + ); + }, 50); + timer.unref?.(); + this.pendingTimeouts.set(key, timer); + } + private resetTeamScopedTransientStateForNewRun(teamName: string): void { peekAutoResumeService()?.cancelPendingAutoResume(teamName); this.invalidateRuntimeSnapshotCaches(teamName); @@ -10290,6 +10316,7 @@ export class TeamProvisioningService { this.recentCrossTeamLeadDeliveryMessageIds.delete(teamName); this.recentSameTeamNativeFingerprints.delete(teamName); this.clearSameTeamRetryTimers(teamName); + this.clearLeadInboxFollowUpRelayTimer(teamName); for (const key of Array.from(this.memberInboxRelayInFlight.keys())) { if (key.startsWith(`${teamName}:`)) { @@ -10677,6 +10704,40 @@ export class TeamProvisioningService { }); } + private hasCapturedUserVisibleSendMessage( + content: Record[], + teamName: string + ): boolean { + return content.some((part) => { + if (!part || typeof part !== 'object') return false; + if (part.type !== 'tool_use' || typeof part.name !== 'string') return false; + + const input = part.input; + if (!input || typeof input !== 'object') return false; + const inp = input as Record; + + if (part.name === 'SendMessage') { + const target = (typeof inp.recipient === 'string' ? inp.recipient : '') + .trim() + .toLowerCase(); + const text = (typeof inp.content === 'string' ? inp.content : '').trim(); + return target === 'user' && text.length > 0; + } + + const isTeamMessageSendTool = isAgentTeamsToolUse({ + rawName: part.name, + canonicalName: 'message_send', + toolInput: inp, + currentTeamName: teamName, + }); + if (!isTeamMessageSendTool) return false; + + const target = typeof inp.to === 'string' ? inp.to.trim().toLowerCase() : ''; + const text = typeof inp.text === 'string' ? inp.text.trim() : ''; + return target === 'user' && text.length > 0; + }); + } + private async matchCrossTeamLeadInboxMessages( teamName: string, leadName: string, @@ -21344,6 +21405,11 @@ export class TeamProvisioningService { return typeof message.messageId === 'string' && message.messageId.trim().length > 0; } + private isUserOriginatedLeadRelayMessage(message: InboxMessage): boolean { + const from = typeof message.from === 'string' ? message.from.trim().toLowerCase() : ''; + return from === 'user' || message.source === 'user_sent'; + } + async relayLeadInboxMessages(teamName: string): Promise { const existing = this.leadInboxRelayInFlight.get(teamName); if (existing) { @@ -21612,7 +21678,17 @@ export class TeamProvisioningService { if (actionableUnread.length === 0) return 0; const MAX_RELAY = 10; - const batch = actionableUnread.slice(0, MAX_RELAY); + const userOriginatedUnread = actionableUnread.filter((message) => + this.isUserOriginatedLeadRelayMessage(message) + ); + const replyVisibility: 'user' | 'internal_activity' = + userOriginatedUnread.length > 0 ? 'user' : 'internal_activity'; + const batchSource = userOriginatedUnread.length > 0 ? userOriginatedUnread : actionableUnread; + const batch = batchSource.slice(0, MAX_RELAY); + const batchIds = new Set(batch.map((message) => message.messageId)); + const hasPendingFollowUpRelay = unread.some( + (message) => !batchIds.has(message.messageId) && !readOnlyIgnoredIds.has(message.messageId) + ); const teammateRoster = (config.members ?? []) .filter((member) => { const name = member.name?.trim(); @@ -21634,13 +21710,25 @@ export class TeamProvisioningService { if (!sourceTeam || !conversationId) return []; return [{ toTeam: sourceTeam, conversationId }]; }); + const replyVisibilityInstruction = + replyVisibility === 'user' + ? [ + `Plain text reply visibility for this batch: user-visible.`, + `These inbox rows originated from the human user, so a concise plain text reply is allowed and will be shown to the user.`, + `If a visible reply is needed for a teammate or another team, use the appropriate messaging tool; plain text is only for the human response.`, + ] + : [ + `Plain text reply visibility for this batch: internal lead activity only.`, + `Do NOT write a user-facing summary for teammate/system/cross-team relay traffic. If the human user must be notified, explicitly call SendMessage with recipient "user".`, + `If you take action and no visible message/tool result already records it, you may write one terse internal status line for the team activity log.`, + `If a visible reply is needed for a teammate, another team, or the human user, use the appropriate messaging tool instead of relying on plain text.`, + ]; const message = [ `You have new inbox messages addressed to you (team lead "${leadName}").`, `Process them in order (oldest first).`, `If action is required, delegate via task creation or SendMessage, and keep responses minimal.`, - `IMPORTANT: Your text response here is shown to the user.`, - `If you actually take action, include a brief human-readable summary (e.g. "Delegated to carol.").`, + ...replyVisibilityInstruction, `If there is no action to take, produce ZERO text output. Do NOT write "No action needed.", status echoes, or any other no-op summary.`, `For pure system notifications, comment notifications, or routine teammate availability updates that require no reply/comment/action, say nothing.`, `Do NOT respond with only an agent-only block.`, @@ -21712,6 +21800,9 @@ export class TeamProvisioningService { leadName, startedAt: nowIso(), textParts: [] as string[], + replyVisibility, + hasVisibleSendMessage: false, + hasUserVisibleSendMessage: false, settled: false, idleHandle: null as NodeJS.Timeout | null, idleMs: captureIdleMs, @@ -21768,6 +21859,8 @@ export class TeamProvisioningService { } let replyText: string | null = null; + let capturedVisibleSendMessage = false; + let capturedUserVisibleSendMessage = false; try { replyText = (await capturePromise).trim() || null; } catch { @@ -21776,6 +21869,8 @@ export class TeamProvisioningService { replyText = partial && partial.length > 0 ? partial : null; } finally { if (run.leadRelayCapture) { + capturedVisibleSendMessage = run.leadRelayCapture.hasVisibleSendMessage === true; + capturedUserVisibleSendMessage = run.leadRelayCapture.hasUserVisibleSendMessage === true; if (run.leadRelayCapture.idleHandle) { clearTimeout(run.leadRelayCapture.idleHandle); run.leadRelayCapture.idleHandle = null; @@ -21796,6 +21891,18 @@ export class TeamProvisioningService { if (cleanReply) { if (isTeamInternalControlMessageText(cleanReply)) { logger.debug(`[${teamName}] Suppressed internal lead relay echo`); + } else if ( + (replyVisibility === 'internal_activity' && capturedVisibleSendMessage) || + (replyVisibility === 'user' && capturedUserVisibleSendMessage) + ) { + logger.debug(`[${teamName}] Suppressed lead relay text duplicated by visible message`); + } else if (replyVisibility === 'internal_activity') { + this.pushLiveLeadTextMessage( + run, + cleanReply, + `lead-relay-${runId}-${Date.now()}`, + nowIso() + ); } else { const relayMsg: InboxMessage = { from: leadName, @@ -21817,6 +21924,9 @@ export class TeamProvisioningService { }); } } + if (hasPendingFollowUpRelay) { + this.scheduleLeadInboxFollowUpRelay(teamName); + } return batch.length; })(); @@ -27158,14 +27268,16 @@ export class TeamProvisioningService { continue; } - const recipient = isNativeSendMessage + const rawRecipient = isNativeSendMessage ? typeof inp.recipient === 'string' ? inp.recipient : '' : typeof inp.to === 'string' ? inp.to : ''; - if (!recipient.trim()) continue; + const trimmedRecipient = rawRecipient.trim(); + if (!trimmedRecipient) continue; + const recipient = trimmedRecipient.toLowerCase() === 'user' ? 'user' : trimmedRecipient; const msgContent = isNativeSendMessage ? typeof inp.content === 'string' @@ -28283,6 +28395,14 @@ export class TeamProvisioningService { content, run.teamName ); + if (run.leadRelayCapture) { + if (hasCapturedVisibleSendMessage) { + run.leadRelayCapture.hasVisibleSendMessage = true; + } + if (this.hasCapturedUserVisibleSendMessage(content, run.teamName)) { + run.leadRelayCapture.hasUserVisibleSendMessage = true; + } + } const textParts = content .filter((part) => part.type === 'text' && typeof part.text === 'string') @@ -30843,6 +30963,7 @@ export class TeamProvisioningService { this.recentCrossTeamLeadDeliveryMessageIds.delete(run.teamName); this.recentSameTeamNativeFingerprints.delete(run.teamName); this.clearSameTeamRetryTimers(run.teamName); + this.clearLeadInboxFollowUpRelayTimer(run.teamName); } for (const memberName of run.memberSpawnStatuses.keys()) { const key = this.getMemberLaunchGraceKey(run, memberName); diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 3ac18cad..209c0f06 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -529,6 +529,9 @@ export const REVIEW_GET_AGENT_CHANGES = 'review:getAgentChanges'; /** Получить изменения задачи */ export const REVIEW_GET_TASK_CHANGES = 'review:getTaskChanges'; +/** Получить summary изменений по нескольким задачам команды */ +export const REVIEW_GET_TEAM_TASK_CHANGE_SUMMARIES = 'review:getTeamTaskChangeSummaries'; + /** Инвалидировать persisted/in-memory summary cache для задач */ export const REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES = 'review:invalidateTaskChangeSummaries'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 4db3fa60..576c657f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -76,6 +76,7 @@ import { REVIEW_GET_FILE_CONTENT, REVIEW_GET_GIT_FILE_LOG, REVIEW_GET_TASK_CHANGES, + REVIEW_GET_TEAM_TASK_CHANGE_SUMMARIES, REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES, REVIEW_LOAD_DECISIONS, REVIEW_PREVIEW_REJECT, @@ -305,6 +306,7 @@ import type { SshLastConnection, TaskAttachmentMeta, TaskChangePresenceState, + TaskChangeRequestOptions, TaskChangeSetV2, TaskComment, TeamAgentRuntimeSnapshot, @@ -325,6 +327,8 @@ import type { TeamProvisioningProgress, TeamSummary, TeamTask, + TeamTaskChangeSummariesResponse, + TeamTaskChangeSummaryRequest, TeamTaskStatus, TeamUpdateConfigRequest, TeamViewSnapshot, @@ -1359,15 +1363,7 @@ const electronAPI: ElectronAPI = { getTaskChanges: async ( teamName: string, taskId: string, - options?: { - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - since?: string; - stateBucket?: 'approved' | 'review' | 'completed' | 'active'; - summaryOnly?: boolean; - forceFresh?: boolean; - } + options?: TaskChangeRequestOptions ) => { return invokeIpcWithResult( REVIEW_GET_TASK_CHANGES, @@ -1376,6 +1372,16 @@ const electronAPI: ElectronAPI = { options ); }, + getTeamTaskChangeSummaries: async ( + teamName: string, + requests: TeamTaskChangeSummaryRequest[] + ) => { + return invokeIpcWithResult( + REVIEW_GET_TEAM_TASK_CHANGE_SUMMARIES, + teamName, + requests + ); + }, invalidateTaskChangeSummaries: async (teamName: string, taskIds: string[]) => { return invokeIpcWithResult(REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES, teamName, taskIds); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 30e1ebdf..f319864d 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -67,6 +67,7 @@ import type { SshConnectionStatus, SshLastConnection, SubagentDetail, + TaskChangeRequestOptions, TeamChangeEvent, TeamClaudeLogsQuery, TeamClaudeLogsResponse, @@ -82,6 +83,8 @@ import type { TeamsAPI, TeamSummary, TeamTask, + TeamTaskChangeSummariesResponse, + TeamTaskChangeSummaryRequest, TeamTaskStatus, TeamViewSnapshot, TeamWorktreeGitStatus, @@ -1138,18 +1141,16 @@ export class HttpAPIClient implements ElectronAPI { getTaskChanges: async ( _teamName: string, _taskId: string, - _options?: { - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - since?: string; - stateBucket?: 'approved' | 'review' | 'completed' | 'active'; - summaryOnly?: boolean; - forceFresh?: boolean; - } + _options?: TaskChangeRequestOptions ): Promise => { throw new Error('Review is not available in browser mode'); }, + getTeamTaskChangeSummaries: async ( + _teamName: string, + _requests: TeamTaskChangeSummaryRequest[] + ): Promise => { + throw new Error('Review is not available in browser mode'); + }, invalidateTaskChangeSummaries: async (): Promise => { throw new Error('Review is not available in browser mode'); }, diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index a522c708..f42a0535 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -10,10 +10,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { - formatCodexRemainingPercent, - formatCodexWindowDuration, mergeCodexProviderStatusWithSnapshot, - normalizeCodexResetTimestamp, useCodexAccountSnapshot, } from '@features/codex-account/renderer'; import { api, isElectronMode } from '@renderer/api'; @@ -68,7 +65,16 @@ import { Terminal, } from 'lucide-react'; -import type { CliProviderId, CliProviderStatus } from '@shared/types'; +import type { CliProviderAuthMode, CliProviderId, CliProviderStatus } from '@shared/types'; + +import { + getAnthropicDashboardRateLimits, + getCodexDashboardRateLimits, + isDashboardRateLimitSubscriptionMode, + shouldShowDashboardRateLimitSkeleton, +} from './providerDashboardRateLimits'; + +import type { DashboardRateLimitItem } from './providerDashboardRateLimits'; // ============================================================================= // Border color by state @@ -88,12 +94,81 @@ const OPENCODE_DOWNLOAD_URL = 'https://opencode.ai/download'; /** Minimum banner height — prevents layout shift between states (loading → installed → checking). */ const BANNER_MIN_H = 'min-h-[4.25rem]'; +const ANTHROPIC_LIMIT_REFRESH_INTERVAL_MS = 60 * 1000; -interface CodexDashboardRateLimitItem { - label: string; - remaining: string; - resetsAt: string; -} +const DashboardRateLimitChips = ({ + providerId, + items, +}: { + providerId: CliProviderId; + items: DashboardRateLimitItem[]; +}): React.JSX.Element => ( +
+ {items.map((item) => ( +
+
+ + {item.label} + + + {item.remaining} + + + • resets {item.resetsAt} + +
+
+ ))} +
+); + +const RATE_LIMIT_SKELETON_LABELS = ['5h left', 'Weekly left'] as const; + +const DashboardRateLimitSkeletonChips = (): React.JSX.Element => ( +
+ {RATE_LIMIT_SKELETON_LABELS.map((label, index) => ( +
+
+ + {label} + + + +
+
+ ))} +
+); function getCodexDashboardHint(provider: CliProviderStatus): string | null { if (provider.providerId !== 'codex') { @@ -272,6 +347,12 @@ interface InstalledBannerProps { codexSnapshotPending: boolean; cliStatusError: string | null; providersCollapsed: boolean; + providerConnectionAuthModes: { + anthropic: CliProviderAuthMode | null; + codex: CliProviderAuthMode | null; + }; + codexRateLimitsLoading: boolean; + anthropicRateLimitsRefreshing: boolean; isBusy: boolean; onInstall: () => void; onRefresh: () => void; @@ -438,71 +519,6 @@ function formatRuntimeLabel( : runtimeLabel; } -function isCodexSubscriptionActive( - connection: CliProviderStatus['connection'] | null | undefined -): boolean { - return ( - connection?.codex?.effectiveAuthMode === 'chatgpt' && - (connection.codex.managedAccount?.type === 'chatgpt' || connection.codex.launchAllowed) - ); -} - -function buildCodexRateLimitLabel( - fallbackTitle: 'Primary left' | 'Secondary left' | 'Weekly left', - windowDurationMins: number | null | undefined -): string { - const duration = formatCodexWindowDuration(windowDurationMins); - return duration ? `${duration} left` : fallbackTitle; -} - -function formatCodexDashboardResetTime(timestampSeconds: number | null | undefined): string { - const normalized = normalizeCodexResetTimestamp(timestampSeconds); - if (!normalized) { - return 'reset unknown'; - } - - return new Date(normalized).toLocaleString(undefined, { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - }); -} - -function getCodexDashboardRateLimits( - provider: CliProviderStatus -): CodexDashboardRateLimitItem[] | null { - if (provider.providerId !== 'codex' || !isCodexSubscriptionActive(provider.connection)) { - return null; - } - - const rateLimits = provider.connection?.codex?.rateLimits; - if (!rateLimits?.primary) { - return null; - } - - const items: CodexDashboardRateLimitItem[] = []; - const primaryRemaining = formatCodexRemainingPercent(rateLimits.primary.usedPercent) ?? 'Unknown'; - items.push({ - label: buildCodexRateLimitLabel('Primary left', rateLimits.primary.windowDurationMins), - remaining: primaryRemaining, - resetsAt: formatCodexDashboardResetTime(rateLimits.primary.resetsAt), - }); - - if (rateLimits.secondary) { - items.push({ - label: buildCodexRateLimitLabel( - rateLimits.secondary.windowDurationMins === 10_080 ? 'Weekly left' : 'Secondary left', - rateLimits.secondary.windowDurationMins - ), - remaining: formatCodexRemainingPercent(rateLimits.secondary.usedPercent) ?? 'Unknown', - resetsAt: formatCodexDashboardResetTime(rateLimits.secondary.resetsAt), - }); - } - - return items; -} - function formatRuntimeAuthSummary( cliStatus: NonNullable['cliStatus']>, visibleProviders: readonly CliProviderStatus[] @@ -576,6 +592,9 @@ const InstalledBanner = ({ codexSnapshotPending, cliStatusError, providersCollapsed, + providerConnectionAuthModes, + codexRateLimitsLoading, + anthropicRateLimitsRefreshing, isBusy, onInstall, onRefresh, @@ -717,6 +736,14 @@ const InstalledBanner = ({ const connectionModeSummary = getProviderConnectionModeSummary(provider); const credentialSummary = getProviderCredentialSummary(provider); const codexDashboardRateLimits = getCodexDashboardRateLimits(provider); + const anthropicDashboardRateLimits = getAnthropicDashboardRateLimits(provider); + const dashboardRateLimits = codexDashboardRateLimits ?? anthropicDashboardRateLimits; + const hasDashboardRateLimits = Boolean(dashboardRateLimits?.length); + const isSubscriptionRateLimitMode = isDashboardRateLimitSubscriptionMode({ + provider, + sourceProvider: sourceProviderMap.get(provider.providerId) ?? null, + configuredAuthModes: providerConnectionAuthModes, + }); const codexDashboardHint = getCodexDashboardHint(provider); const codexNeedsReconnect = provider.providerId === 'codex' && @@ -738,11 +765,17 @@ const InstalledBanner = ({ isProviderCardLoading(provider, providerLoading) || isCodexSnapshotPending(provider, codexSnapshotPending) || maskNegativeBootstrapState; - const showInlineCodexAccessoryRow = - !showSkeleton && - provider.providerId === 'codex' && - provider.models.length > 0 && - Boolean(codexDashboardRateLimits?.length); + const showRateLimitSkeleton = + (showSkeleton && + shouldShowDashboardRateLimitSkeleton({ + provider, + sourceProvider, + configuredAuthModes: providerConnectionAuthModes, + })) || + (isSubscriptionRateLimitMode && + !hasDashboardRateLimits && + ((provider.providerId === 'codex' && codexRateLimitsLoading) || + (provider.providerId === 'anthropic' && anthropicRateLimitsRefreshing))); const statusText = showSkeleton ? 'Checking...' : formatProviderStatusText(provider); const hasDetailContent = Boolean( (provider.backend?.label && !runtimeSummary) || @@ -808,80 +841,7 @@ const InstalledBanner = ({ )}
) : null} - {showInlineCodexAccessoryRow ? ( -
- - {codexDashboardRateLimits!.map((item) => ( -
-
- - {item.label} - - - {item.remaining} - - - • resets {item.resetsAt} - -
-
- ))} -
- ) : !showSkeleton && - codexDashboardRateLimits && - codexDashboardRateLimits.length > 0 ? ( -
- {codexDashboardRateLimits.map((item) => ( -
-
- - {item.label} - - - {item.remaining} - - - • resets {item.resetsAt} - -
-
- ))} -
- ) : !showSkeleton && codexDashboardHint ? ( + {!showSkeleton && codexDashboardHint ? (
- {!showSkeleton && provider.models.length > 0 && !showInlineCodexAccessoryRow && ( + {!showSkeleton && provider.models.length > 0 && (
)} + {!showSkeleton && dashboardRateLimits && dashboardRateLimits.length > 0 && ( +
+ +
+ )} + {showRateLimitSkeleton && ( +
+ +
+ )} ); })} @@ -1076,6 +1049,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { const [providersCollapsed, setProvidersCollapsed] = useState(() => loadDashboardCliStatusBannerCollapsed() ); + const [anthropicRateLimitsRefreshing, setAnthropicRateLimitsRefreshing] = useState(false); const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true; const selectedProjectPath = useMemo( () => resolveProjectPathById(selectedProjectId, projects, repositoryGroups)?.path ?? null, @@ -1088,6 +1062,16 @@ export const CliStatusBanner = (): React.JSX.Element | null => { : cliStatus, [cliStatus, cliStatusLoading, multimodelEnabled] ); + const providerConnectionAuthModes = useMemo( + () => ({ + anthropic: appConfig?.providerConnections?.anthropic.authMode ?? null, + codex: appConfig?.providerConnections?.codex.preferredAuthMode ?? null, + }), + [ + appConfig?.providerConnections?.anthropic.authMode, + appConfig?.providerConnections?.codex.preferredAuthMode, + ] + ); const codexAccount = useCodexAccountSnapshot({ enabled: isElectron && @@ -1130,6 +1114,27 @@ export const CliStatusBanner = (): React.JSX.Element | null => { [loadingCliStatus, visibleCliProviders] ); const renderCliStatus = effectiveCliStatus; + const shouldPollAnthropicSubscriptionLimits = useMemo(() => { + if ( + !renderCliStatus?.installed || + renderCliStatus.flavor !== 'agent_teams_orchestrator' || + !multimodelEnabled + ) { + return false; + } + + const provider = + renderCliStatus.providers.find((candidate) => candidate.providerId === 'anthropic') ?? null; + if (!provider) { + return false; + } + + return isDashboardRateLimitSubscriptionMode({ + provider, + sourceProvider: loadingCliProviderMap.get('anthropic') ?? null, + configuredAuthModes: providerConnectionAuthModes, + }); + }, [loadingCliProviderMap, multimodelEnabled, providerConnectionAuthModes, renderCliStatus]); const runtimeDisplayName = getHumanRuntimeDisplayName(renderCliStatus, multimodelEnabled); useEffect(() => { @@ -1156,6 +1161,38 @@ export const CliStatusBanner = (): React.JSX.Element | null => { return () => clearInterval(interval); }, [bootstrapCliStatus, cliStatus, fetchCliStatus, isElectron, multimodelEnabled]); + useEffect(() => { + if (!isElectron || !shouldPollAnthropicSubscriptionLimits) { + setAnthropicRateLimitsRefreshing(false); + return; + } + + let active = true; + const refreshAnthropicLimits = async (): Promise => { + if (!active) { + return; + } + + setAnthropicRateLimitsRefreshing(true); + try { + await fetchCliProviderStatus('anthropic', { silent: true }); + } finally { + if (active) { + setAnthropicRateLimitsRefreshing(false); + } + } + }; + + const interval = setInterval(() => { + void refreshAnthropicLimits(); + }, ANTHROPIC_LIMIT_REFRESH_INTERVAL_MS); + + return () => { + active = false; + clearInterval(interval); + }; + }, [fetchCliProviderStatus, isElectron, shouldPollAnthropicSubscriptionLimits]); + const handleInstall = useCallback(() => { installCli(); }, [installCli]); @@ -1426,6 +1463,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => { codexSnapshotPending={codexSnapshotPending} cliStatusError={cliStatusError ?? null} providersCollapsed={providersCollapsed} + providerConnectionAuthModes={providerConnectionAuthModes} + codexRateLimitsLoading={codexAccount.rateLimitsLoading} + anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing} isBusy={isBusy} onInstall={handleInstall} onRefresh={handleRefresh} @@ -1652,6 +1692,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => { codexSnapshotPending={codexSnapshotPending} cliStatusError={cliStatusError ?? null} providersCollapsed={providersCollapsed} + providerConnectionAuthModes={providerConnectionAuthModes} + codexRateLimitsLoading={codexAccount.rateLimitsLoading} + anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing} isBusy={isBusy} onInstall={handleInstall} onRefresh={handleRefresh} @@ -1712,6 +1755,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => { codexSnapshotPending={codexSnapshotPending} cliStatusError={cliStatusError ?? null} providersCollapsed={providersCollapsed} + providerConnectionAuthModes={providerConnectionAuthModes} + codexRateLimitsLoading={codexAccount.rateLimitsLoading} + anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing} isBusy={isBusy} onInstall={handleInstall} onRefresh={handleRefresh} @@ -1932,6 +1978,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => { codexSnapshotPending={codexSnapshotPending} cliStatusError={cliStatusError ?? null} providersCollapsed={providersCollapsed} + providerConnectionAuthModes={providerConnectionAuthModes} + codexRateLimitsLoading={codexAccount.rateLimitsLoading} + anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing} isBusy={isBusy} onInstall={handleInstall} onRefresh={handleRefresh} diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index ad1f4d68..1466549e 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -6,6 +6,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { RecentProjectsSection } from '@features/recent-projects/renderer'; +import { RunningTeamsSection } from '@features/running-teams/renderer'; import { useStore } from '@renderer/store'; import { formatShortcut } from '@renderer/utils/stringUtils'; import { Command, Search, Users } from 'lucide-react'; @@ -131,6 +132,8 @@ export const DashboardView = (): React.JSX.Element => { + +

{searchQuery.trim() ? 'Search Results' : 'Recent Projects'} diff --git a/src/renderer/components/dashboard/providerDashboardRateLimits.test.ts b/src/renderer/components/dashboard/providerDashboardRateLimits.test.ts new file mode 100644 index 00000000..1428dadd --- /dev/null +++ b/src/renderer/components/dashboard/providerDashboardRateLimits.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, test } from 'vitest'; + +import { + getAnthropicDashboardRateLimits, + getCodexDashboardRateLimits, + shouldShowDashboardRateLimitSkeleton, +} from './providerDashboardRateLimits'; + +import type { CliProviderConnectionInfo, CliProviderStatus } from '@shared/types'; + +function createProvider(overrides: Partial): CliProviderStatus { + return { + providerId: 'anthropic', + displayName: 'Anthropic', + supported: true, + authenticated: true, + authMethod: 'claude.ai', + verificationState: 'verified', + statusMessage: null, + detailMessage: null, + models: ['haiku'], + modelAvailability: [], + runtimeCapabilities: null, + subscriptionRateLimits: null, + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + plugins: { status: 'supported', ownership: 'shared', reason: null }, + mcp: { status: 'supported', ownership: 'shared', reason: null }, + skills: { status: 'supported', ownership: 'shared', reason: null }, + apiKeys: { status: 'supported', ownership: 'shared', reason: null }, + }, + }, + selectedBackendId: null, + resolvedBackendId: null, + availableBackends: [], + externalRuntimeDiagnostics: [], + backend: null, + connection: { + supportsOAuth: true, + supportsApiKey: true, + configurableAuthModes: ['auto', 'oauth', 'api_key'], + configuredAuthMode: 'oauth', + apiKeyConfigured: false, + apiKeySource: null, + codex: null, + }, + ...overrides, + }; +} + +function createCodexConnection(): CliProviderConnectionInfo { + return { + supportsOAuth: false, + supportsApiKey: true, + configurableAuthModes: ['auto', 'chatgpt', 'api_key'], + configuredAuthMode: 'chatgpt', + apiKeyConfigured: false, + apiKeySource: null, + codex: { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + requiresOpenaiAuth: false, + localAccountArtifactsPresent: true, + localActiveChatgptAccountPresent: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: { + limitId: null, + limitName: null, + primary: { + usedPercent: 20, + windowDurationMins: 300, + resetsAt: null, + }, + secondary: null, + credits: null, + planType: 'pro', + }, + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + }, + }; +} + +describe('providerDashboardRateLimits', () => { + test('shows Anthropic subscription limits for subscription auth', () => { + const items = getAnthropicDashboardRateLimits( + createProvider({ + authMethod: 'claude.ai', + subscriptionRateLimits: { + primary: { + usedPercent: 25, + windowDurationMins: 300, + resetsAt: null, + }, + secondary: { + usedPercent: 50, + windowDurationMins: 10_080, + resetsAt: null, + }, + }, + }) + ); + + expect(items).toEqual([ + { + label: '5h left', + remaining: '75%', + resetsAt: 'reset unknown', + }, + { + label: 'Weekly left', + remaining: '50%', + resetsAt: 'reset unknown', + }, + ]); + }); + + test('hides Anthropic subscription limits in API key mode', () => { + const provider = createProvider({ + authMethod: 'claude.ai', + connection: { + supportsOAuth: true, + supportsApiKey: true, + configurableAuthModes: ['auto', 'oauth', 'api_key'], + configuredAuthMode: 'api_key', + apiKeyConfigured: true, + apiKeySource: 'stored', + codex: null, + }, + subscriptionRateLimits: { + primary: { + usedPercent: 25, + windowDurationMins: 300, + resetsAt: null, + }, + secondary: null, + }, + }); + + expect(getAnthropicDashboardRateLimits(provider)).toBeNull(); + }); + + test('hides Anthropic limits when auth method is API key even if a snapshot exists', () => { + expect( + getAnthropicDashboardRateLimits( + createProvider({ + authMethod: 'api_key', + subscriptionRateLimits: { + primary: { + usedPercent: 25, + windowDurationMins: 300, + resetsAt: null, + }, + secondary: null, + }, + }) + ) + ).toBeNull(); + }); + + test('keeps existing Codex subscription limit rendering', () => { + const items = getCodexDashboardRateLimits( + createProvider({ + providerId: 'codex', + displayName: 'Codex', + authMethod: 'oauth_token', + connection: createCodexConnection(), + }) + ); + + expect(items).toEqual([ + { + label: '5h left', + remaining: '80%', + resetsAt: 'reset unknown', + }, + ]); + }); + + test('shows Anthropic rate limit skeletons when subscription mode is selected in config', () => { + expect( + shouldShowDashboardRateLimitSkeleton({ + provider: createProvider({ + authenticated: false, + authMethod: null, + statusMessage: 'Checking...', + connection: null, + }), + configuredAuthModes: { + anthropic: 'oauth', + }, + }) + ).toBe(true); + }); + + test('hides Anthropic rate limit skeletons when API key mode is selected', () => { + expect( + shouldShowDashboardRateLimitSkeleton({ + provider: createProvider({ + authenticated: false, + authMethod: null, + statusMessage: 'Checking...', + connection: null, + }), + sourceProvider: createProvider({ + authenticated: true, + authMethod: 'claude.ai', + }), + configuredAuthModes: { + anthropic: 'api_key', + }, + }) + ).toBe(false); + }); + + test('shows Codex rate limit skeletons when ChatGPT account mode is selected', () => { + expect( + shouldShowDashboardRateLimitSkeleton({ + provider: createProvider({ + providerId: 'codex', + displayName: 'Codex', + authenticated: false, + authMethod: null, + statusMessage: 'Checking...', + connection: null, + }), + configuredAuthModes: { + codex: 'chatgpt', + }, + }) + ).toBe(true); + }); + + test('hides Codex rate limit skeletons when API key mode is selected', () => { + expect( + shouldShowDashboardRateLimitSkeleton({ + provider: createProvider({ + providerId: 'codex', + displayName: 'Codex', + authenticated: false, + authMethod: null, + statusMessage: 'Checking...', + connection: null, + }), + sourceProvider: createProvider({ + providerId: 'codex', + displayName: 'Codex', + authMethod: 'chatgpt', + connection: createCodexConnection(), + }), + configuredAuthModes: { + codex: 'api_key', + }, + }) + ).toBe(false); + }); +}); diff --git a/src/renderer/components/dashboard/providerDashboardRateLimits.ts b/src/renderer/components/dashboard/providerDashboardRateLimits.ts new file mode 100644 index 00000000..7506021d --- /dev/null +++ b/src/renderer/components/dashboard/providerDashboardRateLimits.ts @@ -0,0 +1,268 @@ +import { + formatCodexRemainingPercent, + formatCodexWindowDuration, + normalizeCodexResetTimestamp, +} from '@features/codex-account/renderer'; + +import type { + CodexAccountAuthMode, + CodexAccountEffectiveAuthMode, +} from '@features/codex-account/contracts'; +import type { CliProviderAuthMode, CliProviderStatus } from '@shared/types'; + +export interface DashboardRateLimitItem { + label: string; + remaining: string; + resetsAt: string; +} + +export interface DashboardRateLimitSkeletonModeInput { + provider: CliProviderStatus; + sourceProvider?: CliProviderStatus | null; + configuredAuthModes?: { + anthropic?: CliProviderAuthMode | null; + codex?: CodexAccountAuthMode | CliProviderAuthMode | null; + }; +} + +function firstKnown(...values: Array): T | null { + for (const value of values) { + if (value !== null && typeof value !== 'undefined') { + return value; + } + } + + return null; +} + +function isCodexSubscriptionActive( + connection: CliProviderStatus['connection'] | null | undefined +): boolean { + return ( + connection?.codex?.effectiveAuthMode === 'chatgpt' && + (connection.codex.managedAccount?.type === 'chatgpt' || connection.codex.launchAllowed) + ); +} + +function isAnthropicSubscriptionActive(provider: CliProviderStatus): boolean { + return ( + provider.providerId === 'anthropic' && + provider.authenticated && + provider.connection?.configuredAuthMode !== 'api_key' && + (provider.authMethod === 'claude.ai' || provider.authMethod === 'oauth_token') + ); +} + +function getProviderConfiguredAuthMode({ + provider, + sourceProvider, + configuredAuthModes, +}: DashboardRateLimitSkeletonModeInput): CliProviderAuthMode | null { + if (provider.providerId === 'anthropic') { + return firstKnown( + configuredAuthModes?.anthropic, + provider.connection?.configuredAuthMode, + sourceProvider?.connection?.configuredAuthMode + ); + } + + if (provider.providerId === 'codex') { + return firstKnown( + configuredAuthModes?.codex as CliProviderAuthMode | null | undefined, + provider.connection?.codex?.preferredAuthMode, + provider.connection?.configuredAuthMode, + sourceProvider?.connection?.codex?.preferredAuthMode, + sourceProvider?.connection?.configuredAuthMode + ); + } + + return firstKnown( + provider.connection?.configuredAuthMode, + sourceProvider?.connection?.configuredAuthMode + ); +} + +function getCodexEffectiveAuthMode( + provider: CliProviderStatus, + sourceProvider: CliProviderStatus | null | undefined +): CodexAccountEffectiveAuthMode { + return firstKnown( + provider.connection?.codex?.effectiveAuthMode, + sourceProvider?.connection?.codex?.effectiveAuthMode + ) as CodexAccountEffectiveAuthMode; +} + +export function isDashboardRateLimitSubscriptionMode({ + provider, + sourceProvider = null, + configuredAuthModes, +}: DashboardRateLimitSkeletonModeInput): boolean { + if (provider.providerId === 'anthropic') { + const configuredAuthMode = getProviderConfiguredAuthMode({ + provider, + sourceProvider, + configuredAuthModes, + }); + + if (configuredAuthMode === 'api_key') { + return false; + } + + if (configuredAuthMode === 'oauth') { + return true; + } + + return ( + provider.authMethod === 'claude.ai' || + provider.authMethod === 'oauth_token' || + sourceProvider?.authMethod === 'claude.ai' || + sourceProvider?.authMethod === 'oauth_token' + ); + } + + if (provider.providerId === 'codex') { + const configuredAuthMode = getProviderConfiguredAuthMode({ + provider, + sourceProvider, + configuredAuthModes, + }); + + if (configuredAuthMode === 'api_key') { + return false; + } + + if (configuredAuthMode === 'chatgpt') { + return true; + } + + return getCodexEffectiveAuthMode(provider, sourceProvider) === 'chatgpt'; + } + + return false; +} + +export function shouldShowDashboardRateLimitSkeleton( + input: DashboardRateLimitSkeletonModeInput +): boolean { + return isDashboardRateLimitSubscriptionMode(input); +} + +function buildRateLimitLabel( + fallbackTitle: 'Primary left' | 'Secondary left' | 'Weekly left', + windowDurationMins: number | null | undefined +): string { + const duration = formatCodexWindowDuration(windowDurationMins); + return duration ? `${duration} left` : fallbackTitle; +} + +function buildAnthropicRateLimitLabel( + fallbackTitle: 'Primary left' | 'Secondary left' | 'Weekly left', + windowDurationMins: number | null | undefined +): string { + if (windowDurationMins === 10_080) { + return 'Weekly left'; + } + + return buildRateLimitLabel(fallbackTitle, windowDurationMins); +} + +function formatDashboardResetTime(timestampSeconds: number | null | undefined): string { + const normalized = normalizeCodexResetTimestamp(timestampSeconds); + if (!normalized) { + return 'reset unknown'; + } + + return new Date(normalized).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +function buildRateLimitItem( + label: string, + usedPercent: number, + resetsAt: number | null | undefined +): DashboardRateLimitItem { + return { + label, + remaining: formatCodexRemainingPercent(usedPercent) ?? 'Unknown', + resetsAt: formatDashboardResetTime(resetsAt), + }; +} + +export function getCodexDashboardRateLimits( + provider: CliProviderStatus +): DashboardRateLimitItem[] | null { + if (provider.providerId !== 'codex' || !isCodexSubscriptionActive(provider.connection)) { + return null; + } + + const rateLimits = provider.connection?.codex?.rateLimits; + if (!rateLimits?.primary) { + return null; + } + + const items: DashboardRateLimitItem[] = [ + buildRateLimitItem( + buildRateLimitLabel('Primary left', rateLimits.primary.windowDurationMins), + rateLimits.primary.usedPercent, + rateLimits.primary.resetsAt + ), + ]; + + if (rateLimits.secondary) { + items.push( + buildRateLimitItem( + buildRateLimitLabel( + rateLimits.secondary.windowDurationMins === 10_080 ? 'Weekly left' : 'Secondary left', + rateLimits.secondary.windowDurationMins + ), + rateLimits.secondary.usedPercent, + rateLimits.secondary.resetsAt + ) + ); + } + + return items; +} + +export function getAnthropicDashboardRateLimits( + provider: CliProviderStatus +): DashboardRateLimitItem[] | null { + if (!isAnthropicSubscriptionActive(provider)) { + return null; + } + + const rateLimits = provider.subscriptionRateLimits; + if (!rateLimits?.primary && !rateLimits?.secondary) { + return null; + } + + const items: DashboardRateLimitItem[] = []; + if (rateLimits.primary) { + items.push( + buildRateLimitItem( + buildAnthropicRateLimitLabel('Primary left', rateLimits.primary.windowDurationMins), + rateLimits.primary.usedPercent, + rateLimits.primary.resetsAt + ) + ); + } + + if (rateLimits.secondary) { + items.push( + buildRateLimitItem( + buildAnthropicRateLimitLabel( + rateLimits.secondary.windowDurationMins === 10_080 ? 'Weekly left' : 'Secondary left', + rateLimits.secondary.windowDurationMins + ), + rateLimits.secondary.usedPercent, + rateLimits.secondary.resetsAt + ) + ); + } + + return items.length > 0 ? items : null; +} diff --git a/src/renderer/components/team/TeamChangesSection.tsx b/src/renderer/components/team/TeamChangesSection.tsx new file mode 100644 index 00000000..d022f2d6 --- /dev/null +++ b/src/renderer/components/team/TeamChangesSection.tsx @@ -0,0 +1,597 @@ +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { api } from '@renderer/api'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { useStore } from '@renderer/store'; +import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence'; +import { + buildTaskChangeRequestOptions, + canDisplayTaskChangesForOptions, + type TaskChangeRequestOptions, +} from '@renderer/utils/taskChangeRequest'; +import { deriveTaskDisplayId } from '@shared/utils/taskIdentity'; +import { AlertTriangle, FileDiff, GitCompareArrows, Loader2, RefreshCw } from 'lucide-react'; + +import { FileIcon } from './editor/FileIcon'; +import { CollapsibleTeamSection } from './CollapsibleTeamSection'; + +import type { + FileChangeSummary, + TaskChangeSetV2, + TeamTaskChangeSummaryRequest, + TeamTaskWithKanban, +} from '@shared/types'; + +const TEAM_CHANGES_AUTO_REFRESH_MS = 30_000; +const TEAM_CHANGES_MAX_REQUESTS = 120; +const TEAM_CHANGES_UNKNOWN_SCAN_LIMIT = 32; +const TEAM_CHANGES_MAX_RENDERED_FILE_ROWS = 300; + +interface TeamChangesSectionProps { + teamName: string; + tasks: TeamTaskWithKanban[]; + onViewChanges: (taskId: string, filePath?: string) => void; +} + +interface TeamChangeCandidate { + task: TeamTaskWithKanban; + options: TaskChangeRequestOptions; + priority: number; + isUnknownScan: boolean; +} + +interface TeamChangeRequestPlan { + requests: TeamTaskChangeSummaryRequest[]; + requestOptionsByTaskId: Map; + eligibleCount: number; + requestedCount: number; + deferredCount: number; + nextUnknownScanCursor: number; +} + +interface TeamChangeSummaryState { + taskId: string; + changeSet: TaskChangeSetV2 | null; + error?: string; + options: TaskChangeRequestOptions; + loadedAt: number; +} + +interface TeamChangeStats { + eligibleCount: number; + requestedCount: number; + deferredCount: number; +} + +function getTaskTimeMs(task: TeamTaskWithKanban): number { + const value = task.updatedAt ?? task.createdAt; + if (!value) return 0; + const ms = new Date(value).getTime(); + return Number.isFinite(ms) ? ms : 0; +} + +function compareCandidateRecency(a: TeamChangeCandidate, b: TeamChangeCandidate): number { + const priorityDelta = a.priority - b.priority; + if (priorityDelta !== 0) return priorityDelta; + return getTaskTimeMs(b.task) - getTaskTimeMs(a.task); +} + +function rotateCandidates(items: T[], cursor: number): T[] { + if (items.length === 0) return items; + const start = cursor % items.length; + if (start === 0) return items; + return [...items.slice(start), ...items.slice(0, start)]; +} + +function buildTeamChangeRequestPlan( + tasks: TeamTaskWithKanban[], + unknownScanCursor: number, + forceFresh: boolean +): TeamChangeRequestPlan { + const primary: TeamChangeCandidate[] = []; + const active: TeamChangeCandidate[] = []; + const unknown: TeamChangeCandidate[] = []; + const seenTaskIds = new Set(); + + for (const task of tasks) { + if (!task.id || task.status === 'deleted' || seenTaskIds.has(task.id)) { + continue; + } + seenTaskIds.add(task.id); + + const options = buildTaskChangeRequestOptions(task, { summaryOnly: true }); + const presence = task.changePresence ?? 'unknown'; + const canDisplay = canDisplayTaskChangesForOptions(options); + if (!canDisplay && presence !== 'has_changes' && presence !== 'needs_attention') { + continue; + } + + if (presence === 'has_changes') { + primary.push({ task, options, priority: 0, isUnknownScan: false }); + continue; + } + if (presence === 'needs_attention') { + primary.push({ task, options, priority: 1, isUnknownScan: false }); + continue; + } + if (options.stateBucket === 'active' && options.status === 'in_progress') { + active.push({ task, options, priority: 2, isUnknownScan: false }); + continue; + } + if (presence === 'unknown') { + unknown.push({ task, options, priority: 3, isUnknownScan: true }); + } + } + + primary.sort(compareCandidateRecency); + active.sort(compareCandidateRecency); + unknown.sort(compareCandidateRecency); + + const unknownWindow = rotateCandidates(unknown, unknownScanCursor).slice( + 0, + TEAM_CHANGES_UNKNOWN_SCAN_LIMIT + ); + const selected = [...primary, ...active, ...unknownWindow].slice(0, TEAM_CHANGES_MAX_REQUESTS); + const requestOptionsByTaskId = new Map(); + const requests = selected.map((candidate) => { + const options = { + ...candidate.options, + summaryOnly: true, + forceFresh: forceFresh ? true : candidate.options.forceFresh, + }; + requestOptionsByTaskId.set(candidate.task.id, options); + return { + taskId: candidate.task.id, + options, + }; + }); + const eligibleCount = primary.length + active.length + unknown.length; + const nextUnknownScanCursor = + unknown.length > 0 + ? (unknownScanCursor + Math.min(TEAM_CHANGES_UNKNOWN_SCAN_LIMIT, unknown.length)) % + unknown.length + : 0; + + return { + requests, + requestOptionsByTaskId, + eligibleCount, + requestedCount: requests.length, + deferredCount: Math.max(0, eligibleCount - requests.length), + nextUnknownScanCursor, + }; +} + +function getTaskChangeContributors( + task: TeamTaskWithKanban, + changeSet: TaskChangeSetV2 | null +): string[] { + const names = new Set(); + for (const contributor of changeSet?.scope.contributors ?? []) { + if (contributor.memberName) names.add(contributor.memberName); + } + for (const name of changeSet?.scope.memberNames ?? []) { + names.add(name); + } + if (changeSet?.scope.primaryMemberName) { + names.add(changeSet.scope.primaryMemberName); + } + for (const file of changeSet?.files ?? []) { + for (const name of file.ledgerSummary?.memberNames ?? []) { + names.add(name); + } + } + if (names.size === 0 && task.owner) { + names.add(task.owner); + } + return [...names]; +} + +function getVisibleFileName(file: FileChangeSummary): string { + const value = file.relativePath || file.filePath; + return value.split('/').pop() ?? value; +} + +function getTaskSummaryBadge(changeSet: TaskChangeSetV2 | null): string | undefined { + if (!changeSet) return undefined; + if (changeSet.totalFiles > 0) return `${changeSet.totalFiles} files`; + if (changeSet.warnings.length > 0) return 'attention'; + return undefined; +} + +function buildTasksFingerprint(tasks: TeamTaskWithKanban[]): string { + return tasks + .map((task) => + [ + task.id, + task.status, + task.owner ?? '', + task.updatedAt ?? '', + task.changePresence ?? 'unknown', + task.workIntervals?.length ?? 0, + ].join(':') + ) + .join('|'); +} + +export const TeamChangesSection = memo(function TeamChangesSection({ + teamName, + tasks, + onViewChanges, +}: TeamChangesSectionProps): React.JSX.Element { + const recordTaskChangePresence = useStore((s) => s.recordTaskChangePresence); + const setSelectedTeamTaskChangePresence = useStore((s) => s.setSelectedTeamTaskChangePresence); + const [sectionOpen, setSectionOpen] = useState(false); + const [summariesByTaskId, setSummariesByTaskId] = useState< + Record + >({}); + const [stats, setStats] = useState({ + eligibleCount: 0, + requestedCount: 0, + deferredCount: 0, + }); + const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const hasLoadedRef = useRef(false); + const requestSeqRef = useRef(0); + const unknownScanCursorRef = useRef(0); + const lastRequestedTasksFingerprintRef = useRef(null); + const tasksFingerprint = useMemo(() => buildTasksFingerprint(tasks), [tasks]); + const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]); + + const visibleSummaries = useMemo(() => { + return Object.values(summariesByTaskId) + .map((summary) => ({ summary, task: taskMap.get(summary.taskId) })) + .filter( + (entry): entry is { summary: TeamChangeSummaryState; task: TeamTaskWithKanban } => + Boolean(entry.task) && + (Boolean(entry.summary.error) || + (entry.summary.changeSet?.files.length ?? 0) > 0 || + (entry.summary.changeSet?.warnings.length ?? 0) > 0) + ) + .sort((a, b) => getTaskTimeMs(b.task) - getTaskTimeMs(a.task)); + }, [summariesByTaskId, taskMap]); + + const totalFiles = visibleSummaries.reduce( + (sum, entry) => sum + (entry.summary.changeSet?.files.length ?? 0), + 0 + ); + const hiddenFileRows = Math.max(0, totalFiles - TEAM_CHANGES_MAX_RENDERED_FILE_ROWS); + const badge = totalFiles > 0 ? totalFiles : visibleSummaries.length || undefined; + + const loadSummaries = useCallback( + async ({ + forceFresh = false, + showSpinner = false, + preserveOnError = true, + }: { + forceFresh?: boolean; + showSpinner?: boolean; + preserveOnError?: boolean; + } = {}): Promise => { + const plan = buildTeamChangeRequestPlan(tasks, unknownScanCursorRef.current, forceFresh); + unknownScanCursorRef.current = plan.nextUnknownScanCursor; + setStats({ + eligibleCount: plan.eligibleCount, + requestedCount: plan.requestedCount, + deferredCount: plan.deferredCount, + }); + setError(null); + + if (plan.requests.length === 0) { + setSummariesByTaskId({}); + return; + } + + const requestSeq = requestSeqRef.current + 1; + requestSeqRef.current = requestSeq; + if (showSpinner) { + setLoading(true); + } else { + setRefreshing(true); + } + + try { + const response = await api.review.getTeamTaskChangeSummaries(teamName, plan.requests); + if (requestSeqRef.current !== requestSeq) { + return; + } + + const currentTaskIds = new Set(tasks.map((task) => task.id)); + for (const item of response.items) { + const changeSet = item.changeSet; + const options = plan.requestOptionsByTaskId.get(item.taskId); + if (!changeSet || !options) continue; + + const nextPresence = resolveTaskChangePresenceFromResult(changeSet); + recordTaskChangePresence(teamName, item.taskId, options, nextPresence); + setSelectedTeamTaskChangePresence(teamName, item.taskId, nextPresence ?? 'unknown'); + } + + setSummariesByTaskId((previous) => { + const next: Record = {}; + for (const [taskId, summary] of Object.entries(previous)) { + if (currentTaskIds.has(taskId)) { + next[taskId] = summary; + } + } + for (const item of response.items) { + const options = plan.requestOptionsByTaskId.get(item.taskId); + if (!options) continue; + next[item.taskId] = { + taskId: item.taskId, + changeSet: item.changeSet, + error: item.error, + options, + loadedAt: Date.now(), + }; + } + return next; + }); + } catch (err) { + if (requestSeqRef.current !== requestSeq) { + return; + } + if (!preserveOnError) { + setSummariesByTaskId({}); + } + setError(err instanceof Error ? err.message : 'Failed to load team changes'); + } finally { + if (requestSeqRef.current === requestSeq) { + setLoading(false); + setRefreshing(false); + } + } + }, + [recordTaskChangePresence, setSelectedTeamTaskChangePresence, tasks, teamName] + ); + + useEffect(() => { + hasLoadedRef.current = false; + requestSeqRef.current += 1; + unknownScanCursorRef.current = 0; + lastRequestedTasksFingerprintRef.current = null; + setSummariesByTaskId({}); + setError(null); + setStats({ eligibleCount: 0, requestedCount: 0, deferredCount: 0 }); + }, [teamName]); + + useEffect(() => { + if (!sectionOpen || hasLoadedRef.current) { + return; + } + hasLoadedRef.current = true; + lastRequestedTasksFingerprintRef.current = tasksFingerprint; + void loadSummaries({ showSpinner: true, preserveOnError: false }); + }, [loadSummaries, sectionOpen, tasksFingerprint]); + + useEffect(() => { + if (!sectionOpen || !hasLoadedRef.current) { + return; + } + if (lastRequestedTasksFingerprintRef.current === tasksFingerprint) { + return; + } + lastRequestedTasksFingerprintRef.current = tasksFingerprint; + void loadSummaries({ showSpinner: false, preserveOnError: true }); + }, [loadSummaries, sectionOpen, tasksFingerprint]); + + useEffect(() => { + if (!sectionOpen) { + return; + } + + const timer = window.setInterval(() => { + void loadSummaries({ showSpinner: false, preserveOnError: true }); + }, TEAM_CHANGES_AUTO_REFRESH_MS); + + return () => { + window.clearInterval(timer); + }; + }, [loadSummaries, sectionOpen]); + + const handleRefresh = useCallback(() => { + void loadSummaries({ forceFresh: true, showSpinner: true, preserveOnError: false }); + }, [loadSummaries]); + + let remainingFileRows = TEAM_CHANGES_MAX_RENDERED_FILE_ROWS; + + return ( + } + badge={badge} + defaultOpen={false} + onOpenChange={setSectionOpen} + headerExtra={ + loading && !sectionOpen ? ( + + ) : sectionOpen ? ( + + + + + Refresh + + ) : null + } + contentClassName="pl-2.5" + > + {loading && visibleSummaries.length === 0 ? ( +
+ + Loading changes... +
+ ) : error ? ( +

{error}

+ ) : visibleSummaries.length > 0 ? ( +
+
+ {visibleSummaries.map(({ summary, task }) => { + const changeSet = summary.changeSet; + const files = changeSet?.files ?? []; + const fileBudget = Math.max(0, remainingFileRows); + const visibleFiles = files.slice(0, fileBudget); + remainingFileRows -= visibleFiles.length; + const contributors = getTaskChangeContributors(task, changeSet); + const contributorLabel = + contributors.length > 0 ? contributors.slice(0, 3).join(', ') : 'Unassigned'; + const extraContributors = Math.max(0, contributors.length - 3); + const badgeText = getTaskSummaryBadge(changeSet); + + if (visibleFiles.length === 0 && !summary.error && !changeSet?.warnings.length) { + return null; + } + + return ( +
+ + + {summary.error ? ( +
+ + {summary.error} +
+ ) : null} + + {changeSet?.warnings.length ? ( +
+ {changeSet.warnings.slice(0, 2).map((warning) => ( +
+ + {warning} +
+ ))} +
+ ) : null} + + {visibleFiles.length > 0 ? ( +
+ {visibleFiles.map((file) => ( +
+ + + + {file.linesAdded > 0 ? ( + +{file.linesAdded} + ) : null} + {file.linesRemoved > 0 ? ( + -{file.linesRemoved} + ) : null} + + + + + + + Review diff + + +
+ ))} +
+ ) : null} + + {files.length > visibleFiles.length && fileBudget > 0 ? ( +
+ {files.length - visibleFiles.length} more files +
+ ) : null} +
+ ); + })} +
+ +
+ {refreshing ? ( + + + Refreshing + + ) : null} + {hiddenFileRows > 0 ? {hiddenFileRows} file rows hidden : null} + {stats.deferredCount > 0 ? ( + {stats.deferredCount} tasks deferred this pass + ) : null} +
+
+ ) : ( +
+

No file changes recorded

+ {stats.eligibleCount > 0 ? ( +

+ Scanned {stats.requestedCount} of {stats.eligibleCount} candidate tasks +

+ ) : null} +
+ )} +
+ ); +}); + +TeamChangesSection.displayName = 'TeamChangesSection'; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 967080db..92b5a2a3 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -133,6 +133,7 @@ import { LeadSessionDetailGate } from './LeadSessionDetailGate'; import { LiveRuntimeStatusBridge } from './LiveRuntimeStatusBridge'; import { ProcessesSection } from './ProcessesSection'; import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps'; +import { TeamChangesSection } from './TeamChangesSection'; import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { loadTeamSessionMetadata } from './teamSessionFetchGuards'; import { TeamSessionsSection } from './TeamSessionsSection'; @@ -2714,6 +2715,12 @@ export const TeamDetailView = memo(function TeamDetailView({ /> + +
- {(() => { - const tc = taskCountsByTeam.get(team.teamName); - const pending = tc?.pending ?? 0; - const inProgress = tc?.inProgress ?? 0; - const completed = tc?.completed ?? 0; - const totalTasks = pending + inProgress + completed; - const completedRatio = totalTasks > 0 ? completed / totalTasks : 0; - return ( -
-
-
-
-
- - {completed}/{totalTasks} - -
- {totalTasks > 0 && ( -
- {inProgress > 0 && ( - - - {inProgress} in_progress - - )} - {pending > 0 && ( - - - {pending} pending - - )} - {completed > 0 && ( - - - {completed} completed - - )} -
- )} -
- ); - })()} + {renderTeamRecentPaths(team, status, matchesCurrentProject, isLight)}
diff --git a/src/renderer/components/team/TeamTaskStatusSummary.tsx b/src/renderer/components/team/TeamTaskStatusSummary.tsx new file mode 100644 index 00000000..c805c206 --- /dev/null +++ b/src/renderer/components/team/TeamTaskStatusSummary.tsx @@ -0,0 +1,88 @@ +import { CheckCircle, Clock, Play } from 'lucide-react'; + +import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; +import type React from 'react'; + +interface TeamTaskStatusSummaryProps { + counts?: TaskStatusCounts | null; + className?: string; + showProgress?: boolean; + iconSize?: number; + countersClassName?: string; +} + +function normalizeCounts(counts?: TaskStatusCounts | null): TaskStatusCounts { + return { + pending: counts?.pending ?? 0, + inProgress: counts?.inProgress ?? 0, + completed: counts?.completed ?? 0, + }; +} + +function getTaskStatusTotal(counts?: TaskStatusCounts | null): number { + const normalized = normalizeCounts(counts); + return normalized.pending + normalized.inProgress + normalized.completed; +} + +export const TeamTaskStatusSummary = ({ + counts, + className = 'mt-2 w-full space-y-1.5', + showProgress = true, + iconSize = 10, + countersClassName = 'flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-[var(--color-text-muted)]', +}: Readonly): React.JSX.Element | null => { + const normalized = normalizeCounts(counts); + const totalTasks = getTaskStatusTotal(normalized); + const completedRatio = totalTasks > 0 ? normalized.completed / totalTasks : 0; + + if (!showProgress && totalTasks === 0) { + return null; + } + + return ( +
+ {showProgress && ( +
+
+
+
+ + {normalized.completed}/{totalTasks} + +
+ )} + {totalTasks > 0 && ( +
+ {normalized.inProgress > 0 && ( + + + {normalized.inProgress} in_progress + + )} + {normalized.pending > 0 && ( + + + {normalized.pending} pending + + )} + {normalized.completed > 0 && ( + + + {normalized.completed} completed + + )} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/team/__tests__/TeamTaskStatusSummary.test.tsx b/src/renderer/components/team/__tests__/TeamTaskStatusSummary.test.tsx new file mode 100644 index 00000000..7ffa3ea9 --- /dev/null +++ b/src/renderer/components/team/__tests__/TeamTaskStatusSummary.test.tsx @@ -0,0 +1,63 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { TeamTaskStatusSummary } from '../TeamTaskStatusSummary'; + +function renderSummary(element: React.ReactElement): { + host: HTMLDivElement; + root: ReturnType; +} { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + act(() => { + root.render(element); + }); + + return { host, root }; +} + +describe('TeamTaskStatusSummary', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('renders task status counters with team card labels', () => { + const { host, root } = renderSummary( + + ); + + expect(host.textContent).toContain('2 in_progress'); + expect(host.textContent).toContain('2 pending'); + expect(host.textContent).toContain('1 completed'); + + act(() => { + root.unmount(); + }); + }); + + it('hides zero counters when progress is disabled', () => { + const { host, root } = renderSummary( + + ); + + expect(host.textContent).toBe(''); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index f92b998b..8a2edabc 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -347,7 +347,7 @@ const PassiveIdlePeerSummaryRow = ({ return (
- update + note ( +

+ {ANTHROPIC_SONNET_EXTRA_USAGE_WARNING}{' '} + + Read Anthropic pricing docs + + . +

+); diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index f1ce30ee..a63b9873 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -463,10 +463,6 @@ export const CreateTeamDialog = ({ const normalizedValue = normalizeLeadProviderForMode(value, multimodelEnabled); setSelectedProviderIdRaw(normalizedValue); setStoredCreateTeamProvider(normalizedValue); - if (normalizedValue !== 'anthropic') { - setLimitContextRaw(false); - setStoredCreateTeamLimitContext(false); - } setSelectedModelRaw(getStoredTeamModel(normalizedValue)); }; @@ -589,6 +585,8 @@ export const CreateTeamDialog = ({ ]) ); }, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]); + const hasSelectedAnthropicRuntime = selectedMemberProviders.includes('anthropic'); + const effectiveAnthropicRuntimeLimitContext = hasSelectedAnthropicRuntime ? limitContext : false; const runtimeBackendSummaryByProvider = useMemo(() => { const entries: (readonly [TeamProviderId, string | null])[] = ( @@ -670,13 +668,13 @@ export const CreateTeamDialog = ({ selectedProviderId, selectedModel, selectedMemberProviders, - limitContext, + limitContext: effectiveAnthropicRuntimeLimitContext, runtimeStatusSignature: prepareRuntimeStatusSignature, membersSignature: prepareMembersSignature, }), [ effectiveCwd, - limitContext, + effectiveAnthropicRuntimeLimitContext, prepareMembersSignature, prepareRuntimeStatusSignature, selectedMemberProviders, @@ -777,7 +775,7 @@ export const CreateTeamDialog = ({ (providerId === 'anthropic' && selectedProviderId === 'anthropic'); const leadModel = computeEffectiveTeamModel( selectedModel, - limitContext, + effectiveAnthropicRuntimeLimitContext, selectedProviderId ); if (selectedProviderId === providerId && selectedModel.trim()) { @@ -816,7 +814,7 @@ export const CreateTeamDialog = ({ cwd: effectiveCwd, providerId, backendSummary, - limitContext, + limitContext: effectiveAnthropicRuntimeLimitContext, runtimeStatusSignature: prepareRuntimeStatusSignature, }); const cachedModelResultsById = { @@ -859,7 +857,7 @@ export const CreateTeamDialog = ({ providerId: plan.providerId, selectedModelIds: plan.selectedModelChecks, prepareProvisioning: api.teams.prepareProvisioning, - limitContext, + limitContext: effectiveAnthropicRuntimeLimitContext, cachedModelResultsById: plan.cachedModelResultsById, onModelProgress: ({ status, details }) => { checks = updateProviderCheck(checks, plan.providerId, { @@ -940,7 +938,7 @@ export const CreateTeamDialog = ({ launchTeam, effectiveCwd, effectiveMemberDrafts, - limitContext, + effectiveAnthropicRuntimeLimitContext, prepareRequestSignature, runtimeProviderStatusById, selectedModel, @@ -1169,11 +1167,16 @@ export const CreateTeamDialog = ({ () => computeEffectiveTeamModel( selectedModel, - limitContext, + effectiveAnthropicRuntimeLimitContext, selectedProviderId, runtimeProviderStatusById.get(selectedProviderId) ), - [limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId] + [ + effectiveAnthropicRuntimeLimitContext, + runtimeProviderStatusById, + selectedModel, + selectedProviderId, + ] ); const teammateRuntimeCompatibility = useMemo( () => @@ -1209,10 +1212,15 @@ export const CreateTeamDialog = ({ runtimeCapabilities: runtimeProviderStatusById.get('anthropic')?.runtimeCapabilities, }, selectedModel, - limitContext, + limitContext: effectiveAnthropicRuntimeLimitContext, }) : null, - [limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId] + [ + effectiveAnthropicRuntimeLimitContext, + runtimeProviderStatusById, + selectedModel, + selectedProviderId, + ] ); const anthropicFastModeResolution = useMemo( () => @@ -1274,7 +1282,7 @@ export const CreateTeamDialog = ({ runtimeCapabilities: null, }, selectedModel, - limitContext, + limitContext: effectiveAnthropicRuntimeLimitContext, }), selectedEffort, selectedFastMode, @@ -1320,7 +1328,7 @@ export const CreateTeamDialog = ({ anthropicProviderFastModeDefault, anthropicRuntimeSelection, codexRuntimeSelection, - limitContext, + effectiveAnthropicRuntimeLimitContext, runtimeProviderStatusById, selectedEffort, selectedFastMode, @@ -1350,7 +1358,7 @@ export const CreateTeamDialog = ({ selectedProviderId === 'anthropic' || selectedProviderId === 'codex' ? selectedFastMode : undefined, - limitContext, + limitContext: effectiveAnthropicRuntimeLimitContext, skipPermissions, worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined, extraCliArgs: customArgs.trim() || undefined, @@ -1368,7 +1376,7 @@ export const CreateTeamDialog = ({ effectiveModel, selectedEffort, selectedFastMode, - limitContext, + effectiveAnthropicRuntimeLimitContext, skipPermissions, worktreeEnabled, worktreeName, @@ -1512,12 +1520,16 @@ export const CreateTeamDialog = ({ summary.push('Fast default'); } } + if (effectiveAnthropicRuntimeLimitContext) { + summary.push('Anthropic limited to 200K context'); + } if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`); if (customArgs.trim()) summary.push('Custom CLI args'); return summary; }, [ anthropicProviderFastModeDefault, customArgs, + effectiveAnthropicRuntimeLimitContext, prompt, selectedFastMode, selectedProviderId, @@ -1833,7 +1845,7 @@ export const CreateTeamDialog = ({ providerId={selectedProviderId} model={selectedModel} effort={(selectedEffort as EffortLevel) || undefined} - limitContext={limitContext} + limitContext={effectiveAnthropicRuntimeLimitContext} onProviderChange={setSelectedProviderId} onModelChange={setSelectedModel} onEffortChange={setSelectedEffort} @@ -1944,7 +1956,7 @@ export const CreateTeamDialog = ({ onValueChange={setSelectedFastMode} providerFastModeDefault={anthropicProviderFastModeDefault} model={selectedModel} - limitContext={limitContext} + limitContext={effectiveAnthropicRuntimeLimitContext} id="create-fast-mode" /> {anthropicRuntimeNotice ? ( diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 3f6bf8c2..c330e0ae 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -516,6 +516,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ), [effectiveMemberDrafts, multimodelEnabled, selectedProviderId] ); + const hasSelectedAnthropicRuntime = isLaunchMode && selectedMemberProviders.includes('anthropic'); + const effectiveAnthropicRuntimeLimitContext = + hasSelectedAnthropicRuntime && !isSchedule ? limitContext : false; const runtimeBackendSummaryByProvider = useMemo(() => { const entries: (readonly [TeamProviderId, string | null])[] = ( @@ -642,10 +645,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen : normalizeOneShotProviderForMode(value, multimodelEnabled); setSelectedProviderIdRaw(normalizedValue); localStorage.setItem('team:lastSelectedProvider', normalizedValue); - if (normalizedValue !== 'anthropic') { - setLimitContextRaw(false); - localStorage.setItem('team:lastLimitContext', 'false'); - } setSelectedModelRaw(getStoredTeamModel(normalizedValue)); }; @@ -897,17 +896,20 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen return previousProviderId !== selectedProviderId; }, [isLaunchMode, previousProviderId, selectedProviderId]); - const effectiveAnthropicRuntimeLimitContext = isSchedule ? false : limitContext; - const effectiveLeadRuntimeModel = useMemo( () => computeEffectiveTeamModel( selectedModel, - limitContext, + effectiveAnthropicRuntimeLimitContext, selectedProviderId, runtimeProviderStatusById.get(selectedProviderId) ) ?? '', - [limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId] + [ + effectiveAnthropicRuntimeLimitContext, + runtimeProviderStatusById, + selectedModel, + selectedProviderId, + ] ); const selectedProviderBackendId = useMemo( () => @@ -1401,13 +1403,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen selectedProviderId, selectedModel, selectedMemberProviders, - limitContext, + limitContext: effectiveAnthropicRuntimeLimitContext, runtimeStatusSignature: prepareRuntimeStatusSignature, modelChecksSignature: selectedModelChecksByProviderSignature, }), [ effectiveCwd, - limitContext, + effectiveAnthropicRuntimeLimitContext, prepareRuntimeStatusSignature, selectedMemberProviders, selectedModel, @@ -1477,7 +1479,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen cwd: effectiveCwd, providerId, backendSummary, - limitContext, + limitContext: effectiveAnthropicRuntimeLimitContext, runtimeStatusSignature: prepareRuntimeStatusSignature, }); const cachedModelResultsById = { @@ -1520,7 +1522,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen providerId: plan.providerId, selectedModelIds: plan.selectedModelChecks, prepareProvisioning: api.teams.prepareProvisioning, - limitContext, + limitContext: effectiveAnthropicRuntimeLimitContext, cachedModelResultsById: plan.cachedModelResultsById, onModelProgress: ({ status, details }) => { checks = updateProviderCheck(checks, plan.providerId, { @@ -1599,6 +1601,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen open, isLaunchMode, effectiveCwd, + effectiveAnthropicRuntimeLimitContext, prepareRequestSignature, selectedProviderId, selectedMemberProviders, @@ -1742,7 +1745,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen if (skipPermissions) args.push('--dangerously-skip-permissions'); const model = computeEffectiveTeamModel( selectedModel, - limitContext, + effectiveAnthropicRuntimeLimitContext, selectedProviderId, runtimeProviderStatusById.get(selectedProviderId) ); @@ -1769,7 +1772,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen isLaunchMode, skipPermissions, selectedModel, - limitContext, + effectiveAnthropicRuntimeLimitContext, selectedEffort, selectedProviderId, clearContext, @@ -1799,7 +1802,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen summary.push('Fast default'); } } - if (selectedProviderId === 'anthropic' && limitContext) summary.push('Limited to 200K context'); + if (effectiveAnthropicRuntimeLimitContext) { + summary.push('Anthropic limited to 200K context'); + } if (skipPermissions) summary.push('Auto-approve tools'); if (clearContext) summary.push('Fresh session'); if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`); @@ -1814,7 +1819,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen selectedEffort, selectedFastMode, anthropicProviderFastModeDefault, - limitContext, + effectiveAnthropicRuntimeLimitContext, skipPermissions, clearContext, worktreeEnabled, @@ -2054,7 +2059,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen undefined, model: computeEffectiveTeamModel( selectedModel, - limitContext, + effectiveAnthropicRuntimeLimitContext, selectedProviderId, runtimeProviderStatusById.get(selectedProviderId) ), @@ -2063,7 +2068,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen selectedProviderId === 'anthropic' || selectedProviderId === 'codex' ? selectedFastMode : undefined, - limitContext, + limitContext: effectiveAnthropicRuntimeLimitContext, clearContext: clearContext || undefined, skipPermissions, worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined, @@ -2542,7 +2547,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen providerId={selectedProviderId} model={selectedModel} effort={(selectedEffort as EffortLevel) || undefined} - limitContext={limitContext} + limitContext={effectiveAnthropicRuntimeLimitContext} onProviderChange={setSelectedProviderId} onModelChange={setSelectedModel} onEffortChange={setSelectedEffort} diff --git a/src/renderer/components/team/dialogs/LimitContextCheckbox.tsx b/src/renderer/components/team/dialogs/LimitContextCheckbox.tsx index 772cdf66..5145c169 100644 --- a/src/renderer/components/team/dialogs/LimitContextCheckbox.tsx +++ b/src/renderer/components/team/dialogs/LimitContextCheckbox.tsx @@ -15,6 +15,7 @@ interface LimitContextCheckboxProps { checked: boolean; onCheckedChange: (checked: boolean) => void; disabled?: boolean; + scopeLabel?: string; } export const LimitContextCheckbox: React.FC = ({ @@ -22,21 +23,25 @@ export const LimitContextCheckbox: React.FC = ({ checked, onCheckedChange, disabled = false, + scopeLabel, }) => ( -
+
onCheckedChange(value === true)} /> @@ -48,8 +53,8 @@ export const LimitContextCheckbox: React.FC = ({

- Agents will use 200K context window instead of the default 1M. Useful if you want to - save tokens and reduce costs. + Enable this to cap Anthropic runtimes at 200K tokens. Leave it off only when you want + the selected Anthropic model or runtime to use a longer context window when available.

diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index e2defea8..27a0374b 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -120,7 +120,8 @@ export function formatTeamModelSummary( * Computes the effective model string for team provisioning. * By default adds [1m] suffix for Opus 1M context. * When limitContext=true, returns base model without [1m] (200K context). - * Sonnet and Haiku default to standard context to avoid extra-usage-only variants. + * Standard Sonnet and Haiku selections stay standard context. Explicit Sonnet 1M selections keep + * their [1m] suffix unless the 200K limit is enabled. */ export function computeEffectiveTeamModel( selectedModel: string, diff --git a/src/renderer/components/team/members/LeadModelRow.test.tsx b/src/renderer/components/team/members/LeadModelRow.test.tsx index e9686163..6f90cec1 100644 --- a/src/renderer/components/team/members/LeadModelRow.test.tsx +++ b/src/renderer/components/team/members/LeadModelRow.test.tsx @@ -14,7 +14,12 @@ vi.mock('@renderer/components/team/dialogs/EffortLevelSelector', () => ({ })); vi.mock('@renderer/components/team/dialogs/LimitContextCheckbox', () => ({ - LimitContextCheckbox: () => React.createElement('div', null, 'limit-context'), + LimitContextCheckbox: ({ disabled, scopeLabel }: { disabled?: boolean; scopeLabel?: string }) => + React.createElement( + 'div', + null, + ['limit-context', scopeLabel, disabled ? 'disabled' : 'enabled'].filter(Boolean).join(' ') + ), })); vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({ @@ -55,8 +60,8 @@ vi.mock('@renderer/hooks/useTheme', () => ({ vi.mock('@renderer/utils/teamModelCatalog', () => ({ isAnthropicHaikuTeamModel: () => false, - isAnthropicSonnetTeamModel: (model: string | undefined) => - model === 'sonnet' || model === 'claude-sonnet-4-6' || model === 'sonnet[1m]', + isAnthropicSonnetOneMillionContextTeamModel: (model: string | undefined) => + model === 'sonnet[1m]' || model === 'claude-sonnet-4-6' || model === 'claude-sonnet-4-6[1m]', })); vi.mock('../../ui/button', () => ({ @@ -135,15 +140,16 @@ describe('LeadModelRow', () => { }); }); - it('warns that unchecked 200K limit can put Sonnet on Anthropic Extra Usage', () => { + it('warns that unchecked 200K limit can affect Sonnet 1M billing by plan/runtime', () => { const { host, root } = renderLeadModelRow({ providerId: 'anthropic', - model: 'sonnet', + model: 'sonnet[1m]', limitContext: false, }); - expect(host.textContent).toContain('Sonnet with 1M context can use Anthropic Extra Usage'); - expect(host.textContent).toContain('Requests over 200K input tokens'); + expect(host.textContent).toContain('Sonnet 1M context can affect billing'); + expect(host.textContent).toContain('standard API pricing'); + expect(host.textContent).toContain('Extra Usage for Sonnet 1M'); const docsLink = host.querySelector(`a[href="${ANTHROPIC_LONG_CONTEXT_PRICING_URL}"]`); expect(docsLink?.textContent).toContain('Anthropic pricing docs'); @@ -158,7 +164,7 @@ describe('LeadModelRow', () => { it('does not show the Sonnet Extra Usage warning when 200K limit is enabled', () => { const { host, root } = renderLeadModelRow({ providerId: 'anthropic', - model: 'sonnet', + model: 'sonnet[1m]', limitContext: true, }); @@ -168,4 +174,77 @@ describe('LeadModelRow', () => { root.unmount(); }); }); + + it('does not show the Sonnet Extra Usage warning for standard-context Sonnet', () => { + const { host, root } = renderLeadModelRow({ + providerId: 'anthropic', + model: 'sonnet', + limitContext: false, + }); + + expect(host.textContent).not.toContain('Anthropic Extra Usage'); + + act(() => { + root.unmount(); + }); + }); + + it('warns for native 1M Sonnet launch ids without an explicit suffix', () => { + const { host, root } = renderLeadModelRow({ + providerId: 'anthropic', + model: 'claude-sonnet-4-6', + limitContext: false, + }); + + expect(host.textContent).toContain('Sonnet 1M context can affect billing'); + + act(() => { + root.unmount(); + }); + }); + + it('shows the team-wide Anthropic context control when only teammates use Anthropic', () => { + const { host, root } = renderLeadModelRow({ + providerId: 'codex', + model: 'gpt-5.4', + showAnthropicContextLimit: true, + }); + + const modelButton = host.querySelector( + 'button[aria-label="codex provider, gpt-5.4"]' + ) as HTMLButtonElement; + act(() => { + modelButton.click(); + }); + + expect(host.textContent).toContain('limit-context Anthropic team-wide'); + expect(host.textContent).toContain( + 'The 200K context limit is team-wide for Anthropic runtimes' + ); + + act(() => { + root.unmount(); + }); + }); + + it('honors the explicit disabled state for the Anthropic context control', () => { + const { host, root } = renderLeadModelRow({ + providerId: 'anthropic', + model: 'haiku', + disableAnthropicContextLimit: true, + }); + + const modelButton = host.querySelector( + 'button[aria-label="anthropic provider, haiku"]' + ) as HTMLButtonElement; + act(() => { + modelButton.click(); + }); + + expect(host.textContent).toContain('limit-context disabled'); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/src/renderer/components/team/members/LeadModelRow.tsx b/src/renderer/components/team/members/LeadModelRow.tsx index b1dbb506..2696a584 100644 --- a/src/renderer/components/team/members/LeadModelRow.tsx +++ b/src/renderer/components/team/members/LeadModelRow.tsx @@ -1,6 +1,11 @@ import React, { useState } from 'react'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; +import { + AnthropicExtraUsageWarning, + ANTHROPIC_LONG_CONTEXT_PRICING_URL, + ANTHROPIC_SONNET_EXTRA_USAGE_WARNING, +} from '@renderer/components/team/dialogs/AnthropicExtraUsageWarning'; import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector'; import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox'; import { @@ -16,7 +21,7 @@ import { cn } from '@renderer/lib/utils'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; import { isAnthropicHaikuTeamModel, - isAnthropicSonnetTeamModel, + isAnthropicSonnetOneMillionContextTeamModel, } from '@renderer/utils/teamModelCatalog'; import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors'; import { AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react'; @@ -25,10 +30,7 @@ import { Button } from '../../ui/button'; import type { EffortLevel, TeamProviderId } from '@shared/types'; -export const ANTHROPIC_SONNET_EXTRA_USAGE_WARNING = - 'Sonnet with 1M context can use Anthropic Extra Usage. Requests over 200K input tokens are billed at premium long-context rates; enable Limit context to 200K tokens to avoid that billing path.'; -export const ANTHROPIC_LONG_CONTEXT_PRICING_URL = - 'https://platform.claude.com/docs/en/about-claude/pricing'; +export { ANTHROPIC_LONG_CONTEXT_PRICING_URL, ANTHROPIC_SONNET_EXTRA_USAGE_WARNING }; interface LeadModelRowProps { providerId: TeamProviderId; @@ -44,6 +46,8 @@ interface LeadModelRowProps { warningText?: string | null; disableGeminiOption?: boolean; modelIssueText?: string | null; + showAnthropicContextLimit?: boolean; + disableAnthropicContextLimit?: boolean; } export const LeadModelRow = ({ @@ -60,6 +64,8 @@ export const LeadModelRow = ({ warningText, disableGeminiOption = false, modelIssueText, + showAnthropicContextLimit = providerId === 'anthropic', + disableAnthropicContextLimit, }: LeadModelRowProps): React.JSX.Element => { const { isLight } = useTheme(); const [modelExpanded, setModelExpanded] = useState(false); @@ -70,11 +76,16 @@ export const LeadModelRow = ({ const modelButtonAriaLabel = `${getTeamProviderLabel(providerId)} provider, ${modelButtonLabel}`; const hasModelIssue = Boolean(modelIssueText); const showSonnetExtraUsageWarning = - providerId === 'anthropic' && !limitContext && isAnthropicSonnetTeamModel(model); + providerId === 'anthropic' && + !limitContext && + isAnthropicSonnetOneMillionContextTeamModel(model); const warningMessages = [warningText?.trim() || null].filter((message): message is string => Boolean(message) ); const hasWarnings = warningMessages.length > 0 || showSonnetExtraUsageWarning; + const contextLimitDisabled = + disableAnthropicContextLimit ?? + (providerId === 'anthropic' && isAnthropicHaikuTeamModel(model)); return (
(

{message}

))} - {showSonnetExtraUsageWarning ? ( -

- {ANTHROPIC_SONNET_EXTRA_USAGE_WARNING}{' '} - - Read Anthropic pricing docs - - . -

- ) : null} + {showSonnetExtraUsageWarning ? : null}
@@ -191,19 +189,22 @@ export const LeadModelRow = ({ model={model} limitContext={limitContext} /> - {providerId === 'anthropic' ? ( + {showAnthropicContextLimit ? ( ) : null}

- These settings control the team lead and act as the default runtime for teammates that - do not have their own override. + Lead runtime applies to teammates unless they set their own provider or model. + {showAnthropicContextLimit + ? ' The 200K context limit is team-wide for Anthropic runtimes in this launch, including custom Anthropic teammates.' + : null}

diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index b1b171b3..ec3cfa30 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -437,7 +437,7 @@ export const MemberCard = memo(function MemberCard({ ({ ProviderBrandLogo: () => React.createElement('span', { 'data-testid': 'provider-logo' }), })); @@ -182,4 +184,89 @@ describe('MemberDraftRow', () => { root.unmount(); }); }); + + it('explains that Anthropic context limit is team-wide for teammate overrides', () => { + const { host, root } = renderMemberDraftRow({ + limitContext: true, + }); + + const modelButton = host.querySelector( + 'button[aria-label="anthropic provider, opus"]' + ) as HTMLButtonElement; + act(() => { + modelButton.click(); + }); + + expect(host.textContent).toContain('Anthropic context is team-wide for this launch'); + expect(host.textContent).toContain('200K limit enabled'); + + act(() => { + root.unmount(); + }); + }); + + it('warns custom Anthropic Sonnet teammates about plan/runtime billing when 200K limit is off', () => { + const { host, root } = renderMemberDraftRow({ + member: { + id: 'member-1', + name: 'alice', + roleSelection: 'developer', + customRole: '', + providerId: 'anthropic', + model: 'sonnet[1m]', + }, + limitContext: false, + }); + + expect(host.textContent).toContain('Sonnet 1M context can affect billing'); + expect(host.textContent).toContain('Extra Usage for Sonnet 1M'); + const docsLink = host.querySelector(`a[href="${ANTHROPIC_LONG_CONTEXT_PRICING_URL}"]`); + + expect(docsLink?.textContent).toContain('Anthropic pricing docs'); + + act(() => { + root.unmount(); + }); + }); + + it('does not warn standard-context Anthropic Sonnet teammates about Extra Usage', () => { + const { host, root } = renderMemberDraftRow({ + member: createMemberDraft({ + id: 'member-1', + name: 'alice', + roleSelection: 'developer', + providerId: 'anthropic', + model: 'sonnet', + }), + limitContext: false, + }); + + expect(host.textContent).not.toContain('Anthropic Extra Usage'); + + act(() => { + root.unmount(); + }); + }); + + it('does not duplicate the Sonnet Extra Usage warning for effort-only inherited teammates', () => { + const { host, root } = renderMemberDraftRow({ + member: createMemberDraft({ + id: 'member-1', + name: 'alice', + roleSelection: 'developer', + providerId: undefined, + model: '', + effort: 'max', + }), + inheritedProviderId: 'anthropic', + inheritedModel: 'sonnet[1m]', + limitContext: false, + }); + + expect(host.textContent).not.toContain('Anthropic Extra Usage'); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index d6e5fe9e..18860a8c 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; +import { AnthropicExtraUsageWarning } from '@renderer/components/team/dialogs/AnthropicExtraUsageWarning'; import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector'; import { formatTeamModelSummary, @@ -21,6 +22,7 @@ import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { useTheme } from '@renderer/hooks/useTheme'; import { cn } from '@renderer/lib/utils'; import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils'; +import { isAnthropicSonnetOneMillionContextTeamModel } from '@renderer/utils/teamModelCatalog'; import { getMemberColorByName } from '@shared/constants/memberColors'; import { AlertTriangle, @@ -225,6 +227,20 @@ export const MemberDraftRow = ({ const worktreeIsolationDisabled = isRemoved || Boolean(worktreeIsolationDisabledReason && member.isolation !== 'worktree'); const hasModelIssue = Boolean(modelIssueText); + const hasCustomProviderOrModel = + !forceInheritedModelSettings && Boolean(member.providerId || member.model?.trim()); + const showSonnetExtraUsageWarning = + effectiveProviderId === 'anthropic' && + !limitContext && + hasCustomProviderOrModel && + isAnthropicSonnetOneMillionContextTeamModel(effectiveModel); + const warningMessages = [warningText?.trim() || null].filter((message): message is string => + Boolean(message) + ); + const hasWarnings = warningMessages.length > 0 || showSonnetExtraUsageWarning; + const anthropicContextModeLabel = limitContext + ? '200K limit enabled' + : '1M-capable context allowed'; const runtimeSummary = formatTeamModelSummary( effectiveProviderId, effectiveModel?.trim() ?? '', @@ -413,11 +429,16 @@ export const MemberDraftRow = ({
Removed
) : null}
- {!isRemoved && warningText ? ( + {!isRemoved && hasWarnings ? (
-

{warningText}

+
+ {warningMessages.map((message) => ( +

{message}

+ ))} + {showSonnetExtraUsageWarning ? : null} +
) : null} @@ -518,6 +539,15 @@ export const MemberDraftRow = ({ model={effectiveModel} limitContext={limitContext} /> + {effectiveProviderId === 'anthropic' ? ( +
+ +

+ Anthropic context is team-wide for this launch: {anthropicContextModeLabel}. Use + the lead runtime panel's Limit context checkbox to change it. +

+
+ ) : null} {lockProviderModel && (

{modelLockReason ?? diff --git a/src/renderer/components/team/members/MemberStatsTab.tsx b/src/renderer/components/team/members/MemberStatsTab.tsx index 38affae4..97a93e49 100644 --- a/src/renderer/components/team/members/MemberStatsTab.tsx +++ b/src/renderer/components/team/members/MemberStatsTab.tsx @@ -203,6 +203,8 @@ const ToolUsageBars = ({ }; const TRAILING_PUNCT = ';.,'; +const INVALID_PATH_NAMES = new Set(['null', 'undefined', 'none']); +const WINDOWS_NULL_DEVICE_RE = /^[a-z]:\/nul$/; function isInvalidPath(path: string): boolean { let trimmed = path.trim(); @@ -211,7 +213,15 @@ function isInvalidPath(path: string): boolean { end--; } trimmed = trimmed.slice(0, end); - return !trimmed || trimmed === 'null' || trimmed === 'undefined' || trimmed === 'None'; + const normalized = trimmed.replace(/\\/g, '/').toLowerCase(); + return ( + !trimmed || + INVALID_PATH_NAMES.has(normalized) || + normalized === '/dev/null' || + normalized === '//./nul' || + normalized === '//?/nul' || + WINDOWS_NULL_DEVICE_RE.test(normalized) + ); } const FilesTouchedSection = ({ diff --git a/src/renderer/components/team/members/TeamRosterEditorSection.test.tsx b/src/renderer/components/team/members/TeamRosterEditorSection.test.tsx new file mode 100644 index 00000000..3bbd239f --- /dev/null +++ b/src/renderer/components/team/members/TeamRosterEditorSection.test.tsx @@ -0,0 +1,244 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { MemberDraft } from './membersEditorTypes'; + +const leadRowMockState = vi.hoisted(() => ({ + lastLeadProps: null as { + showAnthropicContextLimit?: boolean; + disableAnthropicContextLimit?: boolean; + } | null, +})); + +vi.mock('./LeadModelRow', () => ({ + LeadModelRow: (props: { + showAnthropicContextLimit?: boolean; + disableAnthropicContextLimit?: boolean; + }) => { + leadRowMockState.lastLeadProps = props; + return React.createElement( + 'div', + { 'data-testid': 'lead-model-row' }, + String(props.showAnthropicContextLimit) + ); + }, +})); + +vi.mock('./MembersEditorSection', () => ({ + MembersEditorSection: ({ headerExtra }: { headerExtra?: React.ReactNode }) => + React.createElement('div', null, headerExtra), +})); + +import { TeamRosterEditorSection } from './TeamRosterEditorSection'; + +function renderTeamRosterEditorSection(overrides: { + providerId?: React.ComponentProps['providerId']; + model?: string; + members?: MemberDraft[]; + syncModelsWithTeammates?: boolean; + forceInheritedModelSettings?: boolean; + hideMembersContent?: boolean; +}): { host: HTMLDivElement; root: ReturnType } { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + act(() => { + root.render( + React.createElement(TeamRosterEditorSection, { + members: overrides.members ?? [], + onMembersChange: () => undefined, + inheritedProviderId: overrides.providerId ?? 'codex', + inheritedModel: '', + providerId: overrides.providerId ?? 'codex', + model: overrides.model ?? '', + limitContext: false, + onProviderChange: () => undefined, + onModelChange: () => undefined, + onEffortChange: () => undefined, + onLimitContextChange: () => undefined, + syncModelsWithTeammates: overrides.syncModelsWithTeammates ?? false, + onSyncModelsWithTeammatesChange: () => undefined, + forceInheritedModelSettings: overrides.forceInheritedModelSettings, + hideMembersContent: overrides.hideMembersContent, + }) + ); + }); + + return { host, root }; +} + +describe('TeamRosterEditorSection', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + leadRowMockState.lastLeadProps = null; + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('shows the Anthropic context control for explicit Anthropic teammates under a non-Anthropic lead', () => { + const { root } = renderTeamRosterEditorSection({ + providerId: 'codex', + members: [ + { + id: 'member-1', + name: 'alice', + roleSelection: 'developer', + customRole: '', + providerId: 'anthropic', + model: 'sonnet', + }, + ], + }); + + expect(leadRowMockState.lastLeadProps?.showAnthropicContextLimit).toBe(true); + + act(() => { + root.unmount(); + }); + }); + + it('hides the Anthropic context control when teammates are synced to a non-Anthropic lead', () => { + const { root } = renderTeamRosterEditorSection({ + providerId: 'codex', + syncModelsWithTeammates: true, + members: [ + { + id: 'member-1', + name: 'alice', + roleSelection: 'developer', + customRole: '', + providerId: 'anthropic', + model: 'sonnet', + }, + ], + }); + + expect(leadRowMockState.lastLeadProps?.showAnthropicContextLimit).toBe(false); + + act(() => { + root.unmount(); + }); + }); + + it('ignores stale Anthropic teammate drafts when member content is hidden', () => { + const { root } = renderTeamRosterEditorSection({ + providerId: 'codex', + hideMembersContent: true, + members: [ + { + id: 'member-1', + name: 'alice', + roleSelection: 'developer', + customRole: '', + providerId: 'anthropic', + model: 'sonnet', + }, + ], + }); + + expect(leadRowMockState.lastLeadProps?.showAnthropicContextLimit).toBe(false); + + act(() => { + root.unmount(); + }); + }); + + it('keeps the team-wide context control enabled for Anthropic teammate overrides under a Haiku lead', () => { + const { root } = renderTeamRosterEditorSection({ + providerId: 'anthropic', + model: 'haiku', + members: [ + { + id: 'member-1', + name: 'alice', + roleSelection: 'developer', + customRole: '', + providerId: 'anthropic', + model: 'opus', + }, + ], + }); + + expect(leadRowMockState.lastLeadProps?.showAnthropicContextLimit).toBe(true); + expect(leadRowMockState.lastLeadProps?.disableAnthropicContextLimit).toBe(false); + + act(() => { + root.unmount(); + }); + }); + + it('keeps the team-wide context control enabled for inherited Anthropic model overrides under a Haiku lead', () => { + const { root } = renderTeamRosterEditorSection({ + providerId: 'anthropic', + model: 'haiku', + members: [ + { + id: 'member-1', + name: 'alice', + roleSelection: 'developer', + customRole: '', + model: 'opus', + }, + ], + }); + + expect(leadRowMockState.lastLeadProps?.showAnthropicContextLimit).toBe(true); + expect(leadRowMockState.lastLeadProps?.disableAnthropicContextLimit).toBe(false); + + act(() => { + root.unmount(); + }); + }); + + it('keeps the team-wide context control enabled for Anthropic provider defaults under a Haiku lead', () => { + const { root } = renderTeamRosterEditorSection({ + providerId: 'anthropic', + model: 'haiku', + members: [ + { + id: 'member-1', + name: 'alice', + roleSelection: 'developer', + customRole: '', + providerId: 'anthropic', + model: '', + }, + ], + }); + + expect(leadRowMockState.lastLeadProps?.showAnthropicContextLimit).toBe(true); + expect(leadRowMockState.lastLeadProps?.disableAnthropicContextLimit).toBe(false); + + act(() => { + root.unmount(); + }); + }); + + it('keeps the team-wide context control disabled when teammates only inherit a Haiku lead', () => { + const { root } = renderTeamRosterEditorSection({ + providerId: 'anthropic', + model: 'haiku', + members: [ + { + id: 'member-1', + name: 'alice', + roleSelection: 'developer', + customRole: '', + model: '', + }, + ], + }); + + expect(leadRowMockState.lastLeadProps?.showAnthropicContextLimit).toBe(true); + expect(leadRowMockState.lastLeadProps?.disableAnthropicContextLimit).toBe(true); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/src/renderer/components/team/members/TeamRosterEditorSection.tsx b/src/renderer/components/team/members/TeamRosterEditorSection.tsx index 1ce5b61a..3232cc1f 100644 --- a/src/renderer/components/team/members/TeamRosterEditorSection.tsx +++ b/src/renderer/components/team/members/TeamRosterEditorSection.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import { isAnthropicHaikuTeamModel } from '@renderer/utils/teamModelCatalog'; + import { LeadModelRow } from './LeadModelRow'; import { MembersEditorSection } from './MembersEditorSection'; @@ -98,6 +100,33 @@ export const TeamRosterEditorSection = ({ worktreeIsolationDisabledReason, onTeammateWorktreeDefaultChange, }: TeamRosterEditorSectionProps): React.JSX.Element => { + const canUseCustomMemberRuntimes = + !hideMembersContent && !forceInheritedModelSettings && !syncModelsWithTeammates; + const activeRuntimeMembers = canUseCustomMemberRuntimes + ? members.filter((member) => !member.removedAt) + : []; + const hasCustomAnthropicRuntime = activeRuntimeMembers.some( + (member) => member.providerId === 'anthropic' + ); + const hasMemberAnthropicRuntimeWithContextChoice = activeRuntimeMembers.some((member) => { + if (member.providerId === 'anthropic') { + const memberModel = member.model?.trim(); + return !memberModel || !isAnthropicHaikuTeamModel(memberModel); + } + + if (member.providerId == null && providerId === 'anthropic') { + const memberModel = member.model?.trim(); + return Boolean(memberModel && !isAnthropicHaikuTeamModel(memberModel)); + } + + return false; + }); + const hasAnthropicRuntime = providerId === 'anthropic' || hasCustomAnthropicRuntime; + const disableAnthropicContextLimit = + providerId === 'anthropic' && + isAnthropicHaikuTeamModel(model) && + !hasMemberAnthropicRuntimeWithContextChoice; + return ( {headerBottom}

diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 4d0ad8ce..b46f3a4a 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -589,7 +589,6 @@ export const MessagesPanel = memo(function MessagesPanel({ const activityTimelineMessages = useMemo(() => { return filterTeamMessages(effectiveMessages, { includeAutomationEvents: true, - includePassiveIdlePeerSummariesWhenNoiseHidden: true, leadNames, timeWindow, filter: messagesFilter, diff --git a/src/renderer/components/ui/ActivePulseIndicator.tsx b/src/renderer/components/ui/ActivePulseIndicator.tsx new file mode 100644 index 00000000..b828653b --- /dev/null +++ b/src/renderer/components/ui/ActivePulseIndicator.tsx @@ -0,0 +1,16 @@ +import { cn } from '@renderer/lib/utils'; + +import type React from 'react'; + +interface ActivePulseIndicatorProps { + className?: string; +} + +export const ActivePulseIndicator = ({ + className, +}: Readonly): React.JSX.Element => ( +