diff --git a/docs/team-management/member-log-stream-v2-implementation-plan.md b/docs/team-management/member-log-stream-v2-implementation-plan.md new file mode 100644 index 00000000..ef9b460f --- /dev/null +++ b/docs/team-management/member-log-stream-v2-implementation-plan.md @@ -0,0 +1,1231 @@ +# Member Log Stream V2 Implementation Plan + +## Goal + +Сделать в попапе участника новый формат логов, визуально и поведенчески близкий к `Task Log Stream`, но со scope "все доступные логи выбранного участника", а не "логи конкретной задачи". + +Ключевой продуктовый ответ: + +- Показываем логи только выбранного участника, а не всех агентов команды. +- Если у выбранного участника есть безопасно доступные Claude/OpenCode/Codex источники, они могут быть показаны в одном stream. +- Вариант 2 не обещает абсолютно полный provider-wide audit log для всех runtime. Для этого нужен вариант 3. +- Старый `MemberLogsTab` не удаляем на первом шаге. Он остается fallback, чтобы снизить риск регрессий. + +## Decision + +Выбран вариант 2: + +🎯 8.5 🛡️ 8.5 🧠 6 +Оценка изменений: примерно 1500-2300 строк вместе с тестами, если делать canonical feature slice, аккуратную source-port архитектуру, dedupe cumulative subagent snapshots, вынести OpenCode projection mapper, покрыть fallback boundary, добавить member tracking activation, OpenCode/renderer in-flight protection и аккуратно отделить provider-neutral message hygiene от board/task sanitization. + +Почему не frontend-only: + +- Старый `MemberLogsTab` работает с session summaries и раскрытием `MemberExecutionLog`. +- Новый stream требует готовый normalized response: participants, segments, chunks, source metadata. +- Делать это в renderer означало бы тащить parsing/composition туда, где уже нет нужных main-process primitives и где выше риск UI freeze. + +Почему не вариант 3: + +- Полный member-wide Codex native stream требует отдельного trace index/read path по owner/member без taskId. +- Полный OpenCode multi-lane stream требует либо безопасного list-all-lanes contract, либо явного lane resolution в desktop. +- Это полезно, но заметно повышает риск смешать чужие логи или показать неполную картину как полную. + +## Options Considered + +| Вариант | Оценка | Пример LOC | Что делает | Главный риск | +| --- | --- | ---: | --- | --- | +| 1. Frontend-only reuse старых member logs | 🎯 6 🛡️ 5 🧠 3 | 150-250 | Renderer берет `getMemberLogs` и пытается отрисовать похоже на stream | Старый формат данных не равен stream, легко получить UI-псевдопаритет без реального качества | +| 2. Canonical feature slice плюс source ports | 🎯 8.5 🛡️ 9 🧠 7 | 1500-2300 | `src/features/member-log-stream` owns contracts/core/source ports/renderer, app shell only integrates it | Нужно держать provider sources маленькими, dedupe cumulative logs, жестко ограничить response и не мигрировать task logs в этом PR | +| 3. Maximum provider/runtime log stream | 🎯 6 🛡️ 6 🧠 9 | 1000-1800+ | Отдельный provider-wide member log index для Claude/OpenCode/Codex | Высокий риск ошибок в Codex/OpenCode attribution и долгий rollout | + +## Current Code Facts + +Точки интеграции в `claude_team`: + +- `src/renderer/components/team/members/MemberDetailDialog.tsx` сейчас показывает ``. +- `src/renderer/components/team/members/MemberLogsTab.tsx` вызывает `api.teams.getMemberLogs(...)` и рендерит старые session logs. +- `src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx` вызывает `api.teams.getTaskLogStream(teamName, taskId)` и рендерит новый stream через `MemberExecutionLog`. +- `TaskLogStreamSection` only subscribes to `onTeamChange`; `TaskLogsPanel` separately enables `setTaskLogStreamTracking`. Member popup needs its own tracking activation. +- `TeamLogSourceTracker` tracks activity only while at least one consumer is active. Its consumer counts are per team and per consumer name, so member stream should add a semantic `member_log_stream` consumer instead of starting a separate watcher layer. +- `TaskLogStreamSection.normalizeResponse()` preserves only known task response fields, so a reused copy can drop member fields like `coverage`, `warnings`, `metadata`, `truncated`, `generatedAt` and `segment.source`. +- `BoardTaskLogParticipant` is actor identity, not source identity. Provider/session/lane labels for member logs must stay in `MemberLogStreamSegment.source`, not in fake provider-specific participants. +- `src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts` возвращает `BoardTaskLogStreamResponse`. +- `src/shared/types/team.ts` уже содержит `BoardTaskLogParticipant`, `BoardTaskLogSegment`, `BoardTaskLogStreamResponse`. +- `src/shared/types/team.ts` уже содержит `TeamMemberSnapshot.providerBackendId`, `selectedFastMode`, `resolvedFastMode`, `laneId`, `laneKind`, `laneOwnerProviderId`, но `ResolvedTeamMember` пока не объявляет эти поля. +- `src/renderer/store/slices/teamSlice.ts` в `buildResolvedMember()` возвращает `{ ...snapshot, status, messageCount, lastActiveAt }`, поэтому runtime object уже несет эти поля. Это почти type-contract change, а не новая data mapping ветка. +- `src/main/services/team/TeamMemberLogsFinder.ts` уже умеет искать member logs и содержит attribution precedence. +- `src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser.ts` умеет strict-parse JSONL transcript files с cache и concurrency. +- `BoardTaskExactLogStrictParser.parseFiles()` вызывает `cache.retainOnly(uniquePaths)`, а underlying `BoardTaskActivityParseCache.retainOnly()` удаляет не только parsed cache, но и `inFlight` entries вне текущего набора файлов. Поэтому parser instance нельзя шарить между task stream и member stream без изменения cache ownership. +- `src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder.ts` строит `EnhancedChunk[]`, которые уже понимает `MemberExecutionLog`. +- `src/main/services/runtime/ClaudeMultimodelBridgeService.ts` уже имеет `getOpenCodeTranscript(...)`, но пока без `laneId`. +- `CodexNativeTraceReader.readTaskRuns()` is task-keyed and scans `processed//` plus optional `incoming//`, then caps recent candidates before parsing. It has no member-wide owner index. +- `CodexNativeTraceProjector` only projects native tool events (`toolSource === 'native'`), not full Codex chat/runtime history. +- `src/main/ipc/teams.ts`, `src/preload/index.ts`, `src/preload/constants/ipcChannels.ts` уже имеют аналогичные handlers/channels для `getTaskLogStream`. +- `initializeTeamHandlers()` сейчас принимает много optional dependencies позиционно, и `test/main/ipc/teams.test.ts` передает их в этом порядке. Новую feature нельзя встраивать туда как очередной owned team service. +- `src/renderer/api/httpClient.ts` имеет browser-mode stub для `getTaskLogStream`; для member stream нужен такой же safe fallback, иначе можно сломать browser compile/test path. +- `findRecentMemberLogFileRefsByMember()` сейчас принимает третий аргумент только как numeric/null `mtimeSinceMs`, не object options; не передает `forceRefresh`; добавляет lead transcript до mtime filter; и возвращает refs без `kind`/`sizeBytes`/`messageCount`. +- В OpenCode сейчас есть два mapper-like `toParsedMessage()`: richer private mapper в `OpenCodeTaskLogStreamSource` и более lossy mapper в `OpenCodeTaskStallEvidenceSource`. Канонический источник для shared mapper - task stream source, не stall monitor. + +Дополнительный deep research зафиксирован в: + +- `docs/team-management/member-log-stream-v2-research-addendum.md`. + +Самые важные уточнения оттуда: + +- для Claude/member transcript использовать `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()`, не `findMemberLogPaths()`; +- для OpenCode передавать `laneId` из member snapshot в `getMemberLogStream` options; +- `ResolvedTeamMember` type нужно явно расширить runtime/lane fields: `providerBackendId`, `selectedFastMode`, `resolvedFastMode`, `laneId`, `laneKind`, `laneOwnerProviderId`; +- Codex native в варианте 2 должен быть skipped или partial native-tool trace, но не "полный Codex log"; +- member stream source stack должен иметь отдельный `BoardTaskExactLogStrictParser`, потому что `parseFiles().retainOnly()` вычищает cache и `inFlight` parse entries вне текущего набора файлов. +- архитектурно лучше выбрать canonical feature slice: `src/features/member-log-stream` owns contracts/core/application/source ports/renderer, while existing team surfaces remain thin app-shell integration points; +- response нужно ограничивать на backend: `maxTranscriptFiles=40`, `maxSegments=30`, `maxChunks=250`, `maxSourceMessages=1200`, `maxMessagesPerSegment=300`, `openCodeMessageLimit=400`; +- `laneId` нельзя валидировать как member name, нужен отдельный optional validator с поддержкой `secondary:opencode:`. +- `findRecentMemberLogFileRefsByMember()` dedupe только by `filePath`, а cumulative subagent snapshots могут дублировать turn history; +- лучше расширить `MemberLogFileRef` optional `kind`/`sizeBytes` и dedupe subagent refs by `memberName + sessionId` до parse; +- OpenCode projection conversion сейчас private внутри task-specific source, поэтому нужен маленький shared mapper вместо копипаста. +- shared OpenCode mapper нужно извлекать из `OpenCodeTaskLogStreamSource`; mapper в stall monitor не подходит как источник, потому что он теряет часть content/tool-result семантики. +- `maxChunks` недостаточен как единственный budget, потому что один AI chunk может содержать сотни tool calls/messages; +- member popup live refresh должен слушать same-team `log-source-change` и `task-log-change`, но не `tool-activity`. +- `setMemberLogStreamTracking()` should map to a new `TeamLogSourceTracker` consumer `member_log_stream`. Reusing the task-named API would work technically but creates semantic coupling. +- Codex native member stream remains skipped in first PR because current trace reader is task-first and current projector is native-tool-only. +- новый IPC handler нужно регистрировать feature-owned способом и не сдвигать existing positional dependencies в `initializeTeamHandlers()`; +- browser-mode API fallback должен возвращать полный empty `MemberLogStreamResponse`, а не бросать ошибку. + +Точки интеграции в orchestrator: + +- `runtime transcript` уже принимает `--team`, `--member`, `--lane`, `--projection-only`, `--limit`. +- `resolveOpenCodeSessionRecordForCli()` специально требует `--lane`, если есть несколько session records для одного `team/member`. +- Поэтому orchestrator менять кодом не нужно для варианта 2, если desktop начнет передавать `laneId` там, где он надежно известен. + +## Target User Experience + +В попапе участника раздел `Logs` должен выглядеть как новый stream: + +- header остается понятным для member popup: `Logs`, не `Task Log Stream`; +- визуальный формат chunks, tool calls, message blocks и spacing должен быть как в `Task Log Stream`; +- описание source не должно говорить "Task-scoped"; +- при пустом stream показываем спокойный empty state; +- при ошибке нового stream показываем fallback-кнопку или inline fallback на старый `MemberLogsTab`; +- для одного участника chips обычно не нужны; +- если у участника несколько sessions/lanes, segment headers должны оставаться видимыми, чтобы не превратить разные сессии в один непрозрачный блок. + +⚠️ Важное поведение: stream не должен показывать "логи всех агентов вместе". Он показывает только выбранного member. Если внутри выбранного member есть разные provider/runtime источники, они могут быть объединены только после безопасной attribution/resolution. + +## Target Architecture + +```mermaid +flowchart LR + A["MemberDetailDialog"] --> B["MemberLogStreamSection"] + B --> C["member-log-stream renderer hook"] + C --> D["feature preload/API bridge"] + D --> E["feature IPC getMemberLogStream"] + E --> S["GetMemberLogStreamUseCase"] + S --> F["MemberLogStreamBudget"] + S --> G["ClaudeMemberTranscriptStreamSource"] + S --> H["OpenCodeMemberRuntimeStreamSource"] + S --> I["CodexNativeMemberTraceStreamSource"] + G --> J["TeamMemberLogsFinder recent refs"] + G --> K["Ref dedupe by member/session/kind"] + G --> L["Dedicated BoardTaskExactLogStrictParser"] + G --> M["BoardTaskExactLogChunkBuilder"] + H --> N["ClaudeMultimodelBridgeService"] + H --> O["OpenCodeRuntimeProjectionMapper"] + N --> P["agent_teams_orchestrator runtime transcript --lane"] + I --> R["skipped coverage in first PR"] + S --> Q["MemberLogStreamResponse"] + Q --> B +``` + +Main-process feature adapters own source discovery, parsing, chunking, cache, provider fallback, response budget and truncation semantics. Renderer feature code owns loading state, visual rendering, source text and fallback UI. + +🧭 Architecture decision: create a canonical `src/features/member-log-stream` slice in the first implementation. This feature spans process boundaries, owns merge/budget/provider policy, needs transport wiring and has a provider roadmap, so it matches `docs/FEATURE_ARCHITECTURE_STANDARD.md`. + +Compatibility rule: + +- do not migrate existing task log stream or legacy `MemberLogsTab` in this PR; +- app shell may keep small compatibility methods in `api.teams` if that is the least disruptive integration point, but those methods should delegate to feature-owned contracts/channels/use case; +- outside callers import only feature public entrypoints: `@features/member-log-stream/contracts`, `@features/member-log-stream/main`, `@features/member-log-stream/preload`, `@features/member-log-stream/renderer`; +- provider logic must live behind source ports, not in IPC handlers or React components. + +Expected feature layout: + +```text +src/features/member-log-stream/ + contracts/ + api.ts + channels.ts + dto.ts + index.ts + normalize.ts + core/ + domain/ + policies/ + models/ + application/ + ports/ + use-cases/ + main/ + composition/ + adapters/ + input/ipc/ + output/sources/ + output/presenters/ + infrastructure/ + preload/ + renderer/ + adapters/ + hooks/ + ui/ + utils/ +``` + +## Architecture Compliance Checklist + +This feature must follow `docs/FEATURE_ARCHITECTURE_STANDARD.md` exactly enough to be reviewable by structure, imports and tests. + +Clean Architecture mapping: + +- `contracts/`: `MemberLogStreamResponse`, request options, API fragment, IPC channels and normalize helpers only. +- `core/domain/`: pure policies for merge order, dedupe keys, source coverage, truncation decisions and render-safe source metadata. +- `core/application/`: `GetMemberLogStreamUseCase`, `SetMemberLogStreamTrackingUseCase`, source ports, cache/clock/logger ports and budget models. +- `main/adapters/input/ipc/`: validate IPC input, reject unknown option keys, call use cases and normalize IPC output. +- `main/adapters/output/sources/`: Claude/OpenCode/Codex source adapters that implement `MemberLogStreamSource`. +- `main/infrastructure/`: TTL/in-flight cache helpers, parser wrappers, bridge/runtime clients and filesystem-specific helpers. +- `main/composition/`: `createMemberLogStreamFeature(...)` wires dependencies and exposes a small facade. +- `preload/`: `createMemberLogStreamBridge(...)` only invokes feature channels and depends on contracts. +- `renderer/hooks/`: loading, tracking, team-change subscription, reload coalescing and fallback state. +- `renderer/adapters/`: DTO-to-view mapping and small renderer-only normalization. +- `renderer/ui/`: presentational components only, no store/API/Electron access. + +Dependency direction: + +- core/domain imports no process, framework, adapter or infrastructure code. +- core/application depends on domain models and ports, not on main/preload/renderer. +- main adapters depend inward on core/application and contracts. +- renderer UI depends on contracts/view models and local props only. +- app shell imports only public feature entrypoints. +- `src/main/ipc/teams.ts`, `src/preload/index.ts` and `src/renderer/components/team/members/MemberDetailDialog.tsx` may integrate the feature, but must not own member stream policy. + +SOLID guardrails: + +- SRP: one class/module has one reason to change. Provider IO changes source adapters; merge/budget changes core policies/use case; transport changes IPC/preload; visual changes renderer UI. +- OCP: adding `CodexPartialMemberTraceSource` later means adding a new source adapter and registering it, not changing renderer or the use case switch logic. +- LSP: every `MemberLogStreamSource` must return the same result contract with `included`, `partial` or `skipped`; no adapter should throw for expected provider absence. +- ISP: keep narrow ports: source loading, tracking activation, clock/logger/cache. Do not pass `TeamDataService` or renderer member objects through core. +- DIP: use cases depend on source/cache/logger ports. Concrete `TeamMemberLogsFinder`, `ClaudeMultimodelBridgeService`, parsers and trace readers stay in main adapters/infrastructure. + +DRY guardrails: + +- Reuse the stream renderer through a public `@features/member-log-stream/renderer` entrypoint instead of copying `TaskLogStreamSection`. +- Extract the OpenCode projection mapper from `OpenCodeTaskLogStreamSource` once and reuse it for task/member streams. +- Extract provider-neutral message hygiene helpers once, but keep board/task JSON cleanup scoped to task logs. +- Do not duplicate DTOs in `src/shared/types/team.ts` and feature contracts. Feature contracts are the owner. +- Do not duplicate feature API methods across `api.teams` and feature bridge unless the `api.teams` method is a thin compatibility delegate. + +Lint guardrails: + +- The repo already has generic feature boundary rules in `eslint.config.js` for `src/features/*`. +- If implementation needs stricter member-log-stream-specific messages, mirror the `recent-projects` feature-specific guard rails. +- Targeted verification should include `pnpm exec eslint src/features/member-log-stream --cache --cache-location .eslintcache --cache-strategy content` plus full `pnpm lint` before merge. + +### Project Standard Traceability Matrix + +This matrix is the implementation checklist for `CLAUDE.md`, `docs/FEATURE_ARCHITECTURE_STANDARD.md`, existing `eslint.config.js` feature rules and the `src/features/recent-projects` reference slice. + +| Standard requirement | Member-log-stream implementation rule | Verification | +| --- | --- | --- | +| New medium/large cross-process features live in `src/features/` | Create `src/features/member-log-stream` and keep legacy team files as integration points only | `rg "@features/member-log-stream" src/main src/preload src/renderer` shows public entrypoint imports only | +| `contracts/` owns cross-process API | DTOs, API fragment types, channel constants and normalize helpers live only in feature contracts | No duplicated member stream DTOs in `src/shared/types/team.ts`; contracts tests cover fallback shape | +| `core/domain/` is pure | Merge order, dedupe keys, coverage state, budget/truncation policy and source metadata are pure functions/models | Domain tests plus `feature-core-domain-guards` | +| `core/application/` owns use cases and ports | `GetMemberLogStreamUseCase`, `SetMemberLogStreamTrackingUseCase`, source/cache/clock/logger/tracking ports | Use-case tests with fake ports; no Electron, Fastify, React, Zustand or child process imports | +| `main/adapters/input/` owns transport translation | IPC adapter validates unknown option keys, exact optional `laneId`, response normalization and fail-soft errors | Feature IPC tests register/remove feature channels without `initializeTeamHandlers()` ownership | +| `main/adapters/output/` owns source adapters | Claude/OpenCode/Codex adapters implement `MemberLogStreamSource` and translate runtime data to core models | Adapter mapping tests for Claude refs, OpenCode projection and Codex skipped coverage | +| `main/infrastructure/` owns concrete IO | Parser wrappers, runtime bridge clients, TTL/in-flight cache and filesystem helpers stay outside core | Source adapters are thin around infrastructure helpers | +| `main/composition/` is the composition root | `createMemberLogStreamFeature(...)` wires sources, use cases, cache, logger and tracker facade | App main imports `@features/member-log-stream/main`, not feature internals | +| `preload/` is a thin bridge | `createMemberLogStreamBridge()` invokes feature channels and depends on contracts | Preload does not import main composition or renderer code | +| `renderer/hooks/` orchestrate interaction | Loading, tracking activation, reload coalescing, fallback and team-change cleanup live in hooks | Hook tests cover mount/unmount, team changes, reload coalescing and browser fallback | +| `renderer/ui/` is presentational | `ExecutionLogStreamView` and section UI receive props/view models only | `feature-renderer-ui-guards`; no imports from `@renderer/api`, store, Electron or main | +| Public entrypoints only | External code imports only `@features/member-log-stream/contracts`, `/main`, `/preload`, `/renderer` | `feature-public-entrypoints-only` and targeted eslint | +| Reference feature shape matches `recent-projects` | Public indexes mirror the reference pattern: contracts/main/preload/renderer expose only supported surface | Review tree against `src/features/recent-projects` before implementation PR | + +## Public API And Types + +Add feature-owned contracts in `src/features/member-log-stream/contracts`, not by mutating task stream semantics: + +```ts +export type MemberLogStreamProvider = 'claude_transcript' | 'opencode_runtime' | 'codex_native_trace'; +export type MemberLogStreamSource = + | 'member_transcript' + | 'member_mixed_runtime' + | 'member_runtime_only' + | 'member_empty'; + +export interface MemberLogStreamCoverage { + provider: MemberLogStreamProvider; + status: 'included' | 'partial' | 'skipped'; + reason?: string; +} + +export interface MemberLogStreamWarning { + code: + | 'opencode_ambiguous_lane' + | 'opencode_missing_runtime_session' + | 'opencode_runtime_unavailable' + | 'opencode_runtime_timeout' + | 'codex_member_wide_not_supported' + | 'large_log_window_limited' + | 'segment_message_window_limited' + | 'message_content_limited' + | 'unreadable_transcript_file'; + message: string; +} + +export interface MemberLogStreamMetadata { + scannedTranscriptFileCount: number; + includedTranscriptFileCount: number; + droppedSegmentCount: number; + droppedChunkCount: number; + droppedMessageCount: number; +} + +export interface MemberLogStreamSegmentSource { + provider: MemberLogStreamProvider; + label: string; + sessionId?: string; + laneId?: string; + messageCount?: number; + truncated?: boolean; +} + +export interface MemberLogStreamSegment extends BoardTaskLogSegment { + source: MemberLogStreamSegmentSource; +} + +export interface MemberLogStreamResponse { + participants: BoardTaskLogParticipant[]; + defaultFilter: 'all' | string; + segments: MemberLogStreamSegment[]; + source: MemberLogStreamSource; + coverage: MemberLogStreamCoverage[]; + warnings: MemberLogStreamWarning[]; + truncated: boolean; + generatedAt: string; + metadata: MemberLogStreamMetadata; +} +``` + +Important TS boundary: + +- Do not make `MemberLogStreamResponse extends BoardTaskLogStreamResponse`. +- `BoardTaskLogStreamResponse.source` is a task-only union: `transcript`, OpenCode task fallback values and Codex task trace values. +- Reusing that response type would either reject member-specific source values or pollute task stream semantics with member values. +- Share the low-level render units instead: `BoardTaskLogParticipant`, `BoardTaskLogSegment` and `EnhancedChunk`. +- Prefer feature contracts for `MemberLogStreamResponse`; export them through `@features/member-log-stream/contracts`. +- If `src/shared/types/api.ts` needs a temporary compatibility reference for `api.teams`, import the feature contract type there or use a narrow adapter type. Do not duplicate DTO definitions in `src/shared/types/team.ts` and feature contracts. + +Internal budget: + +```ts +interface MemberLogStreamBudget { + maxTranscriptFiles: number; + maxSegments: number; + maxChunks: number; + maxSourceMessages: number; + maxMessagesPerSegment: number; + maxTotalContentChars: number; + maxMessageContentChars: number; + maxToolResultContentChars: number; + openCodeMessageLimit: number; + openCodeTimeoutMs: number; +} +``` + +Extend finder metadata in a backward-compatible way: + +```ts +interface MemberLogFileRef { + memberName: string; + sessionId: string; + filePath: string; + mtimeMs: number; + sizeBytes?: number; + messageCount?: number; + kind?: 'lead_session' | 'member_session' | 'subagent'; +} +``` + +This is needed because cumulative subagent snapshots can duplicate history. Existing callers can ignore the optional fields. + +Also adjust `findRecentMemberLogFileRefsByMember()` to include requested member names in the attribution `knownMembers` set. Syntax-only IPC validation is not enough for removed or historical members if they are no longer present in current config/meta/inbox. + +Do this without breaking existing finder callers. `TeamMemberRuntimeAdvisoryService` and existing tests already pass the third argument as positional `mtimeSinceMs`, including numeric values and `null`. + +```ts +type FindRecentMemberLogFileRefsOptions = + | number + | null + | { + mtimeSinceMs?: number | null; + forceRefresh?: boolean; + }; +``` + +Rules: + +- numeric third arg still means `mtimeSinceMs`; +- `null` still means no mtime window; +- object form is added for member stream and can pass `forceRefresh`; +- only object form should bypass `discoverProjectSessions()` cache; +- `mtimeSinceMs` must apply to lead transcript refs as well as member/subagent candidates; +- do not migrate advisory callers in the same PR unless needed by tests. + +Recommended default: + +```ts +const DEFAULT_MEMBER_LOG_STREAM_BUDGET = { + maxTranscriptFiles: 40, + maxSegments: 30, + maxChunks: 250, + maxSourceMessages: 1200, + maxMessagesPerSegment: 300, + maxTotalContentChars: 800_000, + maxMessageContentChars: 80_000, + maxToolResultContentChars: 120_000, + openCodeMessageLimit: 400, + openCodeTimeoutMs: 5_000, +}; +``` + +Add IPC/preload API: + +```ts +getMemberLogStream( + teamName: string, + memberName: string, + options?: { + limitSegments?: number; + since?: string; + laneId?: string; + forceRefresh?: boolean; + } +): Promise +``` + +Add member stream tracking API: + +```ts +setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise +``` + +Why this is separate from `setTaskLogStreamTracking()`: + +- `TaskLogStreamSection` listens to `onTeamChange`, but `TaskLogsPanel` is what enables `TeamLogSourceTracker`. +- Member popup has no `TaskLogsPanel` wrapper. +- Reusing the task-named method from member UI would work mechanically, but it creates semantic drift. +- A dedicated member tracking API can map to the same underlying watcher with a separate `member_log_stream` consumer count. + +Default behavior: + +- `limitSegments` defaults to 30 and is clamped to `1..80`. +- `since` is optional and only used as a performance hint, but invalid dates should be rejected by IPC validation. +- First renderer implementation should not pass `since` for background reload replacement. Without an explicit incremental-merge contract, replacing the visible stream with a since-filtered partial response can hide older segments. +- `laneId` is optional and used only for safe OpenCode runtime projection. +- `forceRefresh` is optional and should be used only by renderer background reloads after `log-source-change`. +- Handler validates `teamName` and `memberName`. +- Handler validates `laneId` separately from `memberName`, because valid runtime lanes can contain `:`. +- Handler must trim but otherwise preserve `laneId`; do not lowercase or rewrite it. +- Handler validates `forceRefresh` as boolean when present. +- Put new validators in `src/main/ipc/guards.ts` as exported functions, or define local validator result types. `ValidationResult` is currently not exported from `guards.ts`. +- Handler should not reject deleted/removed members just because they are absent from current config. Old logs are still useful. +- Handler rejects unknown option keys, matching the stricter `TEAM_GET_DATA` options policy. +- Tracking handler validates `teamName` and boolean `enabled`. + +Suggested lane validator: + +```ts +export function validateOptionalRuntimeLaneId(value: unknown) { + if (value == null) return { valid: true, value: undefined }; + if (typeof value !== 'string') return { valid: false, error: 'laneId must be a string' }; + const trimmed = value.trim(); + if (!trimmed) return { valid: true, value: undefined }; + if (trimmed.length > 256) return { valid: false, error: 'laneId exceeds max length (256)' }; + if (/[\0-\x1F\x7F/\\]/.test(trimmed)) { + return { valid: false, error: 'laneId contains invalid characters' }; + } + return { valid: true, value: trimmed }; +} +``` + +## Main Process Implementation + +Create the application use case and source ports inside `src/features/member-log-stream/core/application`. Main-process source adapters live in `src/features/member-log-stream/main/adapters/output/sources`. + +Existing task log stream services can still be reused as infrastructure dependencies where appropriate, but member stream orchestration must not live inside `src/main/ipc/teams.ts` or a monolithic `src/main/services/team` class. + +Recommended internal source-port interface: + +```ts +interface MemberLogStreamSourceInput { + teamName: string; + memberName: string; + laneId?: string; + budget: MemberLogStreamBudget; + sinceMs?: number | null; + forceRefresh?: boolean; +} + +interface MemberLogStreamSourceResult { + provider: MemberLogStreamProvider; + status: 'included' | 'partial' | 'skipped'; + segments: MemberLogStreamSegment[]; + warnings: MemberLogStreamWarning[]; +} + +interface MemberLogStreamSource { + readonly provider: MemberLogStreamProvider; + load(input: MemberLogStreamSourceInput): Promise; +} +``` + +First PR sources: + +- `ClaudeMemberTranscriptStreamSource`; +- `OpenCodeMemberRuntimeStreamSource`; +- `CodexNativeMemberTraceStreamSource`, but only as skipped coverage adapter in first PR. + +Recommended responsibilities: + +- service normalizes already-validated options and budget; +- source classes discover and load their provider/runtime data; +- service merges source results in deterministic provider order; +- service calls provider sources fail-soft through `Promise.allSettled()` or equivalent per-source try/catch; +- service joins identical active requests by team/member/lane/limit/since key, with no long-lived response cache in the first PR; +- service enforces `maxSourceMessages`, `maxMessagesPerSegment`, `maxSegments` and `maxChunks`; +- service enforces content-size budgets before chunk build, because one huge tool result can freeze the renderer even when message count is low; +- service uses provider-neutral `ParsedMessage` hygiene/truncation helpers and must not apply task/board-specific JSON payload cleanup across all member logs; +- service sorts segments by timestamp ascending in the response; +- service normalizes participant key to selected member; +- service returns structured coverage/warnings instead of throwing for partial provider failures. + +Claude source responsibilities: + +- discover transcript files for selected member through `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()`; +- extend/use optional ref metadata `kind` and `sizeBytes`; +- ensure finder `mtimeSinceMs` filters lead transcript refs too, not only collected member/subagent candidates; +- dedupe cumulative subagent refs by `memberName + sessionId`, keeping largest `messageCount` when available, otherwise largest `sizeBytes`, then newest `mtimeMs`; +- do not parse every candidate just to compute `messageCount`; use `messageCount` only when existing attribution logic already knows it cheaply, otherwise use `sizeBytes` as the cumulative snapshot proxy; +- cap candidate refs by `maxTranscriptFiles` after dedupe; +- strict-parse only candidate files; +- trim very large parsed files with pair-aware message truncation before chunk build; +- trim or summarize oversized message content/tool-result content before chunk build, preserving tool ids and result pairing; +- do not reuse `sanitizeJsonLikeToolResultPayloads()` wholesale for member stream, because board/task-specific sanitization can hide legitimate JSON tool outputs; +- build one segment per session/log file by default; +- populate segment `source` metadata with provider/session/message counts, but never absolute file paths; +- include sidechain chunks through existing chunk builder. + +OpenCode source responsibilities: + +- use `laneId` when present; +- call `ClaudeMultimodelBridgeService.getOpenCodeTranscript()` with `--lane`, `--limit` and a popup-specific timeout; +- keep a small source-local TTL cache and `inFlight` join keyed by team/member/lane/limit, matching the task source pattern closely enough to avoid repeated bridge calls; +- treat ambiguous lane as skipped provider with warning; +- treat runtime timeout/binary errors as skipped or partial provider warnings, not whole-popup failure; +- map projection output into `MemberLogStreamSegment[]` through extracted `OpenCodeRuntimeProjectionMapper`. + +Codex source responsibilities in first PR: + +- return coverage status `skipped`; +- add warning `codex_member_wide_not_supported` only when useful for diagnostics; +- do not scan all Codex trace directories yet. +- do not call `CodexNativeTraceReader.readTaskRuns()` from member stream, because it requires task ids and returns only task-keyed native tool traces. +- if a follow-up adds partial Codex support, call it `codex_native_trace_partial` and cap `maxTaskDirs`, `maxTraceCandidates` and `maxTraceRuns` before parsing. + +Important implementation constraints: + +- Do not reuse one `BoardTaskExactLogStrictParser` instance between task stream and member stream unless cache ownership is changed. `parseFiles()` currently calls `retainOnly()`, so sharing the parser can evict both task-stream parsed cache and task-stream `inFlight` parse dedupe. +- Do not use `findMemberLogPaths()` as the main source. It lacks `sessionId`, `mtimeMs`, and sorted recent refs. +- Do not directly import renderer code into main. +- Do not manually construct `EnhancedChunk` if `BoardTaskExactLogChunkBuilder` can build it. +- Do not use task-specific filtering rules for member stream. Member stream scope is source/member attribution, not task interval. +- Do not reuse `CodexNativeTaskLogStreamSource` for member logs. It is task-owner scoped and relies on `readTaskRuns({ taskIds })`. +- Do not use `OpenCodeTaskLogStreamSource` directly for member logs. It is task-window and task-marker aware. +- Do not copy `toParsedMessage()` into member source. Extract generic OpenCode projection mapping from `OpenCodeTaskLogStreamSource` and let both task/member sources import it. Do not base the shared mapper on `OpenCodeTaskStallEvidenceSource`, because that mapper is intentionally narrower and does not preserve all task-stream rendering data. +- Keep main handler fail-soft. A single unreadable transcript file should become a warning, not a failed popup. +- Keep response bounded. Any cap hit sets `truncated: true` and warning `large_log_window_limited`. +- If a single file/session exceeds message budget, use `segment_message_window_limited` and drop the oldest message window before chunk build. +- Do not add member stream policy or service ownership to `initializeTeamHandlers()`. The feature should have its own main composition and IPC registration path. + +Segment rules: + +- Segment id should be stable across refreshes: include provider, normalized team/member, session id or hashed file fingerprint, first timestamp. +- Do not put absolute file paths in segment ids. +- Segment timestamps come from first/last parsed message. +- Empty parsed files are ignored. +- Very large files are parsed through existing stream parser, not loaded wholesale in renderer. +- If chunks are empty after normalization, skip segment. +- If total chunks exceed `maxChunks`, keep recent useful chunks and mark response truncated. +- Do not text-truncate inside `EnhancedChunk` in first PR. +- Do not leave orphan tool results after message trimming. Keep matching assistant tool call by `sourceToolUseID`, or drop the orphan result. +- Do not synthesize fake `Process` entries for OpenCode. Its projection should render through normal tool/output chunks. +- Do not rely on collapsed UI state for safety. `MemberExecutionLog` keeps AI groups expanded by default and expanded tool rows can render full content. + +## Transport And Composition Integration + +This part is easy to underestimate. The feature is not only a service and renderer component; it must pass through feature contracts, preload, browser fallback and IPC registration without disturbing existing task-log handlers. + +Required transport edits: + +- Add feature channel constants in `src/features/member-log-stream/contracts/channels.ts`, for example `MEMBER_LOG_STREAM_GET` and `MEMBER_LOG_STREAM_SET_TRACKING`. +- Prefer feature channel names such as `member-log-stream:getMemberLogStream` over new `TEAM_*` constants. If a compatibility alias is needed, it should still point to feature-owned contracts. +- Add `MemberLogStreamApi` in `src/features/member-log-stream/contracts/api.ts`. +- Add `createMemberLogStreamBridge()` in `src/features/member-log-stream/preload`. +- Add feature IPC registration in `src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts`. +- Register/remove feature IPC handlers from app main composition through `@features/member-log-stream/main`, not through `registerTeamHandlers()`. +- Add `createMemberLogStreamFeature(...)` in `src/features/member-log-stream/main/composition`. +- Instantiate the feature facade in `src/main/index.ts` or the existing main composition root after finder/parser/bridge/tracker dependencies are available. +- Expose renderer access through a feature-friendly API path, for example `api.memberLogStream`. If `api.teams.getMemberLogStream(...)` is kept for compatibility, it must be a thin delegate to the feature API. +- Add browser-mode fallback returning a complete empty `MemberLogStreamResponse`, not `null` and not a thrown error. +- Add browser-mode no-op fallback for member stream tracking. +- Add `member_log_stream` to `TeamLogSourceTrackingConsumer`. +- Map feature tracking enable to `TeamLogSourceTracker.enableTracking(teamName, 'member_log_stream')`. +- Map feature tracking disable to `TeamLogSourceTracker.disableTracking(teamName, 'member_log_stream')`. +- Keep the tracker consumer team-scoped, not member-scoped. `TeamLogSourceTracker` already scopes watch targets by team sessions and uses reference counts, so multiple open member popups for the same team should increment/decrement the same `member_log_stream` consumer count. +- Cleanup must run for the exact team used on mount. If `teamName` changes while the dialog stays mounted, disable tracking for the previous team before enabling it for the next team. + +Important integration rule: + +🎯 8.5 🛡️ 9 🧠 4 +Примерно 80-180 LOC. + +Use feature-owned composition: + +```ts +const memberLogStreamFeature = createMemberLogStreamFeature({ + logsFinder, + logSourceTracker, + runtimeBridge, + logger, +}); + +registerMemberLogStreamIpc(ipcMain, memberLogStreamFeature); +``` + +Do not insert member stream dependencies into `initializeTeamHandlers()`. Current tests and likely other setup code pass optional team services positionally; adding this feature there would create a false ownership boundary and risk confusing service assignment failures. + +Alternative: + +🎯 7 🛡️ 9 🧠 7 +Примерно 250-450 LOC. + +Refactor all existing `initializeTeamHandlers()` dependencies to a dependency object. This is cleaner long-term for legacy team IPC, but it is a separate IPC hygiene PR and should not be bundled unless the implementation intentionally pays that cost. + +Browser fallback shape: + +```ts +const emptyMemberLogStreamResponse: MemberLogStreamResponse = { + participants: [], + defaultFilter: 'all', + segments: [], + source: 'member_empty', + coverage: [], + warnings: [], + truncated: false, + generatedAt: new Date().toISOString(), + metadata: { + scannedTranscriptFileCount: 0, + includedTranscriptFileCount: 0, + droppedSegmentCount: 0, + droppedChunkCount: 0, + droppedMessageCount: 0, + }, +}; +``` + +This keeps browser-mode tests honest while making it obvious that real stream loading is Electron-only. + +## Renderer Implementation + +Refactor the stream UI so `TaskLogStreamSection` is not copied wholesale. + +Recommended shape: + +- Extract a generic render-only component, for example `ExecutionLogStreamView`. +- It receives `title`, `description`, `stream`, `loading`, `error`, `emptyTitle`, `emptyDescription`, `teamName`, `forceSegmentHeaders`, `boundedHistoryNote`, and optional `buildSegmentRenderKey`. +- It must not import `api.teams`, `MemberLogsTab`, feature gates, provider sources or task/member loading hooks. +- It should receive already loaded `teamMembers` from the container, so pure view tests do not need the app store. +- `TaskLogStreamSection` keeps task-specific loading logic and task-specific descriptions. +- New `MemberLogStreamSection` owns member-specific loading logic and descriptions. +- `ExecutionLogStreamView` should be exported from `@features/member-log-stream/renderer` as a public render primitive if legacy `TaskLogStreamSection` needs to reuse it. +- `MemberDetailDialog` renders `MemberLogStreamSection` in the existing Logs tab area. +- `MemberLogsTab` remains available as fallback. +- Do not modify `MemberLogsTab` behavior in the first PR. It is also used by `ExecutionSessionsSection` in task logs, so changing it would widen the regression surface. +- Put the renderer feature-gate decision at the `MemberDetailDialog` Logs tab boundary. `MemberLogStreamSection` should not import or own the legacy `MemberLogsTab`. +- On first-load failure in the new stream, show the member stream error and an explicit legacy fallback panel below it. Do not silently replace the new stream with old logs, because that hides QA failures. +- Do not add virtualization in the first PR. `MemberExecutionLog` currently renders all groups, so protection must come from backend `maxSegments`/`maxChunks`. +- If `stream.truncated` is true or warning `large_log_window_limited` exists, show a short note that the popup is showing the recent bounded stream. +- Keep `normalizeResponse()` generic enough to convert every segment `chunks` through `asEnhancedChunkArray`. +- Generic response normalization must preserve the original stream shape with object spread. Do not reconstruct only task fields, because member-only metadata and `segment.source` are part of the contract. +- Keep participant identity actor-based. For a selected member, prefer one member participant plus per-segment `source` labels; do not create provider/session pseudo-participants just to show Claude/OpenCode/Codex labels. +- If source filtering is needed later, add a separate source filter. Do not overload participant chips with provider identity. +- Preserve current task-stream render-key behavior by default: `participantKey:firstChunkId`. This keeps expanded state stable when a task segment grows. +- For member stream, pass a source-aware render key based on safe `segment.id` plus first chunk id or start timestamp. This avoids collisions between providers/sessions for the same participant. +- Keep date handling consistent with task stream. Electron IPC should preserve `Date` objects through structured clone, but renderer tests and browser-mode stubs may use JSON-like strings. +- If `ExecutionLogStreamView` starts accepting JSON-like fixtures, add one shared chunk date normalizer instead of ad hoc `new Date()` calls in each component. +- Keep `describeStreamSource()` source-specific. Do not reuse task copy in member popup. + +Member-specific UI text: + +- Loading: `Loading member log stream...` +- Empty: `No log stream entries were found for this member yet.` +- Error: `Failed to load member log stream` +- Source description should mention member-scoped transcript/runtime logs, not task-scoped logs. + +Live refresh: + +- Enable feature tracking bridge while `MemberLogStreamSection` is mounted, then disable it on unmount. If `api.teams.setMemberLogStreamTracking` is kept for compatibility, it delegates to the feature bridge. +- Do not rely on `TaskLogsPanel` to keep `TeamLogSourceTracker` active. Member popup can be opened without task logs panel mounted. +- Do not call `setTaskLogStreamTracking()` from member UI. It would work technically, but it couples member stream lifecycle to task stream naming and tests. +- Reuse the existing team change subscription path for now. Do not create a second event bus in this PR. +- Reload on same-team `log-source-change` with `forceRefresh: true` to bypass the finder discovery cache. +- Reload on same-team `task-log-change`, because that is a log freshness signal and does not include memberName. +- Do not reload on `tool-activity`. +- Debounce 500-750ms, slightly more conservative than task stream. +- Background reload replaces the full bounded response. Do not request a `since`-only response from renderer until the service exposes a response merge contract. +- Coalesce duplicate background reloads while one member stream request is active. Keep at most one pending reload and run it after the active request settles. +- Background reload with `forceRefresh: true` should not start a parallel request if a request for the same member key is already running. +- Do not clear old stream on background refresh failure. + +## Provider Coverage + +| Provider/runtime | Variant 2 coverage | Reliability | Notes | +| --- | --- | --- | --- | +| Claude JSONL transcript | Good | 🛡️ 9 | Existing member attribution, recent refs, session ids and JSONL parsing are enough for first release | +| OpenCode runtime projection | Good if session/lane is resolvable | 🛡️ 8 | Safe when desktop passes `laneId`; ambiguous records are skipped, not guessed | +| Codex native trace | Explicitly skipped in first PR | 🛡️ 8 | Honest coverage is safer than partial trace presented as full Codex logs | + +Claude transcript path: + +- Use `TeamMemberLogsFinder` as the source locator. +- Do not re-implement member attribution in renderer. +- Trust existing precedence: process/team metadata over routing sender over teammate id over text mention. + +OpenCode path: + +- Extend `ClaudeMultimodelBridgeService.getOpenCodeTranscript()` params with optional `laneId`. +- If desktop knows a safe lane for the member, pass `--lane`. Renderer can pass `member.laneId` from `TeamMemberSnapshot` through `getMemberLogStream` options. +- If lane is unknown, calling without lane is allowed only as best-effort and must catch the orchestrator "pass --lane" error. +- On ambiguity, skip OpenCode runtime projection and add `opencode_ambiguous_lane` warning. +- Do not merge multiple OpenCode lanes unless a later orchestrator contract exposes safe list-by-member output. + +Codex native path: + +- Do not claim full member-wide Codex support in variant 2. +- Existing trace headers include `ownerName`, so partial member-wide native tool trace is possible as a follow-up. +- Current `CodexNativeTraceReader` is task-first and `CodexNativeTraceProjector` is native-tool-only. +- First PR should return coverage status `skipped` with `codex_member_wide_not_supported`. +- Optional second PR can add `readMemberRuns()` and label it as partial native tool trace, not full Codex logs. + +## Edge Cases And Mitigations + +1. Member removed from team. + - Keep handler validation syntax-only for `memberName`. + - Allow logs finder to search historical logs. + - UI should still render if popup can open from historical data. + +2. Member renamed. + - Variant 2 uses exact current/historical name passed by caller. + - Do not guess aliases. + - Future work can add alias map from team config history. + +3. Lead member. + - Use existing `isLeadMember` and participant role conventions. + - Do not assume lead means non-sidechain in every transcript. + +4. Multiple members with similar names. + - Do not use loose substring matching in the new service. + - Rely on `TeamMemberLogsFinder` attribution signals. + +5. OpenCode multiple lanes. + - Treat as ambiguous unless lane is known. + - Do not silently pick first record. + +6. OpenCode runtime unavailable or slow. + - Missing binary returns transcript-only stream with warning, not hard error. + - Use popup-specific `openCodeTimeoutMs`. + - Return Claude transcript segments even when OpenCode times out. + - Add `opencode_runtime_timeout` warning. + +7. Huge member history. + - Limit transcript refs, source messages, per-segment messages, segment count and total chunks. + - Limit content characters as well as message count. + - Prefer most recent files first if finder supports mtime. + - Expose warning `large_log_window_limited`. + - Set `truncated: true`. + - Avoid rendering thousands of chunks in one popup. + +7.1 Huge single tool result or output. + - A single parsed message can contain a massive tool result or markdown output. + - Apply `maxMessageContentChars`, `maxToolResultContentChars` and `maxTotalContentChars` before chunk build. + - Preserve tool call/result ids and replace truncated content with a clear bounded placeholder. + - Add `message_content_limited` warning and set `truncated: true`. + - Extract provider-neutral message hygiene helpers instead of applying board/task-specific JSON sanitization to every member log. + - JSON-looking Bash/API outputs should remain visible unless they exceed content budget, in which case they are truncated, not blanked. + +8. Partial or malformed JSONL. + - Strict parser already skips invalid/unreadable records. + - Service should continue with remaining files. + +9. Empty logs. + - Return `member_empty`, no exception. + - UI empty state should not look like failure. + +10. Provider duplication. + - If OpenCode runtime projection duplicates transcript-derived entries, prefer transcript when there is a stable message uuid. + - If no stable uuid, do not attempt risky fuzzy dedupe in first release. + - Deduplicate cumulative Claude subagent snapshots before parse, using ref metadata. + +11. Sorting across sessions. + - Response sorted ascending. + - Renderer may reverse for newest-first, matching task stream behavior. + - Mixed provider segments should be sorted by timestamp, not provider priority. + +12. Cache invalidation. + - Cache key should include team, member, candidate file mtimes/sizes and runtime projection identity. + - Do not keep stale stream after log-source-change event. + - If no explicit service-level cache is added in first PR, document that dedicated parser/runtime caches are the only caches. + - `TeamMemberLogsFinder` discovery cache has a 30s TTL, so member stream needs a force-refresh path after `log-source-change`. + - OpenCode member runtime source should have its own short TTL and in-flight join so repeated live refreshes do not spawn duplicate `runtime transcript` calls. + - `forceRefresh` may bypass completed OpenCode cache, but should still join an existing in-flight call for the same team/member/lane/limit. + +13. Renderer memory pressure. + - Keep `limitSegments` plus backend `maxChunks` and message budgets. + - Avoid storing raw logs in React state. + - Store only normalized stream response. + - Defer virtualization until there is a real need for audit-sized popup history. + +14. Old fallback hiding new bugs. + - Fallback should be visible as fallback, not silently replace new stream in a way that masks errors during QA. + +15. Browser mode API client. + - `src/renderer/api/httpClient.ts` should add unavailable stub like existing `getTaskLogStream`. + - Do not break browser-mode compile. + +16. Unsafe or malformed `laneId`. + - Do not validate with `validateMemberName`. + - Allow colon-separated runtime lane ids. + - Reject NUL/newline and oversized values. + - Treat invalid `laneId` as IPC validation error, not as "no lane". + +17. OpenCode mapper drift. + - Do not duplicate projection message mapping in the new member source. + - Extract generic mapper from `OpenCodeTaskLogStreamSource`. + - Keep task marker/window filtering inside the task source. + +18. Tool call/result split by trimming. + - Truncate parsed messages before chunk build with pair-aware logic. + - If a retained tool result has `sourceToolUseID`, retain the matching assistant tool call when possible. + - If the matching call cannot fit the hard budget, drop the orphan result. + +19. Live refresh noise. + - Member stream cannot filter `task-log-change` by member because the event has no `memberName`. + - Reload on same-team `task-log-change` only while popup is mounted and debounced. + - Skip `tool-activity` reloads to avoid heavy parser churn. + +20. Feature IPC ownership drift. + - `initializeTeamHandlers()` has many optional dependencies passed positionally. + - Adding member stream there would both risk positional service drift and put new feature policy in legacy team IPC. + - Register member stream through feature-owned IPC/composition instead. Leave legacy dependency-object cleanup as a separate PR. + +21. Browser mode fallback drift. + - Browser mode does not support Electron IPC, but the renderer API still must satisfy the feature API. + - Return a complete empty `MemberLogStreamResponse` from the feature/browser fallback path. + - Add a small test or compile check so shared type changes do not break browser fallback. + +22. Historical member not in current config. + - `TeamMemberLogsFinder` attribution uses `knownMembers`. + - If the requested member is removed from config/meta/inbox, attribution can miss old files. + - Add requested member names into the finder attribution set for `findRecentMemberLogFileRefsByMember()`. + +23. Mixed-provider segment headers. + - Plain `BoardTaskLogSegment` has no provider/session label for renderer. + - Use `MemberLogStreamSegment.source` metadata for safe labels. + - Do not expose absolute transcript paths in segment metadata or warnings. + +24. Path leakage through stable ids. + - Segment ids are visible to renderer and can end up in test snapshots. + - Use a short hash/fingerprint of `filePath + mtimeMs + sizeBytes`, not the absolute path itself. + - Warnings should say counts/provider/session labels, not local filesystem paths. + +25. Discovery cache stale after launch/log source changes. + - Finder discovery cache can keep old session ids briefly. + - Renderer should pass `forceRefresh: true` after same-team `log-source-change`. + - Service should pass that through with object-form finder options: `{ mtimeSinceMs, forceRefresh }`. + - For OpenCode runtime calls, keep source-local TTL/in-flight protection. `forceRefresh` should bypass completed cache only, not duplicate an active bridge call. + +26. Validator placement/type drift. + - `src/main/ipc/guards.ts` currently keeps `ValidationResult` local. + - Prefer exporting concrete validators such as `validateOptionalRuntimeLaneId()` and `validateOptionalBooleanOption()`. + - If validators stay local in `teams.ts`, avoid importing the private `ValidationResult` type. + +27. Date object assumptions in renderer. + - `groupTransformer` calls `.getTime()` on chunk/step dates. + - Existing task stream relies on Electron structured clone preserving dates. + - New shared tests should cover Date-shaped chunks and avoid accidentally feeding raw JSON date strings into `MemberExecutionLog`. + - If JSON-like chunks are needed in tests or browser mode, add one shared `normalizeEnhancedChunkDates()` helper. + +28. Fallback boundary drift. + - `MemberLogsTab` is shared by the member popup and task `Execution Sessions`. + - Changing `MemberLogsTab` itself can break task-log legacy browsing. + - Keep the new-vs-old decision in `MemberDetailDialog` only. + - Test renderer gate on/off at dialog level. + +29. Hidden stream failure during QA. + - If the new stream error silently switches to old logs, failures can ship unnoticed. + - Show the error and a clearly labeled fallback. + - Background refresh failures can preserve the previous good stream, but initial load failures should be visible. + +30. Resolved member lane type drift. + - `TeamMemberSnapshot` already has runtime/lane fields: `providerBackendId`, `selectedFastMode`, `resolvedFastMode`, `laneId`, `laneKind`, `laneOwnerProviderId`. + - `ResolvedTeamMember` currently does not declare them, even though `buildResolvedMember()` spreads the snapshot. + - Add explicit fields to the shared type instead of using casts in `MemberLogStreamSection`. + - Do not add duplicate renderer mapping unless tests prove a real runtime gap. + +31. OpenCode bridge call churn. + - Live refresh can fire more than once while a popup is open. + - Mirror the task OpenCode source pattern with a short TTL cache and `inFlight` join. + - Cache both success and null briefly, and keep timeout warnings fail-soft. + +32. Board/task sanitization leakage. + - Task stream has private helpers that clean board-tool JSON payloads. + - Member stream is wider and may legitimately contain JSON output from Bash/API tools. + - Reuse only provider-neutral clone/prune/truncate helpers, or rename board-specific cleanup so it cannot be accidentally applied globally. + +33. Finder third-arg compatibility drift. + - `findRecentMemberLogFileRefsByMember()` already has numeric/null positional callers. + - Adding `forceRefresh` as object-only third arg can break runtime advisory or live tests. + - Add a compatibility parser that accepts `number`, `null` and `{ mtimeSinceMs, forceRefresh }`. + - Member stream should use object form, existing advisory code can keep numeric/null form. + +34. Lead transcript bypasses `mtimeSinceMs`. + - Current recent-ref finder applies `mtimeSinceMs` to collected candidates, but lead transcript is pushed before that scan. + - If selected member is lead and `since` is used as a performance window, old lead logs can be returned unexpectedly. + - Apply the same mtime filter to lead transcript stat before pushing the lead ref. + +35. Renderer duplicate reload pressure. + - `TaskLogStreamSection` ignores stale responses with `requestSeqRef`, but still can start parallel IPC calls. + - Member stream requests are heavier because they can parse many files and call OpenCode runtime transcript. + - Coalesce active member stream loads in `MemberLogStreamSection`, and join identical active requests in `GetMemberLogStreamUseCase`. + +36. Segment render key collision. + - Task stream default render key uses `participantKey:firstChunkId` to preserve expanded state when a segment tail grows. + - Member stream can have multiple providers/sessions for one participant, so that default can collide. + - Keep the task default, but let member stream pass a source-aware `buildSegmentRenderKey`. + +37. Live tracking not activated. + - `TaskLogStreamSection` subscribes to `onTeamChange`, but `TaskLogsPanel` enables `setTaskLogStreamTracking`. + - Member popup has no equivalent parent, so it can miss `task-log-change` and `log-source-change` events if no other UI consumer is active. + - Add `setMemberLogStreamTracking()` and a `member_log_stream` tracker consumer. + - Enable tracking only while `MemberLogStreamSection` is mounted and disable it on unmount. + +38. IPC option typo drift. + - Existing `TEAM_GET_DATA` rejects unknown option keys before dispatching. + - If `getMemberLogStream` silently accepts unknown keys, typos in `laneId`, `since` or `forceRefresh` can create stale or ambiguous logs while tests still pass. + - Use an allow-list: `limitSegments`, `since`, `laneId`, `forceRefresh`. + - Return `Unknown getMemberLogStream option: ${key}` before calling the service. + +39. Participant/source identity drift. + - `BoardTaskLogParticipant` drives actor chips, color lookup and member labels. + - If provider/session is encoded as participant identity, one selected member can appear as multiple people and filters become misleading. + - Keep `participantKey` actor-based, for example `member:` or existing lead/unknown conventions. + - Put provider/session/lane details only in `MemberLogStreamSegment.source`. + +40. Since-only reload hides older visible segments. + - `since` is useful as a backend performance hint, but a since-filtered response is partial unless the API says otherwise. + - Renderer background reload currently replaces state, it does not merge old and new segments. + - First PR should do full bounded background reloads, with `forceRefresh` on `log-source-change`. + - Use `since` only in tests/service paths where the expected partial semantics are explicit. + +41. Parser `retainOnly()` in-flight eviction. + - `BoardTaskExactLogStrictParser.parseFiles()` calls `retainOnly()` before parsing. + - `BoardTaskActivityParseCache.retainOnly()` deletes `inFlight` entries outside the requested file set. + - If task and member streams share a parser, one stream can remove the other's in-flight dedupe and cause duplicate reads or cache churn. + - Treat parser instance ownership as per stream source stack. Do not inject the existing task parser into the member stream source stack. + +42. Wrong OpenCode mapper source. + - `OpenCodeTaskLogStreamSource.toParsedMessage()` preserves content blocks, sanitized display text, `toolUseResult`, `sourceToolUseID`, `sourceToolAssistantUUID`, `isMeta`, subtype and level. + - `OpenCodeTaskStallEvidenceSource.toParsedMessage()` is narrower and maps non-string content to an empty array. + - Extract the shared mapper from task stream source only; stall monitor migration can be a separate cleanup if needed. + +43. Finder metadata overreach. + - Adding `messageCount` to `MemberLogFileRef` is useful only when that value is already cheaply known from existing attribution records. + - Parsing every candidate file inside the finder just to count messages would double the IO before the strict parser runs. + - Keep finder metadata lightweight: `kind`, `sizeBytes`, `mtimeMs` and optional existing `messageCount`. + +44. Tracking consumer leaks or semantic drift. + - `TeamLogSourceTracker` keeps reference counts by team and consumer name. + - Reusing `task_log_stream` from member popup is technically possible but makes task/member lifecycles indistinguishable in tests and diagnostics. + - Add `member_log_stream` as a separate consumer and ensure every mount enable has a matching unmount or team-change disable. + +45. Generic renderer view grows side effects. + - `TaskLogStreamSection` currently owns fetch, debounce, stale-response protection and task copy. + - If `ExecutionLogStreamView` imports `api.teams`, feature gates or `MemberLogsTab`, it stops being a reusable render primitive. + - Keep the generic view pure: render normalized stream, participants, segment headers and state. Containers own loading, tracking and fallback. + +46. Codex partial trace presented as complete. + - `CodexNativeTraceReader` is task-keyed and `CodexNativeTraceProjector` keeps only native tool events. + - Reusing the task source for member logs would either require a task id or scan all task dirs, and the result still would not be full Codex chat history. + - First PR keeps Codex coverage `skipped`. A later partial trace must be explicitly labeled `codex_native_trace_partial`. + +47. Runtime lane validation too strict or too loose. + - `validateMemberName()` rejects `secondary:opencode:` because of `:`. + - Do not lowercase or rewrite `laneId`; orchestrator/session records may expect exact lane ids. + - Add an optional lane validator that trims, accepts `primary` and colon-separated runtime lanes, rejects empty strings, NUL/newlines/control characters, path separators and length over 256. + +48. DTO inheritance/source union drift. + - `BoardTaskLogStreamResponse.source` is task-scoped and does not include member values. + - Do not extend `BoardTaskLogStreamResponse` for member logs or broaden its `source` union. + - Define standalone `MemberLogStreamResponse` with shared participant/segment primitives, then test that task stream source values stay unchanged. + +49. Feature boundary drift. + - The easiest regression is to put policy in `src/main/ipc/teams.ts`, preload glue or React containers because those files already exist. + - Keep the feature-owned policy in `src/features/member-log-stream/core` and provider IO in feature main adapters. + - App-shell files may register, delegate or mount the feature, but they must not know merge order, budgets, source coverage rules or provider-specific parsing details. + +## Rollout And Feature Gates + +Add separate gates: + +- Main: `CLAUDE_TEAM_MEMBER_LOG_STREAM_READ_ENABLED`, default true. +- Renderer: `VITE_MEMBER_LOG_STREAM_UI_ENABLED`, default true. +- Put main gate in `src/features/member-log-stream/main` or feature contracts/helpers, following existing truthy/falsy parsing semantics. +- Put renderer gate in `src/features/member-log-stream/renderer` or a feature renderer helper. Do not read `import.meta.env` outside renderer code. + +Fallback behavior: + +- If main gate is off, handler returns empty response or unavailable result consistently. +- If renderer gate is off, `MemberDetailDialog` uses old `MemberLogsTab`. +- If new stream errors, show error plus old logs fallback. +- If task log `Execution Sessions` renders old session logs, it stays unchanged. It imports `MemberLogsTab` directly and should not be coupled to member stream rollout. + +Rollback: + +- Flip renderer gate off to restore old member logs UI. +- Flip main gate off if parsing causes unexpected load. +- No data migration needed. + +## Testing Plan + +Main-process tests: + +- Core domain policy tests cover merge order, source coverage status, budget decisions, dedupe keys and DTO-safe source metadata without main/preload/renderer imports. +- Core application use-case tests inject fake `MemberLogStreamSource` ports, fake clock/logger/cache ports and verify fail-soft source behavior. +- Core application tests prove adding a new source adapter does not require changing renderer or transport code. +- `GetMemberLogStreamUseCase` returns empty stream for no files. +- It builds one or more segments from synthetic Claude JSONL files for selected member. +- It does not include logs attributed to another member. +- It skips unreadable/malformed files and records warnings. +- It respects `limitSegments`. +- It respects `maxTranscriptFiles`, `maxSegments` and `maxChunks`. +- It respects `maxSourceMessages` and `maxMessagesPerSegment`. +- It respects `maxTotalContentChars`, `maxMessageContentChars` and `maxToolResultContentChars`. +- It sets `truncated: true` and `large_log_window_limited` when budget is hit. +- It records `message_content_limited` when content budget is hit. +- It preserves JSON-looking tool output unless content budget requires truncation. +- It does not apply board/task-specific JSON payload sanitization globally. +- It records `segment_message_window_limited` when trimming one oversized segment. +- It preserves tool call/result pairs when trimming, or drops orphan results. +- It preserves tool ids and source ids when truncating oversized content. +- It dedupes cumulative subagent refs by member/session before parse. +- It keeps segment ids stable across repeated calls. +- It does not expose absolute file paths in segment ids, metadata or warnings. +- It does not share parser cache with task stream service. +- It uses a member-owned `BoardTaskExactLogStrictParser` so member parsing cannot call `retainOnly()` on the task stream parser or clear task `inFlight` entries. +- A task-stream parse in flight stays joined after a member-stream parse starts for a different file set. +- It handles removed member name without requiring current team membership. +- It augments finder attribution with requested member names for historical/removed members. +- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` can return optional `kind` and `sizeBytes` without breaking existing callers. +- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` does not parse candidate transcript files just to compute `messageCount`. +- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` keeps legacy numeric/null third-arg behavior. +- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` supports object options `{ mtimeSinceMs, forceRefresh }`. +- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` passes `forceRefresh` to `discoverProjectSessions()`. +- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` applies `mtimeSinceMs` to lead transcript refs. +- It merges source results in deterministic order. +- It joins identical active `GetMemberLogStreamUseCase` requests and does not add long-lived response cache. +- It returns `MemberLogStreamSegment.source` metadata without leaking file paths. +- Provider source tests cover Claude included, OpenCode skipped, Codex skipped. +- First-PR Codex skipped adapter does not call `CodexNativeTraceReader.readTaskRuns()` and does not scan trace dirs. +- `OpenCodeRuntimeProjectionMapper` preserves tool calls, tool results, `sourceToolUseID`, `sourceToolAssistantUUID`, `toolUseResult`, `isMeta` and sanitized text content. +- `OpenCodeTaskLogStreamSource` and `OpenCodeMemberRuntimeStreamSource` both use `OpenCodeRuntimeProjectionMapper`. +- Mapper fixture tests cover non-string content blocks so member stream does not regress to the narrower stall-monitor conversion. +- `OpenCodeMemberRuntimeStreamSource` joins duplicate in-flight bridge calls for the same team/member/lane/limit. +- `OpenCodeMemberRuntimeStreamSource` honors `forceRefresh` by bypassing completed TTL cache but still joining active in-flight work. +- `ResolvedTeamMember` exposes runtime/lane fields without renderer casts: `providerBackendId`, `selectedFastMode`, `resolvedFastMode`, `laneId`, `laneKind`, `laneOwnerProviderId`. +- `MemberLogStreamSection` passes `laneId` only for OpenCode members whose lane is owned by OpenCode. +- `MemberLogStreamSection` does not pass stale lane-like fields for non-OpenCode members. + +IPC/preload tests: + +- Feature IPC tests register/remove feature channels through `registerMemberLogStreamIpc()` and do not rely on `src/main/ipc/teams.ts` owning the handler. +- Feature preload tests cover `createMemberLogStreamBridge()` and import only feature contracts plus preload-safe helpers. +- `getMemberLogStream` validates `teamName` and `memberName`. +- It accepts colon-separated `laneId`, for example `secondary:opencode:alice`. +- It accepts `primary` as a lane id. +- It preserves exact lane id casing and punctuation when passing to the service. +- It rejects `laneId` with NUL/newline/control characters/path separators or length over 256. +- It clamps `limitSegments` to `1..80`. +- It rejects invalid `since`. +- It accepts boolean `forceRefresh` and rejects non-boolean values. +- It rejects unknown option keys before dispatching to `GetMemberLogStreamUseCase`. +- It registers and removes feature `MEMBER_LOG_STREAM_GET` channel. +- It registers and removes feature `MEMBER_LOG_STREAM_SET_TRACKING` channel. +- It calls the feature facade/use case with normalized options. +- It maps member stream tracking to `TeamLogSourceTracker` consumer `member_log_stream`. +- `TeamLogSourceTracker` supports `member_log_stream` as a separate consumer from `task_log_stream`. +- Multiple member stream mounts for the same team increment/decrement the same team-level consumer count without closing the watcher early. +- Disabling member stream tracking leaves the watcher alive if task stream or another consumer is still active. +- It does not add feature-owned services to existing positional `initializeTeamHandlers()` dependencies. +- Existing task stream IPC test still calls `BoardTaskLogStreamService.getTaskLogStream()` with the correct arguments after adding separate feature IPC registration. +- Browser-mode `httpClient` returns a complete empty `MemberLogStreamResponse`. +- Browser-mode `httpClient` exposes a no-op `setMemberLogStreamTracking`. + +OpenCode tests: + +- `ClaudeMultimodelBridgeService.getOpenCodeTranscript()` passes `--lane` when `laneId` is provided. +- It keeps old behavior when `laneId` is omitted. +- It accepts popup-specific `timeoutMs` and passes it to `execCli`. +- Member stream records `opencode_ambiguous_lane` when orchestrator reports multiple records. +- Member stream records `opencode_runtime_timeout` without failing Claude transcript segments. +- Member stream includes OpenCode projection only for the selected member. + +Renderer tests: + +- `renderer/ui` component tests pass props and never mock `@renderer/api` or `@renderer/store`. +- `renderer/hooks` tests own API calls, tracking, team-change subscription and reload coalescing. +- Feature renderer public entrypoint exports only supported components/hooks, and app shell does not deep-import `renderer/ui` internals. +- `MemberLogStreamSection` shows loading, empty, error and populated states. +- It renders chunks through the same execution-log renderer as task stream. +- It does not display `Task Log Stream` copy. +- It keeps old stream during failed background refresh. +- It shows fallback old logs path when new stream fails. +- `MemberDetailDialog` renders `MemberLogStreamSection` when renderer gate is on. +- `MemberDetailDialog` renders old `MemberLogsTab` when renderer gate is off. +- `MemberDetailDialog` first-load stream failure leaves the Logs tab active and shows explicit old logs fallback. +- Existing `test/renderer/components/team/members/MemberDetailDialog.test.ts` mocks `MemberLogsTab`; add a mock for `MemberLogStreamSection` instead of depending on the full stream component there. +- It shows bounded-history note when `truncated` is true. +- It reloads on same-team `log-source-change` and `task-log-change`. +- It sends `forceRefresh: true` for `log-source-change` reloads. +- It does not pass `since` for renderer background replacement reloads in the first PR. +- It calls the feature tracking bridge on mount and disables it on unmount. +- It disables member stream tracking for the previous team when `teamName` changes. +- It coalesces duplicate reloads while a member stream request is active. +- It does not reload on `tool-activity`. +- It keeps OpenCode segments with no `Process[]` rendering normal tool/output items. +- `TaskLogStreamSection` still renders unchanged after extracting shared view. +- `ExecutionLogStreamView` renders task and member streams from the same segment/chunk shape without task-specific text. +- `ExecutionLogStreamView` does not import `api.teams`, feature gates, `MemberLogsTab` or store-bound loading hooks. +- `ExecutionLogStreamView` handles the same Date-shaped chunk objects that `TaskLogStreamSection` handles today. +- `ExecutionLogStreamView` normalizes chunks without dropping unknown stream fields or member-specific `segment.source`. +- `ExecutionLogStreamView` preserves task tail-growth expanded state with the existing default render key behavior. +- `ExecutionLogStreamView` accepts member source-aware `buildSegmentRenderKey` to avoid provider/session key collisions. +- Shared type tests or compile checks prevent `MemberLogStreamResponse` from extending or mutating task `BoardTaskLogStreamResponse.source`. +- Member stream participant identity remains actor/member identity; provider/session/lane labels render from `segment.source`. +- Member stream does not create provider-specific pseudo-participants for Claude/OpenCode/Codex. + +Architecture/lint tests: + +- `pnpm exec eslint src/features/member-log-stream --cache --cache-location .eslintcache --cache-strategy content` passes. +- Full `pnpm lint` passes with generic `src/features/*` guard rails. +- No file outside `src/features/member-log-stream` deep-imports feature internals such as `core/**`, `main/adapters/**`, `preload/**` or `renderer/ui/**`. +- `core/domain` has no imports from `@main`, `@renderer`, `@preload`, Electron, Fastify or child process modules. +- `core/application` imports only domain, contracts and ports, not concrete source adapters. +- `renderer/ui` has no imports from `@renderer/api`, `@renderer/store`, `@main` or Electron. + +Regression tests: + +- Existing `BoardTaskLogStreamService` tests stay green. +- Existing `TaskLogStreamSection` tests stay green. +- Existing `MemberLogsTab` tests stay green because the fallback component remains. + +Suggested commands: + +```bash +pnpm vitest test/features/member-log-stream/core/application/GetMemberLogStreamUseCase.test.ts +pnpm vitest test/features/member-log-stream/main/adapters/input/registerMemberLogStreamIpc.test.ts +pnpm vitest test/features/member-log-stream/renderer/MemberLogStreamSection.test.tsx +pnpm vitest test/main/services/team/TeamMemberLogsFinder.test.ts +pnpm vitest test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +pnpm vitest test/renderer/components/team/taskLogs/TaskLogStreamSection.test.tsx +pnpm exec eslint src/features/member-log-stream --cache --cache-location .eslintcache --cache-strategy content +pnpm typecheck +``` + +Manual QA: + +- Open participant popup for a Claude member with existing logs. +- Confirm UI looks like task stream but title/copy are member-specific. +- Open a member with no logs. +- Open a member while task activity/log-source events arrive. +- Test OpenCode member where one lane is resolvable. +- Test OpenCode member with ambiguous lane and confirm warning/fallback instead of wrong logs. + +## Acceptance Criteria + +- Participant popup Logs section uses the new stream format. +- Selected member stream does not include unrelated teammates. +- Claude transcript logs render in the same chunk UI as Task Log Stream. +- OpenCode is included only when runtime session resolution is safe. +- Codex native limitation is visible in coverage/warnings and not misrepresented. +- Large histories are capped and marked truncated instead of freezing the popup. +- Oversized single segments are capped by message budget, not only chunk count. +- `laneId` is passed and validated separately from member name. +- Cumulative subagent snapshot duplicates are not shown as repeated turns. +- Lead transcript refs respect `since`/`mtimeSinceMs` the same way member/subagent refs do. +- OpenCode projection rendering is shared with task stream through a mapper, not copy-pasted. +- Shared OpenCode mapper is extracted from task stream source, not from the narrower stall-monitor mapper. +- Member stream parser ownership is isolated, including parsed cache and in-flight parse dedupe. +- Member stream reloads coalesce instead of launching parallel expensive IPC requests. +- Member stream activates log-source tracking while mounted, without depending on Task Logs panel. +- Member stream segment render keys are source-aware and do not collide across provider/session segments. +- Repeated OpenCode member refreshes join in-flight bridge calls instead of spawning duplicate `runtime transcript` processes. +- Old `MemberLogsTab` remains available as fallback. +- No orchestrator code change is required for variant 2 unless desktop cannot pass existing `--lane`. +- Feature can be disabled with renderer/main gates. +- `src/features/member-log-stream` follows `docs/FEATURE_ARCHITECTURE_STANDARD.md`: contracts own DTO/channels, core is side-effect free, source ports live in application, provider IO lives in main adapters, renderer UI is presentational. +- App shell imports only public feature entrypoints and does not deep-import feature internals. +- Clean Architecture dependency direction is preserved: core has no framework/runtime imports, adapters depend inward, and concrete provider IO is hidden behind ports. +- SOLID is visible in code structure: provider adapters are replaceable, use cases depend on abstractions, and UI/transport/provider parsing have separate reasons to change. +- DRY is visible in shared renderer primitive, shared OpenCode projection mapper and single owner for member stream DTOs. +- Tests cover the main risky branches above. + +## Explicit Non-goals + +- Do not build a full provider-wide log audit system in variant 2. +- Do not add Codex member-wide trace index in variant 2. +- Do not merge all team agents into one participant popup stream. +- Do not remove old member logs UI in the first release. +- Do not silently pick an OpenCode lane when orchestrator says the session is ambiguous. + +## Implementation Order + +1. Create `src/features/member-log-stream` with contracts, core/application source ports, main composition, preload bridge and renderer public entrypoints. +2. Add feature-owned member stream DTOs, `MemberLogStreamSegment.source` metadata and `ResolvedTeamMember` runtime/lane fields. +3. Add feature IPC channel constants, feature preload bridge, browser-mode fallback and optional compatibility methods on `api.teams` if the existing member popup integration needs them. +4. Add IPC validation for `limitSegments`, `since`, `forceRefresh`, optional runtime `laneId` and unknown option keys in the feature input adapter or shared guard helpers. +5. Add feature main facade/composition and register IPC handlers without shifting existing positional `initializeTeamHandlers()` dependencies. +6. Add `MemberLogStreamBudget` and source-port interfaces. +7. Extend `MemberLogFileRef` with optional `kind`/`sizeBytes`/`messageCount`, add backward-compatible finder options parsing, apply `mtimeSinceMs` to lead refs, augment finder attribution with requested member names and add tests around cumulative subagent refs. Keep metadata cheap: do not parse every candidate file inside the finder just to compute `messageCount`. +8. Extract provider-neutral `ParsedMessage` hygiene helpers without moving board/task JSON cleanup into member stream. +9. Add pair-aware message trimming helper for oversized transcript windows. +10. Add content-size truncation helper that clones affected `ParsedMessage` objects before chunk build. +11. Add `ClaudeMemberTranscriptStreamSource` using recent member refs, ref dedupe, message/content trimming and dedicated parser. +12. Extract `OpenCodeRuntimeProjectionMapper` from `OpenCodeTaskLogStreamSource` without moving task-marker logic. Do not extract from `OpenCodeTaskStallEvidenceSource`; its mapper is lossy for stream rendering. +13. Add `GetMemberLogStreamUseCase` merge/sort/budget/truncation layer, identical active request join, and export/instantiate it in feature main composition. +14. Add `CodexNativeMemberTraceStreamSource` as skipped coverage adapter. +15. Add renderer feature `ExecutionLogStreamView` render-only extraction with shape-preserving normalization, source-aware key override and `MemberLogStreamSection` with tracking activation plus reload coalescing. +16. Wire `MemberDetailDialog` behind renderer feature gate with old fallback, importing only `@features/member-log-stream/renderer`. +17. Add OpenCode bridge `laneId` and timeout support. +18. Add `OpenCodeMemberRuntimeStreamSource` with mapper reuse, short TTL cache and in-flight join. Leave stall-monitor mapper migration out of scope unless tests show a shared helper can be adopted without changing stall evidence behavior. +19. Add coverage/warnings and empty/error states. +20. Add tests, including feature architecture import-boundary checks if the repo has a matching lint pattern. +21. Manual QA with Claude and OpenCode teams. +22. Decide separately whether to add partial Codex native tool trace or start variant 3. diff --git a/docs/team-management/member-log-stream-v2-research-addendum.md b/docs/team-management/member-log-stream-v2-research-addendum.md new file mode 100644 index 00000000..f7c58664 --- /dev/null +++ b/docs/team-management/member-log-stream-v2-research-addendum.md @@ -0,0 +1,2046 @@ +# Member Log Stream V2 Research Addendum + +## Scope + +Этот документ углубляет места с самой низкой уверенностью из основного плана: + +- OpenCode lane/session resolution; +- Codex native member-wide feasibility; +- Claude/member transcript attribution; +- parser/cache safety; +- architecture placement; +- renderer performance budget; +- oversized message/content budget; +- IPC validation and abuse limits; +- IPC registration/composition safety; +- exact stream DTO/render contract; +- cumulative subagent snapshot dedupe; +- OpenCode projection mapper extraction; +- message/window truncation semantics; +- member popup live refresh event policy; +- API shape между renderer, preload и main. + +Вывод после code research: вариант 2 остается правильным, но детали нужно сделать строже. Особенно важно не использовать `findMemberLogPaths()` как основной источник и не вызывать OpenCode transcript без `laneId`, если lane уже известен. + +## Executive Findings + +| Зона | Было | Стало после research | Решение | +| --- | --- | --- | --- | +| Claude/member transcript | Уверенность высокая | Еще выше: есть готовый `findRecentMemberLogFileRefsByMember()` с mtime, sessionId и сортировкой | Использовать его, не `findMemberLogPaths()` | +| OpenCode lane | Средняя уверенность | `TeamMemberSnapshot` уже несет `providerBackendId/selectedFastMode/resolvedFastMode/laneId/laneKind/laneOwnerProviderId`, renderer получает это через spread | Типизировать `ResolvedTeamMember`, передавать `laneId` в `getMemberLogStream` и bridge | +| Codex native | Низкая уверенность | `readTaskRuns()` физически task-keyed, а projector keeps only native tool events | First PR skipped; partial trace only as separate phase with honest label | +| Parser cache | Средняя уверенность | `parseFiles()` вызывает `retainOnly()`, а `retainOnly()` чистит и cache, и `inFlight` entries | Держать отдельный parser instance для member stream | +| UI reuse | Средняя уверенность | `TaskLogStreamSection` содержит task-specific copy и loading logic | Вынести generic view, оставить source-specific containers | +| Architecture placement | Уверенность высокая | Feature standard прямо подходит: cross-process feature, own policy, transport wiring, more than one adapter, provider roadmap | Делать `src/features/member-log-stream` canonical slice, with thin app-shell integration | +| Render pressure | Низкая уверенность | `MemberExecutionLog` рендерит все groups и держит их expanded by default | Ограничивать backend response budget | +| IPC validation | Средняя уверенность | `laneId` содержит `:`, значит `validateMemberName` не подходит | Добавить отдельную optional lane validator | +| Cumulative subagent logs | Низкая уверенность | `findRecentMemberLogFileRefsByMember()` dedupe только by filePath, а subagent snapshots могут быть cumulative | Добавить/использовать ref metadata и dedupe by member/session/kind перед parse | +| OpenCode projection mapping | Средняя уверенность | Есть richer mapper в task source и lossy mapper в stall monitor | Вынести generic mapper из task source, не из stall monitor | +| Finder ref metadata | Средняя уверенность | Current refs не несут `kind`/`sizeBytes`, но full parse ради `messageCount` удвоит IO | Добавлять lightweight metadata и не считать `messageCount` полным parse | +| Renderer extraction | Средняя уверенность | `TaskLogStreamSection` смешивает generic stream view и task-specific copy/reload | Вынести `ExecutionLogStreamView`, оставить containers source-specific | +| Budget semantics | Низкая уверенность | `maxChunks` не ограничивает большой AI chunk с сотнями tool calls | Добавить message budget и pair-aware trimming before chunk build | +| Content budget | Низкая уверенность | One huge tool result can render through `DisplayItemList` even with low message count | Add content-char budgets before chunk build | +| Live refresh | Средняя уверенность | member stream не знает taskId, а `task-log-change` не несет memberName | Reload on same-team `log-source-change` and `task-log-change`, debounced | +| IPC composition | Уверенность высокая | `initializeTeamHandlers()` positional deps make legacy team IPC a bad owner for this feature | Register feature IPC through `@features/member-log-stream/main`, do not add service to team handlers | +| Browser fallback | Средняя уверенность | Browser API must satisfy feature API even without Electron IPC | Вернуть complete empty `MemberLogStreamResponse`, как task stream fallback | +| Historical members | Средняя уверенность | Finder attribution uses `knownMembers` from config/meta/inbox, not necessarily removed popup member | Add requested names to attribution set inside recent-ref finder | +| Finder options compatibility | Средняя уверенность | `findRecentMemberLogFileRefsByMember()` уже используется positional `mtimeSinceMs` callers | Add backward-compatible third-arg parser, not object-only signature | +| Lead transcript mtime window | Новая находка | `mtimeSinceMs` сейчас фильтрует candidates, но lead transcript добавляется до этого filter | Apply mtime window consistently to lead and candidate refs | +| Segment metadata | Средняя уверенность | `BoardTaskLogSegment` has no provider/session label, so mixed sources become opaque | Use `MemberLogStreamSegment.source` metadata without file paths | +| Segment render keys | Новая находка | Task stream key uses `participantKey:firstChunkId`, good for tail growth but risky across member sources | Generic view gets caller-provided segment key builder | +| Renderer reload pressure | Новая находка | Task stream drops stale responses by request seq but still allows parallel IPC calls | Member stream should coalesce in-flight reloads | +| Live tracking activation | Новая находка | `TaskLogStreamSection` subscribes, but `TaskLogsPanel` enables `TeamLogSourceTracker`; member popup has no equivalent parent | Add member stream tracking activation while popup section is mounted | +| IPC option strictness | Новая находка | `TEAM_GET_DATA` rejects unknown option keys before dispatch | Reject unknown member-stream option keys too | +| Participant/source identity | Новая находка | `BoardTaskLogParticipant` is actor identity, while provider/session/lane is source identity | Keep participant actor-based and render provider/session from `segment.source` | +| Since reload semantics | Новая находка | Renderer state replacement plus since-filtered partial response can hide older visible segments | First PR uses full bounded background reloads, not client-side incremental merge | +| Chunk date shape | Средняя уверенность | Renderer group transformer expects Date objects, while tests can use JSON-like fixtures | Keep task stream assumption, add shared normalizer only if needed | +| Parser in-flight ownership | Новая находка | Shared parser can delete another stream's active parse dedupe through `retainOnly()` | Parser ownership is per stream service unless cache API is redesigned | +| OpenCode mapper source | Новая находка | Stall-monitor mapper drops non-string content blocks and `toolUseResult` | Shared mapper source must be `OpenCodeTaskLogStreamSource` | +| Tracker activation | Новая находка | `TeamLogSourceTracker` uses team/consumer reference counts and only runs while consumers are active | Add `member_log_stream` consumer, not a member-specific watcher | +| Runtime lane validation | Новая находка | Existing member validator rejects `:`, but lane ids can be `secondary:opencode:` | Add optional lane validator that preserves exact lane id | +| Generic renderer purity | Новая находка | `TaskLogStreamSection` mixes fetch/debounce/copy/render helpers | Extract render-only view and keep API/fallback/gates in containers | + +## 0. Architecture Boundary Decision + +### Repo Standard Tension + +`docs/FEATURE_ARCHITECTURE_STANDARD.md` говорит, что full feature slice нужен, когда feature spans process boundaries, имеет business rules, transport bridge, more than one adapter и provider roadmap. + +`Member Log Stream V2` подходит под этот стандарт: + +- feature spans main/preload/renderer; +- feature owns merge, dedupe, budget and provider coverage policy; +- feature needs transport wiring; +- feature has multiple source adapters: Claude, OpenCode, Codex skipped/partial later; +- roadmap явно ведет к variant 3/provider extensibility. + +Текущая реальность кода все еще важна: + +- task stream уже живет в `src/main/services/team/taskLogs/stream`; +- member popup уже живет в `src/renderer/components/team/members`; +- team IPC уже централизован в `src/main/ipc/teams.ts`; +- `api.teams` уже содержит `getMemberLogs`, `getTaskLogStream`, `getLogsForTask`. + +Поэтому правильная стратегия не "перетащить весь team logs слой", а создать feature slice для новой member stream capability and keep old surfaces as integration points. + +### Options + +#### A. Canonical feature slice with thin legacy integration + +🎯 8.5 🛡️ 9 🧠 7 +Примерно 1500-2300 LOC. + +Плюсы: + +- соответствует feature standard; +- contracts/core/application/source ports становятся clean architecture boundary; +- provider sources расширяются по OCP; +- старый task stream and legacy `MemberLogsTab` не мигрируются в этом PR; +- app shell imports only public feature entrypoints. + +Минусы: + +- больше файлов и немного больше boilerplate; +- нужно аккуратно сделать compatibility with existing `MemberDetailDialog` and app API; +- нужно добавить import-boundary discipline from the start. + +#### B. Legacy extension in current team services + +🎯 7 🛡️ 6.5 🧠 5 +Примерно 550-850 LOC. + +Плюсы: + +- минимальный blast radius; +- ближе к существующему `Task Log Stream`; +- быстрее внедряется. + +Минусы: + +- легко получить большой `MemberLogStreamService` с provider-specific ветками; +- хуже соответствует feature architecture standard; +- future variant 3 будет сложнее выделять. + +#### C. Existing team surface plus source ports + +🎯 8 🛡️ 8 🧠 6 +Примерно 1300-2050 LOC, если включить ref metadata dedupe, mapper extraction, renderer shared view, member tracking activation, dialog-level fallback tests, OpenCode in-flight protection, renderer reload coalescing и provider-neutral message hygiene extraction. + +Плюсы: + +- renderer-facing API is feature-owned, for example `api.memberLogStream`; existing `api.teams` can remain only as a thin compatibility delegate if needed by current popup wiring; +- implementation остается рядом с existing `taskLogs/stream`; +- provider/runtime логика делится на отдельные source classes; +- часть будущей миграции к feature slice будет подготовлена. + +Минусы: + +- это все еще не full canonical feature slice; +- нужно дисциплинированно не смешать orchestration, provider IO и UI DTO в одном файле. + +### Recommendation + +Выбрать A для первого PR. + +Правило: + +- `src/features/member-log-stream/contracts` owns DTOs, channels, normalize helpers and API fragment types; +- `core/domain` owns pure policies: merge order, budget decisions, dedupe keys and coverage semantics; +- `core/application` owns `MemberLogStreamSource` ports and the use case; +- `main/adapters/output/sources` owns Claude/OpenCode/Codex source adapters; +- `main/adapters/input/ipc` owns validation and IPC translation; +- `preload` owns thin bridge; +- `renderer/ui` owns presentational components only; +- old `MemberLogsTab`, task stream and task exact logs are not migrated in this PR; +- if existing `api.teams` is needed for compatibility, it delegates to feature contracts/use case and does not own DTO/policy. + +### Exact Standard Compliance + +The feature should be reviewable against `docs/FEATURE_ARCHITECTURE_STANDARD.md` without special exceptions: + +- `contracts/` owns only DTOs, API fragment types, channel constants and normalize helpers. +- `core/domain/` owns pure policies: source merge order, source coverage state, dedupe keys, budget decisions and safe source metadata. +- `core/application/` owns use cases and ports: `MemberLogStreamSource`, clock/logger/cache/tracking ports and response models. +- `main/composition/` wires concrete adapters and exposes `MemberLogStreamFeatureFacade`. +- `main/adapters/input/ipc/` owns IPC validation and transport translation. +- `main/adapters/output/sources/` owns Claude/OpenCode/Codex source adapters. +- `main/infrastructure/` owns runtime bridge helpers, parser wrappers, TTL/in-flight cache and filesystem details. +- `preload/` owns only a thin feature bridge. +- `renderer/hooks/` owns API calls, tracking activation, team-change subscriptions and reload coalescing. +- `renderer/ui/` owns presentational components only. + +Clean Architecture dependency rule: + +- domain imports no application/adapters/infrastructure/framework/process code; +- application imports no main/preload/renderer or concrete IO; +- adapters import inward to application/domain and outward to infrastructure; +- renderer UI receives props and view models, never direct API/store/Electron access; +- app shell imports only public entrypoints. + +SOLID and DRY application: + +- SRP: `GetMemberLogStreamUseCase` coordinates use case flow only; provider IO stays in source adapters; renderer UI only renders. +- OCP: adding Codex partial/full later means adding or swapping a source adapter, not rewriting renderer or use case branches. +- LSP: all sources honor the same `included | partial | skipped` result shape and fail softly for expected absence. +- ISP: core ports stay narrow. Do not pass `TeamDataService`, Electron events or renderer member snapshots through core. +- DIP: use cases depend on ports; concrete finder/bridge/parser implementations live outside core. +- DRY: one DTO owner in feature contracts, one stream render primitive, one OpenCode projection mapper, one provider-neutral message hygiene helper set. + +Lint status: + +- Generic feature guard rails already exist in `eslint.config.js` for `src/features/*`. +- Implementation should rely on those first and add member-log-stream-specific guard rails only if generic messages are not strict enough. +- Targeted lint command: `pnpm exec eslint src/features/member-log-stream --cache --cache-location .eslintcache --cache-strategy content`. + +Project standard cross-check: + +- Top-level `CLAUDE.md` says new medium/large features default to `src/features/` and must follow `docs/FEATURE_ARCHITECTURE_STANDARD.md`. +- `docs/FEATURE_ARCHITECTURE_STANDARD.md` requires the full slice when a feature spans process boundaries, owns business policy, has transport wiring, has multiple adapters or has a provider roadmap. Member log stream matches all five. +- `eslint.config.js` already enforces generic public-entrypoint, core-domain, core-application, preload and renderer-UI guards for `src/features/*`. +- `src/features/recent-projects` confirms the public entrypoint pattern: `contracts/index.ts`, `main/index.ts`, `preload/index.ts` and `renderer/index.ts` expose only supported surface. +- `src/features/CLAUDE.md` is referenced by `CLAUDE.md`, but is absent in this worktree, so the binding standard for this plan is the top-level `CLAUDE.md` plus `docs/FEATURE_ARCHITECTURE_STANDARD.md`. + +## 0.1 Source-Port Design + +Чтобы не нарушить SRP/OCP, `GetMemberLogStreamUseCase` не должен сам знать все детали Claude/OpenCode/Codex. + +Нужен internal interface: + +```ts +interface MemberLogStreamSourceInput { + teamName: string; + memberName: string; + laneId?: string; + budget: MemberLogStreamBudget; + sinceMs?: number | null; + forceRefresh?: boolean; +} + +interface MemberLogStreamSourceResult { + provider: MemberLogStreamProvider; + status: 'included' | 'partial' | 'skipped'; + segments: MemberLogStreamSegment[]; + warnings: MemberLogStreamWarning[]; +} + +interface MemberLogStreamSource { + readonly provider: MemberLogStreamProvider; + load(input: MemberLogStreamSourceInput): Promise; +} +``` + +First PR sources: + +- `ClaudeMemberTranscriptStreamSource`; +- `OpenCodeMemberRuntimeStreamSource`; +- `CodexNativeMemberTraceStreamSource` only as skipped coverage adapter, no heavy trace scan. + +`GetMemberLogStreamUseCase` responsibilities: + +- normalize already-validated options; +- call sources fail-soft, preferably with `Promise.allSettled()` plus deterministic merge order; +- merge source results; +- enforce global budget; +- sort final segments; +- build response metadata and warnings. + +It should not: + +- parse OpenCode CLI output directly; +- scan Codex trace directories directly; +- know renderer copy; +- know old `MemberLogsTab` fallback details. + +## 0.2 Renderer Performance Budget + +### Facts + +`MemberExecutionLog`: + +- transforms all chunks into conversation groups; +- reverses all groups; +- renders all groups; +- keeps everything expanded by default unless user collapses groups; +- has no virtualization. + +Repo already depends on `@tanstack/react-virtual`, and `ActivityTimeline` uses it. But adding virtualization to log stream in first PR would be a separate UI behavior project. + +### Decision + +Do not add virtualization in first PR. + +Instead enforce backend budget: + +```ts +const DEFAULT_MEMBER_LOG_STREAM_BUDGET = { + maxTranscriptFiles: 40, + maxSegments: 30, + maxChunks: 250, + maxSourceMessages: 1200, + maxMessagesPerSegment: 300, + maxTotalContentChars: 800_000, + maxMessageContentChars: 80_000, + maxToolResultContentChars: 120_000, + openCodeMessageLimit: 400, + openCodeTimeoutMs: 5_000, +}; +``` + +Budget semantics: + +- candidate transcript refs are newest-first before capping; +- source segments are sorted by timestamp before final response; +- global merge keeps newest useful content if sources exceed budget; +- message budget is enforced before chunk build when one file/session is too large; +- chunk budget is enforced after chunk build by dropping oldest whole chunks/segments first; +- response includes `truncated: true` and warning `large_log_window_limited` when any cap is hit; +- renderer shows a short "showing recent log stream" note from warnings. +- content-char budgets are enforced before chunk build so a single huge markdown/tool result cannot freeze the popup after expansion. + +Why this is safer: + +- prevents popup freezes; +- avoids new virtualization bugs; +- keeps v2 focused on correct data/source behavior. + +Future: + +- add `Load older` only after source cursor semantics exist; +- add virtualization only if product needs audit-sized member streams in popup. + +## 0.2.1 Pair-Aware Truncation + +### Facts + +`ChunkBuilder` groups all "AI" category messages until the next real user/system/compact boundary. A single AI chunk can contain many assistant messages, tool calls and tool results. + +So this is not enough: + +```ts +maxChunks: 250 +``` + +One chunk can still become a large `displayItems` list in `MemberExecutionLog`. + +### Decision + +Add message-level budget: + +```ts +maxSourceMessages: 1200; +maxMessagesPerSegment: 300; +``` + +Truncation should happen in this order: + +1. Dedupe transcript refs. +2. Sort candidate refs newest-first and cap files. +3. Parse selected files. +4. For each file/session, trim parsed messages before chunk build if it exceeds `maxMessagesPerSegment`. +5. Build chunks. +6. Drop oldest whole segments/chunks to satisfy `maxSegments`, `maxChunks`, and `maxSourceMessages`. + +Pair-aware rule: + +- if retaining a meta user tool-result message with `sourceToolUseID`, also retain the assistant message containing the matching `toolCalls.id`; +- if retaining the matching assistant would exceed the hard message budget, drop the orphan result instead of rendering an unpaired result; +- preserve chronological order after expansion; +- mark response `truncated: true`; +- add warning `segment_message_window_limited`. + +Do not attempt text-level truncation inside `EnhancedChunk` in first PR. It is safer to drop old message windows than to mutate renderer-specific chunk internals. + +## 0.2.3 Content-Size Budget + +### Facts + +`MemberExecutionLog` renders AI groups expanded by default. `DisplayItemList` previews are short, but expanded items can render full markdown/tool result content. So these budgets are not sufficient by themselves: + +```ts +maxChunks: 250; +maxMessagesPerSegment: 300; +``` + +One tool result can still contain hundreds of thousands of characters. + +### Decision + +Add content budgets before chunk build: + +```ts +maxTotalContentChars: 800_000; +maxMessageContentChars: 80_000; +maxToolResultContentChars: 120_000; +``` + +Rules: + +- apply after message-window trimming and before `BoardTaskExactLogChunkBuilder.buildBundleChunks()`; +- preserve `uuid`, `sourceToolUseID`, `sourceToolAssistantUUID`, tool call ids and tool result ids; +- replace oversized text/content fields with a short placeholder that states the content was truncated for popup display; +- do not mutate file-backed parser cache arrays in place. Clone the affected `ParsedMessage` objects before truncating; +- set `truncated: true`; +- add warning `message_content_limited`. + +Risk rating: + +🎯 8 🛡️ 8 🧠 5 +Approx 120-220 LOC. + +This is safer than relying on UI collapse/expand behavior, because the current execution log starts expanded. + +## 0.2.4 ParsedMessage Hygiene Boundary + +### Facts + +`BoardTaskLogStreamService` already has useful private message hygiene helpers: + +- `cloneBlock()`; +- `cloneMessageContent()`; +- `mergeMessages()`; +- `pruneEmptyInternalToolResultMessages()`; +- `retainSyntheticToolUseAssistants()`. + +It also has task-specific cleanup: + +- `sanitizeJsonLikeToolResultPayloads()`; +- `sanitizeToolResultContent()`; +- `sanitizeToolResultPayloadValue()`. + +That task cleanup is not fully provider-neutral. It is designed around board/task tool payloads and can replace JSON-like tool result payloads with `''` when it cannot extract a board-tool display string. In member-wide logs, a JSON-looking tool result can be a legitimate Bash/API/tool output. Applying task cleanup wholesale would risk hiding useful member logs. + +### Options + +#### A. Reuse task sanitization as-is for member stream + +🎯 5 🛡️ 4 🧠 3 +Approx 20-80 LOC. + +Fast, but unsafe. It can hide useful non-board JSON outputs in a member-wide stream. + +#### B. Extract provider-neutral hygiene and add member-specific truncation + +🎯 8.5 🛡️ 9 🧠 6 +Approx 180-340 LOC. + +Create a small shared main-process helper, for example: + +```ts +src/main/services/team/taskLogs/stream/ParsedMessageStreamHygiene.ts +``` + +Provider-neutral exports: + +- clone message/content blocks without mutating parser-cache objects; +- prune empty internal tool-result messages; +- retain synthetic tool-use assistants by clearing only the synthetic model marker; +- count/truncate message content by char budget; +- preserve `uuid`, `toolCalls.id`, `toolResults.toolUseId`, `sourceToolUseID`, `sourceToolAssistantUUID` and `toolUseResult.toolUseId`. + +Keep board/task cleanup either private to `BoardTaskLogStreamService` or export it with a name that makes the scope explicit, such as `sanitizeBoardToolResultPayloads()`. + +#### C. Skip content hygiene and rely only on message-window limits + +🎯 6 🛡️ 5 🧠 2 +Approx 0-60 LOC. + +Too weak. One retained tool result can still be huge. + +### Decision + +Use option B. + +Implementation rules: + +- do not run board-specific JSON payload cleanup across all member logs; +- truncate oversized strings/arrays recursively but preserve linking ids; +- when truncating `toolUseResult.content`, `toolUseResult.message`, `toolUseResult.file.content`, `oldString`, `newString`, or `stderr/stdout`, keep the surrounding object shape; +- replace text with a compact placeholder that includes original char count and retained char count; +- clone only changed messages to keep memory reasonable; +- test with a JSON Bash output to confirm it is truncated if huge but not blanked just because it is JSON. + +Risk rating: + +🎯 8.5 🛡️ 9 🧠 6 +Approx 180-340 LOC. + +## 0.2.2 MemberExecutionLog Process Filtering + +### Facts + +`MemberExecutionLog` passes `memberName` into `AIExecutionGroup`. + +`AIExecutionGroup` filters only `group.processes` by `p.team?.memberName`; it does not filter the raw AI steps or tool executions. + +That means: + +- OpenCode projection segments with no `Process[]` still render normal tools/output; +- Claude chunks with team `Process[]` avoid showing another member's subagent panels; +- synthetic/fake `Process` objects are not required for member stream. + +### Decision + +Do not synthesize fake `Process` entries for OpenCode member stream. + +Render OpenCode through normal tool/output chunks from projected messages. Only use `Process[]` when real process metadata already exists. + +Add a renderer regression test that a segment with `actor.memberName` and no `processes` still displays tool/output items. + +## 0.3 IPC Validation And Limits + +### Facts + +Existing validators: + +- `validateTeamName` fits `teamName`; +- `validateMemberName` fits member names; +- `validateTaskId` is task-only; +- `laneId` can contain `:`, for example `secondary:opencode:alice`, so `validateMemberName` is wrong for lane. + +### Correct IPC Policy + +Add a local helper near the handler, or a shared guard if reused: + +```ts +export function validateOptionalRuntimeLaneId(value: unknown) { + if (value == null) return { valid: true, value: undefined }; + if (typeof value !== 'string') return { valid: false, error: 'laneId must be a string' }; + const trimmed = value.trim(); + if (!trimmed) return { valid: true, value: undefined }; + if (trimmed.length > 256) return { valid: false, error: 'laneId exceeds max length (256)' }; + if (/[\0-\x1F\x7F/\\]/.test(trimmed)) { + return { valid: false, error: 'laneId contains invalid characters' }; + } + return { valid: true, value: trimmed }; +} +``` + +Options policy: + +- `limitSegments`: integer, clamp to `1..80`, default 30; +- `since`: if present, must parse as valid date, otherwise return IPC error; +- `laneId`: optional, max 256, no control chars or path separators, allow `primary` and colon-separated ids like `secondary:opencode:alice`; +- `forceRefresh`: optional boolean; +- unknown option keys rejected. + +Do not lowercase or otherwise normalize `laneId`. It should be trimmed only, because the orchestrator/runtime record lookup may treat lane ids as exact identities. + +Additional code research: `TEAM_GET_DATA` validates options with an allow-list and rejects unknown keys before dispatching to the service/worker. Member log stream should follow that stricter style because option typos can otherwise disable `laneId`, `since` or `forceRefresh` silently. + +Recommended allow-list: + +```ts +const allowed = new Set(['limitSegments', 'since', 'laneId', 'forceRefresh']); +``` + +Return `Unknown getMemberLogStream option: ${key}` for extra keys. + +Security: + +- do not expose transcript file paths in renderer response; +- warnings can mention counts and provider names, not absolute paths; +- service can log paths to main logger if needed. + +## 0.4 Exact Stream DTO And Renderer Contract + +### Facts + +`BoardTaskLogSegment` is already the right low-level render unit: + +```ts +interface BoardTaskLogSegment { + id: string; + participantKey: string; + actor: BoardTaskLogActor; + startTimestamp: string; + endTimestamp: string; + chunks: EnhancedChunk[]; +} +``` + +`MemberExecutionLog` only needs `EnhancedChunk[]` plus optional member visual props. It does not know task ids, provider ids, or source metadata. + +Additional type research: + +- `BoardTaskLogStreamResponse.source` is a task-only union: transcript, OpenCode task runtime fallback/attribution and Codex task trace fallback values. +- `BoardTaskLogStreamResponse.runtimeProjection` is also task-specific, with attribution/heuristic/trace counters that are not the right member coverage model. +- `BoardTaskLogSegment` has no `source` field, so it is safe as the render primitive but not enough as the member DTO by itself. +- Therefore `MemberLogStreamResponse` should be a standalone shared response type, not `extends BoardTaskLogStreamResponse` and not an `Omit` unless the omitted fields are fully replaced. + +Recommended DTO shape: + +```ts +type MemberLogStreamSource = + | 'member_transcript' + | 'member_mixed_runtime' + | 'member_runtime_only' + | 'member_empty'; + +interface MemberLogStreamResponse { + participants: BoardTaskLogParticipant[]; + defaultFilter: 'all' | string; + segments: MemberLogStreamSegment[]; + source: MemberLogStreamSource; + coverage: MemberLogStreamCoverage[]; + warnings: MemberLogStreamWarning[]; + truncated: boolean; + generatedAt: string; + metadata: MemberLogStreamMetadata; +} +``` + +This keeps task stream source semantics untouched and avoids having member-only source values leak into task UI copy or task tests. + +`TaskLogStreamSection` currently owns too much: + +- response normalization through `asEnhancedChunkArray`; +- participant visual mapping; +- segment key building; +- segment headers; +- participant chips; +- loading, empty and error copy; +- live reload behavior; +- task-specific `describeStreamSource()` text. + +### Decision + +Extract generic render-only view, not a generic data loader: + +```ts +interface ExecutionLogStreamViewProps { + title: string; + description: string; + stream: TStream | null; + loading: boolean; + error: string | null; + emptyTitle: string; + emptyDescription?: string; + teamName: string; + forceSegmentHeaders?: boolean; + boundedHistoryNote?: string | null; +} +``` + +Keep loaders separate: + +- `TaskLogStreamSection` loads task stream and keeps task-status reload behavior. +- `MemberLogStreamSection` loads member stream and passes `laneId`/budget options. + +This avoids accidentally bringing task-specific reload and copy into member popup. + +## 0.4.1 Member Segment Metadata + +### Facts + +`BoardTaskLogSegment` is enough for rendering chunks, but it does not identify provider source or safe session/lane labels: + +```ts +interface BoardTaskLogSegment { + id: string; + participantKey: string; + actor: BoardTaskLogActor; + startTimestamp: string; + endTimestamp: string; + chunks: EnhancedChunk[]; +} +``` + +For task stream this is acceptable because the whole section is task-scoped and source text is global. For member stream, multiple Claude sessions and OpenCode lane projection can appear in one popup. Without segment metadata, the UI either hides important context or parses `segment.id`, which is brittle. + +### Decision + +Use a member-specific extension: + +```ts +interface MemberLogStreamSegmentSource { + provider: MemberLogStreamProvider; + label: string; + sessionId?: string; + laneId?: string; + messageCount?: number; + truncated?: boolean; +} + +interface MemberLogStreamSegment extends BoardTaskLogSegment { + source: MemberLogStreamSegmentSource; +} +``` + +Then: + +- `MemberLogStreamResponse.segments` uses `MemberLogStreamSegment[]`; +- `MemberLogStreamResponse` stays standalone and does not extend `BoardTaskLogStreamResponse`; +- `ExecutionLogStreamView` remains generic over base `BoardTaskLogSegment`; +- `MemberLogStreamSection` can pass a segment label renderer that uses `segment.source`; +- task stream stays unchanged; +- segment source metadata never includes absolute file paths. +- segment ids use provider, normalized team/member, session id and a short hash/fingerprint, not raw absolute paths. + +Risk rating: + +🎯 8.5 🛡️ 8.5 🧠 4 +Approx 40-90 LOC. + +This is safer than overloading `segment.id` or adding provider labels into chunk text. + +Path rule: + +- hash `filePath + mtimeMs + sizeBytes` if a file fingerprint is needed; +- never send `filePath` to renderer in `id`, `source`, `warnings` or `metadata`; +- main logger may record file paths for diagnostics, but response DTO should not. + +## 0.4.2 Chunk Date Shape + +### Facts + +`MemberExecutionLog` calls `transformChunksToConversation()`, and `groupTransformer` uses `Date` methods on chunk and semantic-step timestamps. Existing task stream works because Electron IPC/preload returns structured-clone data and the service tests usually pass Date-shaped chunks. + +Additional code research: `src/renderer/api/httpClient.ts` already has an ISO date JSON reviver and comments that Electron IPC preserves `Date` instances via structured clone while HTTP JSON needs rehydration. So the runtime date risk is lower than expected for normal app and browser-mode API paths. + +The remaining risk is mostly tests and ad hoc fixtures. Renderer unit tests often use lightweight segment fixtures, and browser fallback has no real chunks. If the new generic view starts accepting JSON-like chunk fixtures with ISO strings without going through the HTTP client's reviver, it can fail in `durationMs = endTime.getTime() - startTime.getTime()`. + +### Decision + +Do not add broad date rehydration by default in the first implementation. Keep the same runtime assumption as task stream. + +But add a clear guardrail: + +- `ExecutionLogStreamView` tests should use Date-shaped `EnhancedChunk` fixtures when they expect real rendering; +- if a JSON-like fixture is needed, add one shared helper such as `normalizeEnhancedChunkDates()` and use it from both task/member stream normalization; +- do not scatter ad hoc `new Date()` calls across render components. + +Updated risk rating: + +🎯 9 🛡️ 8.5 🧠 3 +Approx 0-120 LOC depending on whether a normalizer is needed. + +This keeps variant 2 aligned with existing task stream behavior without hiding a serialization assumption. + +## 0.4.3 Renderer Normalization And Source Headers + +### Facts + +`TaskLogStreamSection.normalizeResponse()` currently reconstructs only known task-stream fields: + +```ts +return { + participants: response.participants, + defaultFilter: response.defaultFilter, + source: response.source, + runtimeProjection: response.runtimeProjection, + segments: ... +}; +``` + +If this exact function is reused for `MemberLogStreamResponse`, it can accidentally drop member-only fields: + +- `coverage`; +- `warnings`; +- `truncated`; +- `generatedAt`; +- `metadata`; +- `segment.source`. + +`TaskLogStreamSection` also hides segment headers when there is only one participant: + +```ts +participants.length > 1 || selectedParticipantKey !== 'all' +``` + +That is correct for task logs, but wrong for member logs. Member stream often has one participant, while the important context is per-segment provider/session/lane source. + +Additional UI-model research: `BoardTaskLogParticipant` is actor identity. The renderer uses participant labels for chips, member badges and color lookup. Provider/runtime/session identity is a different axis. If member stream encodes provider/session as participant keys, one selected member can appear as multiple people and the "All" filter becomes misleading. + +### Options + +#### A. Reuse task view logic as-is + +🎯 5 🛡️ 4 🧠 2 +Approx 40-100 LOC. + +Low effort, but it can hide provider/session labels and silently drop response metadata. + +#### B. Generic view preserves stream shape and accepts a segment-header renderer + +🎯 9 🛡️ 9 🧠 5 +Approx 120-240 LOC. + +The generic view should: + +- normalize chunks with `asEnhancedChunkArray`; +- preserve the rest of the stream object with object spread, including `coverage`, `warnings`, `metadata`, `truncated`, `generatedAt` and `segment.source`; +- let callers pass `forceSegmentHeaders`; +- let callers pass `renderSegmentMarker` or `getSegmentMetaLabel`; +- keep task copy/source text in `TaskLogStreamSection`; +- keep member provider/session labels in `MemberLogStreamSection`. +- not import `api.teams`, feature gates, `MemberLogsTab`, provider sources or task/member loading hooks; +- receive already loaded `teamMembers` from the container so the pure view can be tested without the global store; +- keep fallback UI outside the generic view, because fallback policy is different for task vs member. + +For member stream: + +- `forceSegmentHeaders: true`; +- label each segment with `segment.source.label`; +- optionally show safe `sessionId` short prefix or `laneId`, never file path; +- show bounded-history/coverage warnings outside the repeated segment blocks. +- keep `participantKey` actor-based, for example `member:`, and do not create `claude:`/`opencode:` pseudo-participants; +- if source filtering is needed later, add a separate source filter instead of overloading participant chips. + +Additional renderer key research: current `TaskLogStreamSection.buildStableSegmentRenderKey()` uses `participantKey:firstChunkId`, not `segment.id`. That preserves expanded/collapsed React state when a live refresh extends the tail of the same segment and changes the segment id. Existing tests cover this behavior. + +For member stream, the same default key can collide more easily because different providers/sessions can have one selected participant and similar first chunk ids. Do not change the task default blindly. + +Add a caller-provided key strategy: + +```ts +buildSegmentRenderKey?: (segment: TSegment) => string; +``` + +Rules: + +- task stream default keeps current `participantKey:firstChunkId` behavior; +- member stream passes a source-aware key such as `${segment.id}:${segment.chunks[0]?.id ?? segment.startTimestamp}`; +- `segment.id` must already be path-safe and stable across refreshes; +- tests should cover both task tail-growth state preservation and member source collision avoidance. + +#### C. Duplicate task stream UI into member component + +🎯 7 🛡️ 6 🧠 4 +Approx 180-320 LOC. + +Works quickly, but drift from task stream UI will grow and future variant 3 migration gets harder. + +### Decision + +Use option B. + +Risk rating: + +🎯 9 🛡️ 9 🧠 5 +Approx 160-300 LOC. + +## 0.4.4 Renderer Request Coalescing + +### Facts + +`TaskLogStreamSection` uses `requestSeqRef` to ignore stale responses. That prevents stale UI writes, but it does not prevent parallel IPC calls when visibility reload, `log-source-change`, and `task-log-change` happen close together. + +For task logs this is acceptable because the source is task-scoped. For member stream, one request can parse many files and may also call OpenCode runtime transcript. Parallel duplicate calls are a bigger performance risk. + +### Decision + +`MemberLogStreamSection` should coalesce active loads: + +- keep one active request for the current `teamName/memberName/laneId/options` key; +- if a background reload is requested while active, set a pending reload flag instead of starting a second IPC call; +- when the active request finishes, run at most one pending background reload; +- if the component unmounts or key changes, ignore old results and clear pending reload state; +- initial foreground load still shows loading, background reload keeps old stream visible. + +This complements backend/source in-flight protection. It is not a replacement for it, because multiple windows or tests can still call IPC directly. + +Risk rating: + +🎯 8.5 🛡️ 8.5 🧠 5 +Approx 80-160 LOC with tests. + +## 0.4.4.1 Since And Full-Response Replacement + +### Facts + +`getMemberLogStream` options include `since` as a useful backend performance hint. But the renderer state model is full response replacement: `setStream(response)`. A since-filtered response is partial unless the API explicitly says it includes enough prior segments to replace the visible stream. + +### Decision + +First PR should not do client-side incremental merge. + +- Initial load requests a full bounded stream. +- Background reload requests a full bounded stream too. +- `log-source-change` background reload adds `forceRefresh: true`, but not `since`. +- `since` remains validated at IPC and covered in service tests, but UI should not use it for replacement reloads until there is an explicit merge contract. +- If incremental reload is added later, response metadata must say whether it is `complete` or `partial`, and renderer must merge by source-aware segment id. + +Risk rating: + +🎯 8.5 🛡️ 9 🧠 3 +Approx 0-60 LOC, mostly tests and avoiding a tempting optimization. + +This prevents a subtle UI regression where fresh logs appear, but older visible segments disappear after a background reload. + +## 0.4.5 Member Popup Fallback Boundary + +### Facts + +`MemberDetailDialog` currently renders old logs directly: + +```tsx + +``` + +The same old `MemberLogsTab` is also used by task logs through `ExecutionSessionsSection`, where it is labeled as legacy session-centric transcript browsing. That means changing `MemberLogsTab` itself is not a member-popup-only change. + +Existing dialog tests live at: + +- `test/renderer/components/team/members/MemberDetailDialog.test.ts` + +That test file mocks `MemberLogsTab`, so replacing the import without updating the mock can cause noisy test failures that are unrelated to the stream logic. + +### Options + +#### A. Replace `MemberLogsTab` internals with new stream + +🎯 5 🛡️ 5 🧠 4 +Approx 120-260 LOC. + +This looks small, but it affects task `Execution Sessions` too. It also removes the clean rollback path. + +#### B. Gate at `MemberDetailDialog` and keep `MemberLogsTab` untouched + +🎯 9 🛡️ 9 🧠 4 +Approx 80-180 LOC. + +This is the safest first implementation: + +- renderer feature gate on: render `MemberLogStreamSection`; +- renderer feature gate off: render old `MemberLogsTab`; +- initial stream error: show error and explicit old logs fallback; +- background refresh error: keep last good stream and surface the error state unobtrusively. + +Task `Execution Sessions` remains untouched because it still imports `MemberLogsTab` directly. + +#### C. Let `MemberLogStreamSection` import and own `MemberLogsTab` + +🎯 6 🛡️ 6 🧠 5 +Approx 100-220 LOC. + +This hides fallback inside the new component, but makes the boundary muddier. It also makes it easier to accidentally couple legacy UI copy and new stream copy. + +### Decision + +Use option B. + +Implementation guardrails: + +- keep fallback decision in `MemberDetailDialog`, not inside the stream renderer; +- do not change `MemberLogsTab` behavior in the first PR; +- add a `MemberLogStreamSection` mock in `MemberDetailDialog` tests; +- test gate-on and gate-off rendering; +- test that first-load stream failure keeps the Logs tab active and shows explicit old logs fallback; +- keep `ExecutionSessionsSection` expectations unchanged. + +Risk rating: + +🎯 9 🛡️ 9 🧠 4 +Approx 80-180 LOC. + +This reduces the highest accidental renderer regression risk without changing the backend design. + +## 0.5 Cumulative Subagent Snapshot Dedupe + +### Facts + +`findLogsForTask()` already has this warning in code: + +- in-process teammates can produce cumulative JSONL snapshots; +- the largest file is a superset of smaller files; +- task flow dedupes subagent snapshots by `sessionId + memberName` and keeps the largest `messageCount`. + +But `findRecentMemberLogFileRefsByMember()` currently returns: + +```ts +interface MemberLogFileRef { + memberName: string; + sessionId: string; + filePath: string; + mtimeMs: number; +} +``` + +It dedupes only exact `filePath`. That is not enough for member stream, because parsing multiple cumulative snapshots can duplicate the same turn several times. + +Another discovered edge case: attribution uses `knownMembers`, built from current config, member meta and inbox names. If the popup is opened for a historical/removed member that is no longer present in those sources, syntax-only IPC validation still will not make attribution recognize that name. + +### Options + +#### A. Use refs as-is and rely on chunk/message ids + +🎯 5 🛡️ 5 🧠 3 +Approx 0-80 LOC. + +Low implementation cost, but duplicate stream entries are likely in cumulative snapshot cases. + +#### B. Extend `MemberLogFileRef` with optional metadata and dedupe before parse + +🎯 8 🛡️ 8 🧠 5 +Approx 120-220 LOC. + +Add optional fields without breaking existing callers: + +```ts +interface MemberLogFileRef { + memberName: string; + sessionId: string; + filePath: string; + mtimeMs: number; + sizeBytes?: number; + messageCount?: number; + kind?: 'lead_session' | 'member_session' | 'subagent'; +} +``` + +Then `ClaudeMemberTranscriptStreamSource` can: + +- group subagent refs by `memberName + sessionId`; +- keep largest `messageCount` when available, otherwise largest `sizeBytes`, tie-break by newest `mtimeMs`; +- keep root member session refs separately; +- cap after dedupe, not before. + +Do not stream every candidate only to compute `messageCount` in first PR. Use it when a caller already has it; otherwise `sizeBytes` is the cheap proxy for cumulative JSONL snapshots. + +Also change `findRecentMemberLogFileRefsByMember()` so it adds requested member names to the attribution set before calling `getCachedSubagentAttribution()` / `getCachedMemberSessionAttribution()`. + +Suggested shape: + +```ts +const attributionMembers = new Set(knownMembers); +for (const key of requestedMembersByKey.keys()) { + attributionMembers.add(key); +} +``` + +Use `attributionMembers` only for attribution. Keep the returned `memberName` as the caller's requested casing from `requestedMembersByKey`. + +#### C. Parse all refs, then dedupe by message uuid + +🎯 6 🛡️ 7 🧠 7 +Approx 180-350 LOC. + +More robust when metadata is missing, but expensive and can still miss duplicates if synthetic/tool result uuids differ. + +### Recommendation + +Use B. + +This is the most important correction to the earlier plan. Without it, variant 2 can be correct by attribution but noisy by duplication. + +Add the requested-member attribution augmentation to B. It is small, but it is what makes removed-member popup logs actually possible instead of only syntactically allowed. + +## 0.6 OpenCode Projection Mapper Boundary + +### Facts + +`ClaudeMultimodelBridgeService.getOpenCodeTranscript()` returns `transcript.logProjection.messages`. + +`OpenCodeTaskLogStreamSource` already has working private mapping from `OpenCodeRuntimeTranscriptLogMessage` to `ParsedMessage`: + +- content block conversion; +- tool call/result conversion; +- `sourceToolUseID`; +- `sourceToolAssistantUUID`; +- `toolUseResult`; +- sanitized text content. + +There is also a separate `toParsedMessage()` in `OpenCodeTaskStallEvidenceSource`, but it is not equivalent. It maps non-string content to `[]` and does not build `toolUseResult`. That is acceptable for stall evidence rows, but not for a user-visible log stream. + +But that file also contains task-specific logic: + +- task marker matching; +- task windows; +- attribution records; +- foreign team marker filtering; +- task fallback heuristics. + +### Options + +#### A. Copy the private mapper into member source + +🎯 7 🛡️ 6 🧠 4 +Approx 150-220 LOC. + +Fast, but mapper drift is likely. + +#### B. Extract generic mapper and make both task/member sources use it + +🎯 8.5 🛡️ 8.5 🧠 6 +Approx 180-300 LOC. + +Create a small file near stream sources, for example: + +```ts +// src/main/services/team/taskLogs/stream/OpenCodeRuntimeProjectionMapper.ts +export function mapOpenCodeProjectionMessagesToParsedMessages( + messages: OpenCodeRuntimeTranscriptLogMessage[] +): ParsedMessage[]; + +export function countProjectionToolCalls(messages: ParsedMessage[]): ProjectionToolCounts; +``` + +Only move generic projection conversion. Do not move task marker/window logic. + +Extraction source must be `OpenCodeTaskLogStreamSource`, not `OpenCodeTaskStallEvidenceSource`. + +Implementation detail: + +- first move the task-source mapper to the shared file without behavior changes; +- update `OpenCodeTaskLogStreamSource` to call the shared mapper; +- then let `OpenCodeMemberRuntimeStreamSource` call the same mapper; +- leave stall monitor as-is unless a separate test-backed cleanup proves identical behavior is intended there. + +#### C. Reuse `OpenCodeTaskLogStreamSource` + +🎯 3 🛡️ 3 🧠 4 +Approx 80-160 LOC. + +Wrong abstraction. It is task-scoped by design. + +### Recommendation + +Use B. This is a small refactor, but it reduces long-term drift and gives member stream the exact same OpenCode rendering semantics as task stream. + +Important: do not use the stall-monitor mapper as the shared base. That would make the implementation look shared while silently degrading member stream content rendering. + +## 0.6.1 OpenCode Runtime Call Budget + +### Facts + +`OpenCodeTaskLogStreamSource` already protects task stream fallback with: + +- a small cache keyed by task/window/attribution; +- an `inFlight` map so concurrent calls join the same bridge request; +- `CACHE_TTL_MS = 1_500`; +- transcript limits of `200` for heuristic and `500` for attributed mode. + +Member stream will reload on same-team `log-source-change` and `task-log-change`. Even with debounce, those events can arrive close together while a popup is open. Calling `runtime transcript` for every refresh can make the popup feel slow and can add unnecessary process churn. + +### Options + +#### A. No OpenCode member-source cache + +🎯 7 🛡️ 6 🧠 2 +Approx 0-40 LOC. + +Simple, but repeated refreshes can spawn repeated bridge calls. Timeout protects the popup from hanging, but not from extra work. + +#### B. Add member-source TTL cache and in-flight join + +🎯 8.5 🛡️ 8.5 🧠 4 +Approx 80-160 LOC. + +Use a cache key like: + +```txt +teamName::memberName::laneId-or-none::limit +``` + +Rules: + +- default TTL `1_500ms`, matching task OpenCode fallback; +- `forceRefresh` can bypass completed cache, but should still join an existing in-flight request for the same key; +- cache both `null` and successful responses briefly, because repeated failures should not spawn repeated CLI calls; +- do not share this cache with `OpenCodeTaskLogStreamSource`; +- keep timeout handling inside the bridge/source, not renderer. + +#### C. Add a long cache in `ClaudeMultimodelBridgeService` + +🎯 5 🛡️ 5 🧠 6 +Approx 120-240 LOC. + +This centralizes caching but risks changing behavior for status/stall/task callers that use the same bridge for different freshness expectations. + +### Decision + +Use option B. + +This keeps OpenCode member stream responsive without changing bridge semantics for other callers. + +Risk rating: + +🎯 8.5 🛡️ 8.5 🧠 4 +Approx 80-160 LOC. + +## 0.7 Live Refresh Policy + +### Facts + +`TeamChangeEvent` has: + +- `log-source-change` without task id; +- `task-log-change` with `taskId`, but no `memberName`; +- `tool-activity`, which can be frequent and is intended for live tool indicators; +- no event that means "logs for member X changed". + +Task stream can reload only for one `taskId`. Member stream cannot do that safely. + +Additional code research: task logs do not rely on `onTeamChange` subscription alone. + +- `TaskLogStreamSection` listens to `onTeamChange` and schedules reloads. +- `TaskLogsPanel` separately calls `api.teams.setTaskLogStreamTracking(teamName, true)` while task log activity tracking is relevant. +- That IPC maps to `TeamLogSourceTracker.enableTracking(teamName, 'task_log_stream')`. +- `TeamLogSourceTracker` only watches transcript/log freshness sources while at least one consumer is active. +- Member popup has no `TaskLogsPanel` parent, so just adding `onTeamChange` in `MemberLogStreamSection` can miss fresh events when no other UI has enabled tracking. + +### Decision + +For an open member popup: + +- enable log-source tracking while the stream section is mounted; +- reload on same-team `log-source-change`; +- pass `forceRefresh: true` for `log-source-change`, because `TeamMemberLogsFinder` discovery cache has a 30s TTL; +- do not pass `since` from renderer for replacement reloads in the first PR; +- reload on same-team `task-log-change`, because it is still a log-source freshness signal; +- do not reload on `tool-activity`; +- debounce at least as strongly as task stream, preferably 500-750ms for member stream; +- do not clear existing stream on background refresh failure; +- reload on visibility return only if popup is still open. + +Risk rating: + +🎯 8 🛡️ 8 🧠 4 +Approx 40-80 LOC. + +This may reload for tasks owned by other members, but only while the member popup is open and with debounce. That is safer than missing fresh logs because `task-log-change` lacks member attribution. + +### Tracking Activation Options + +#### A. Reuse `setTaskLogStreamTracking()` from member popup + +🎯 8 🛡️ 7 🧠 2 +Approx 20-50 LOC. + +This works because it increments the same `task_log_stream` consumer count. The downside is semantic drift: member UI would call a task-named API. + +#### B. Add `setMemberLogStreamTracking()` mapped to a new tracker consumer + +🎯 8.5 🛡️ 9 🧠 4 +Approx 90-170 LOC. + +Add: + +- feature `MEMBER_LOG_STREAM_SET_TRACKING` channel; +- feature API method or thin compatibility delegate; +- feature preload bridge and browser no-op fallback; +- `TeamLogSourceTrackingConsumer | 'member_log_stream'`; +- IPC handler that calls `TeamLogSourceTracker.enableTracking(teamName, 'member_log_stream')`. + +This keeps UI language correct and lets task/member stream lifecycles have separate consumer counts while sharing the same underlying watcher. + +Important implementation detail from code: + +- `TeamLogSourceTracker` consumer counts are per team and consumer name, not per member. +- That is correct for member stream because the watcher scope is team/session-level. +- Do not add one watcher per member popup. Add one `member_log_stream` consumer and rely on reference counts for multiple popups. +- If the component remounts with a different `teamName`, cleanup must disable the previous team before enabling the next team. +- Disabling `member_log_stream` must not close the watcher while `task_log_stream`, `change_presence`, `tool_activity` or `stall_monitor` still has a positive count. + +#### C. Do not enable tracking from member popup + +🎯 5 🛡️ 4 🧠 1 +Approx 0 LOC. + +This is unreliable. It only works when another part of the UI has already enabled log-source tracking. + +### Tracking Decision + +Use B. + +It adds a little transport work, but it removes a hidden dependency on `TaskLogsPanel` and avoids calling task-named APIs from member logs. + +## 0.8 IPC And Composition Integration + +### Facts + +`src/main/ipc/teams.ts` currently has many positional dependencies and already owns legacy team APIs. That is exactly why member log stream should not be added there as another owned service. + +Current legacy facts still matter: + +- `src/main/ipc/teams.ts` owns existing team handlers; +- task stream still uses `TEAM_GET_TASK_LOG_STREAM`; +- `src/preload/index.ts` and `src/renderer/api/httpClient.ts` already expose legacy team API fallbacks; +- `src/main/index.ts` is the composition point where feature facades can be instantiated. + +### Options + +#### A. Feature-owned IPC and composition + +🎯 8.5 🛡️ 9 🧠 4 +Approx 80-180 LOC for transport wiring. + +This is the correct first PR choice after adopting the canonical feature slice. + +Implementation rule: + +```ts +const memberLogStreamFeature = createMemberLogStreamFeature({ + logsFinder, + logSourceTracker, + runtimeBridge, + logger, +}); + +registerMemberLogStreamIpc(ipcMain, memberLogStreamFeature); +``` + +Feature-owned files: + +- `src/features/member-log-stream/contracts/channels.ts`; +- `src/features/member-log-stream/contracts/api.ts`; +- `src/features/member-log-stream/preload/createMemberLogStreamBridge.ts`; +- `src/features/member-log-stream/main/composition/createMemberLogStreamFeature.ts`; +- `src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts`. + +Test rule: + +- feature IPC registration test expects feature channel registration/removal; +- app-shell integration test imports only `@features/member-log-stream/main`; +- existing task stream handler tests remain unchanged and prove legacy team IPC was not disturbed. + +#### B. Add member stream into `initializeTeamHandlers()` + +🎯 5 🛡️ 4 🧠 2 +Approx 30-70 LOC. + +This is no longer the recommended path. It is locally cheap, but it violates feature ownership and risks shifting positional dependencies in `initializeTeamHandlers()`. + +Do not choose this. + +#### C. Convert legacy team initializer to a dependency object + +🎯 7 🛡️ 9 🧠 7 +Approx 250-450 LOC. + +This is a useful legacy IPC hygiene refactor, but it is separate from member log stream. It should not be bundled unless the PR explicitly pays that cost. + +### Required Feature Handler Contract + +Add feature-owned channels, for example: + +```ts +export const MEMBER_LOG_STREAM_GET = 'member-log-stream:getMemberLogStream'; +export const MEMBER_LOG_STREAM_SET_TRACKING = 'member-log-stream:setTracking'; +``` + +Add register/remove symmetry in feature input adapter: + +```ts +ipcMain.handle(MEMBER_LOG_STREAM_GET, handleGetMemberLogStream); +ipcMain.handle(MEMBER_LOG_STREAM_SET_TRACKING, handleSetMemberLogStreamTracking); +ipcMain.removeHandler(MEMBER_LOG_STREAM_GET); +ipcMain.removeHandler(MEMBER_LOG_STREAM_SET_TRACKING); +``` + +Add feature API and preload shape: + +```ts +getMemberLogStream( + teamName: string, + memberName: string, + options?: { + limitSegments?: number; + since?: string; + laneId?: string; + forceRefresh?: boolean; + } +): Promise +``` + +Handler behavior: + +- validate `teamName` with `validateTeamName`; +- validate `memberName` with `validateMemberName`, but do not require current team membership; +- validate `laneId` with the new runtime-lane validator, not with `validateMemberName`; +- clamp `limitSegments` before passing options to service; +- reject invalid `since`; +- validate `forceRefresh` as boolean when present; +- reject unknown option keys; +- call the feature facade/use case with normalized options. + +Validator placement note: + +- `src/main/ipc/guards.ts` keeps `ValidationResult` local today; +- safest implementation is to export concrete helpers like `validateOptionalRuntimeLaneId()` and `validateOptionalBooleanOption()` from `guards.ts`; +- if validators stay local in the feature input adapter, do not import a non-exported `ValidationResult` type. + +### Browser Fallback + +The renderer API/browser fallback must satisfy the feature API even when Electron IPC is unavailable. If `api.teams` compatibility delegates are kept, they must delegate to the same fallback shape. + +Return a complete empty response: + +```ts +{ + participants: [], + defaultFilter: 'all', + segments: [], + source: 'member_empty', + coverage: [], + warnings: [], + truncated: false, + generatedAt: new Date().toISOString(), + metadata: { + scannedTranscriptFileCount: 0, + includedTranscriptFileCount: 0, + droppedSegmentCount: 0, + droppedChunkCount: 0, + droppedMessageCount: 0, + }, +} +``` + +Do not throw in browser fallback. Throwing would make renderer code need an Electron-only branch even though task stream already uses a safe empty fallback. + +### Recommendation + +Use option A in the implementation PR. + +🛡️ The main bug risk is not the new stream logic here. It is accidentally pulling the feature back into legacy `team` IPC/service ownership and weakening the feature boundary. + +## 1. Claude Transcript Attribution + +### Facts + +`TeamMemberLogsFinder` уже содержит лучший source для member stream: + +```ts +findRecentMemberLogFileRefsByMember( + teamName: string, + memberNames: readonly string[], + mtimeSinceMs?: number | null +): Promise +``` + +Дополнительный compatibility факт: этот method уже используется не только будущим member stream. + +- `TeamMemberRuntimeAdvisoryService` вызывает его с третьим positional numeric arg. +- Existing live tests вызывают его с `null` и numeric `mtimeSinceMs`. +- `discoverProjectSessions(teamName, { forceRefresh })` уже поддерживает cache bypass. +- `getLogSourceWatchContext()` и `getLiveLogSourceWatchContext()` уже передают `forceRefresh` в discovery path. +- Current method calls `discoverProjectSessions(teamName)` without options, so member stream needs object-form options only to add `forceRefresh`. +- Current lead transcript branch pushes the lead ref after `fs.stat()` but before any `mtimeSinceMs` check. +- Current refs have only `memberName`, `sessionId`, `filePath` and `mtimeMs`. + +Значит для member stream нельзя делать breaking object-only замену третьего аргумента. + +Нужен backward-compatible options parser: + +```ts +type FindRecentMemberLogFileRefsOptions = + | number + | null + | { + mtimeSinceMs?: number | null; + forceRefresh?: boolean; + }; +``` + +Implementation rule: + +- numeric third arg остается `mtimeSinceMs`; +- `null` остается "без mtime window"; +- object third arg включает `mtimeSinceMs` и `forceRefresh`; +- only object form can bypass discovery cache; +- existing advisory callers do not need changes. + +Он возвращает: + +```ts +{ + memberName: string; + sessionId: string; + filePath: string; + mtimeMs: number; + kind?: 'lead_session' | 'member_session' | 'subagent'; + sizeBytes?: number; + messageCount?: number; +} +``` + +Metadata rule: + +- `kind` and `sizeBytes` are cheap and should be added by the finder; +- `messageCount` is optional and should be filled only when existing attribution/session metadata already knows it; +- do not parse full JSONL files in `TeamMemberLogsFinder` just to compute `messageCount`, because `ClaudeMemberTranscriptStreamSource` will strict-parse the selected refs afterward. + +Почему это лучше, чем `findMemberLogPaths()`: + +- уже сортирует refs по `mtimeMs desc`; +- дедуплицирует `filePath`; +- возвращает `sessionId`, который нужен для stable segment id; +- умеет lead transcript; +- использует ту же attribution precedence, что и старые member logs; +- принимает `mtimeSinceMs`, что дает cheap performance window. + +### Correct Implementation + +🎯 9 🛡️ 9 🧠 4 +Примерно 120-220 LOC для Claude-only member stream service. + +Service должен: + +1. Вызывать `findRecentMemberLogFileRefsByMember(teamName, [memberName], { mtimeSinceMs: sinceMs, forceRefresh })`. +2. Dedupe cumulative subagent refs before cap, using `kind + memberName + sessionId`, then `messageCount`, `sizeBytes`, `mtimeMs`. +3. Сразу применять cap по ref count после dedupe: default `maxTranscriptFiles = 40`. +4. Парсить только capped refs через отдельный `BoardTaskExactLogStrictParser`. +5. Для каждого ref строить отдельный segment. +6. Ограничивать total chunks глобальным budget, default `maxChunks = 250`. +7. Сортировать response segments по `startTimestamp asc`, чтобы renderer мог reverse как task stream. +8. Возвращать warning `large_log_window_limited`, если refs/chunks больше cap. + +### Risks + +- Если member был переименован, finder ищет только переданное имя. +- Если `knownMembers` не содержит historical removed member, attribution может не найти старые logs. +- Если заменить third arg object-only, можно сломать runtime advisory и live tests, которые уже передают numeric/null. +- В текущем коде lead transcript добавляется до candidate scan и не проходит `mtimeSinceMs` filter. +- Если finder начнет считать `messageCount` через full parse, он удвоит IO и сделает popup тяжелее на больших историях. +- Text mention остается low-confidence fallback. Это уже существующий риск, не новый. + +### Mitigation + +- Не требовать current config membership в IPC handler. +- В plan добавить future alias map, но не делать в v2. +- В coverage явно писать `claude_transcript included/partial`. +- Расширять third arg finder-а через compatibility parser, а не менять его на object-only. +- При object/numeric `mtimeSinceMs` применять time window и к lead transcript, и к member/subagent candidates. +- Если lead transcript старше `mtimeSinceMs`, не возвращать его в recent refs. +- Добавить tests на numeric, `null` и object options формы. +- Добавить tests, что `forceRefresh` передается в `discoverProjectSessions()` только через object form. +- Добавить tests, что optional metadata не ломает existing advisory callers и не требует full parse внутри finder. + +## 2. OpenCode Lane Resolution + +### Facts + +В репо уже есть deterministic lane identity: + +```ts +buildPlannedMemberLaneIdentity({ + leadProviderId, + member, +}) +``` + +Для mixed team с non-OpenCode lead и OpenCode member lane становится: + +```txt +secondary:opencode: +``` + +`TeamMemberResolver` уже кладет в `TeamMemberSnapshot`: + +- `laneId`; +- `laneKind`; +- `laneOwnerProviderId`. + +Renderer получает resolved member через spread snapshot: + +```ts +return { + ...snapshot, + status, + messageCount, + lastActiveAt, +} +``` + +Но `ResolvedTeamMember` type сейчас явно не содержит runtime/lane fields, хотя фактический объект их несет. Это хрупко для новой реализации. + +Дополнительный code research: + +- `src/shared/types/team.ts` объявляет `TeamMemberSnapshot.laneId/laneKind/laneOwnerProviderId`; +- `src/shared/types/team.ts` также объявляет `TeamMemberSnapshot.providerBackendId/selectedFastMode/resolvedFastMode`; +- `src/shared/types/team.ts` не объявляет эти поля на `ResolvedTeamMember`; +- `src/renderer/store/slices/teamSlice.ts` делает `return { ...snapshot, status, messageCount, lastActiveAt }`, поэтому runtime object уже несет lane fields; +- renderer code уже обращается к lane fields в некоторых utility paths, но без полноценного `ResolvedTeamMember` contract это легко превращается в касты и drift. + +### Correct Implementation + +🎯 8.5 🛡️ 9 🧠 4 +Примерно 180-320 LOC. + +Нужно сделать так: + +1. Расширить `ResolvedTeamMember` type полями: + - `providerBackendId?: TeamProviderBackendId`; + - `selectedFastMode?: TeamFastMode`; + - `resolvedFastMode?: boolean`; + - `laneId?: string`; + - `laneKind?: 'primary' | 'secondary'`; + - `laneOwnerProviderId?: TeamProviderId`. + Data mapping almost does not change, because `buildResolvedMember()` already spreads `snapshot`. +2. Расширить `getMemberLogStream` options: + +```ts +{ + limitSegments?: number; + since?: string; + laneId?: string; +} +``` + +3. В `MemberLogStreamSection` передавать `member.laneId`, если: + - `member.providerId === 'opencode'`; + - `member.laneOwnerProviderId === 'opencode'`; + - `member.laneId` непустой. + Не передавать `laneId` для non-OpenCode members, даже если в старом snapshot случайно остался lane-like field. +4. В `ClaudeMultimodelBridgeService.getOpenCodeTranscript()` добавить optional `laneId`. +5. В тот же params добавить optional `timeoutMs`, чтобы member popup не зависал на provider probe path. +6. Bridge должен append: + +```ts +if (params.laneId?.trim()) { + args.push('--lane', params.laneId.trim()); +} +``` + +7. Если `laneId` неизвестен, можно сделать best-effort no-lane call, но только с catch ambiguity error. +8. Ambiguity превращать в warning `opencode_ambiguous_lane`, не в hard failure. +9. Timeout/runtime missing превращать в `opencode_runtime_timeout` или `opencode_runtime_unavailable`, не в hard failure. +10. Add a small member-source TTL cache and in-flight join around OpenCode runtime transcript calls. + +### Why This Is Safer + +Orchestrator уже защищает от unsafe resolution: + +```txt +Multiple OpenCode session records exist ... pass --lane to select one +``` + +Desktop должен уважать это, а не обходить. + +### OpenCode Member Stream Semantics + +Для member stream нельзя использовать task-specific logic из `OpenCodeTaskLogStreamSource`: + +- не применять task marker spans; +- не применять task work intervals; +- не фильтровать по task owner; +- не читать task attribution records как основной source. + +Правильный member OpenCode segment: + +- получить projection messages за выбранный member/lane; +- cap по `limit`, default `openCodeMessageLimit = 400`; +- cap по времени, default `openCodeTimeoutMs = 5_000`, потому что текущий bridge path использует `execCli` timeout and can otherwise delay the popup; +- build chunks через `BoardTaskExactLogChunkBuilder`; +- segment id включает `teamName`, `laneId`, `memberName`, `sessionId`; +- source coverage: `opencode_runtime included`; +- если projection пустой: `opencode_runtime skipped`. + +Implementation detail: + +- не использовать `OpenCodeTaskLogStreamSource` напрямую, потому что он task-specific; +- вынести reusable projection-to-ParsedMessage helpers из `OpenCodeTaskLogStreamSource` в small mapper, если без копипаста не обойтись; +- покрыть старый task source тестами после extraction, чтобы не сломать task stream. + +### Risks + +- `ResolvedTeamMember` type drift может скрыть runtime/lane fields от TS. +- Старые teams могут не иметь lane metadata. +- OpenCode primary-only teams могут использовать `laneId: primary`. +- Runtime transcript может быть stale или пустым. + +### Mitigation + +- API принимает laneId от renderer, но main может recompute fallback lane identity later. +- Для v2 достаточно renderer laneId + safe no-lane fallback. +- Не merge multiple lanes. +- Не показывать OpenCode как complete, если он skipped/partial. + +## 3. Codex Native Member-wide Feasibility + +### Facts + +`CodexNativeTraceReader` хранит traces здесь: + +```txt +.member-work-sync/runtime-hooks/codex-native-traces/ + processed///*.jsonl + incoming///*.jsonl.tmp +``` + +Header содержит: + +- `teamName`; +- `taskId`; +- `ownerName`; +- `runId`; +- `cwd`; +- `startedAt`. + +Значит member filtering по `ownerName` технически возможен. + +Но текущий API: + +```ts +readTaskRuns({ teamName, taskIds, includeIncoming }) +``` + +task-first. Он не умеет: + +- перечислить все task dirs для team; +- выбрать последние N runs по ownerName; +- ограничить scan без taskId; +- возвращать member-wide coverage. + +Current implementation detail: + +- It only lists `processed//` and optional `incoming//`. +- It caps latest candidates after task-dir collection, currently to 10 files. +- It dedupes by `team/task/runId`, preferring non-partial/newer candidates. +- That strategy is safe for a known task id, but not enough for member-wide history because there is no bounded owner index. + +Еще важнее: `CodexNativeTraceProjector` проектирует только native tool events, а не полный разговор Codex. То есть даже если сделать member-wide reader, это будет "Codex native tool trace", а не полный Codex log stream. + +### Options + +#### A. Keep Codex skipped in first implementation + +🎯 9 🛡️ 9 🧠 2 +Примерно 20-40 LOC для coverage warning. + +Плюсы: + +- самый надежный первый релиз; +- нет риска тяжелого scan по trace tree; +- честное product обещание. + +Минусы: + +- Codex участники не получат новый native trace в member popup. + +#### B. Add partial Codex native tool trace as phase 2 + +🎯 7 🛡️ 7 🧠 7 +Примерно 250-450 LOC. + +Новый метод: + +```ts +readMemberRuns({ + teamName, + ownerName, + includeIncoming, + limitRuns, + maxTaskDirs, +}) +``` + +Алгоритм: + +1. Resolve trace root. +2. List `processed/` task dirs and optional `incoming/` task dirs. +3. Collect jsonl/jsonl.tmp candidates with stat. +4. Sort by `mtimeMs desc`. +5. Cap candidates before full parse. +6. Parse headers/runs. +7. Filter normalized `ownerName`. +8. Deduplicate by `team/task/runId`, prefer non-partial and newer mtime. +9. Project via `CodexNativeTraceProjector`. +10. Return segment with source label `codex_native_trace_partial`. + +Additional required caps: + +- `maxTaskDirs` before candidate collection; +- `maxTraceCandidates` before full parse; +- `maxTraceRuns` after owner filtering/dedupe; +- default `includeIncoming: false` unless the member is currently active or the UI explicitly requests live partial files. + +Минусы: + +- scanning all task dirs может быть тяжелым; +- trace может быть native-tool-only и выглядеть неполным; +- incoming partial files требуют аккуратного JSONL handling. +- scanning by owner without an index can become O(number of tasks), so it should not ride inside the first PR. + +#### C. Full Codex member-wide log support + +🎯 5 🛡️ 5 🧠 9 +Примерно 700-1200+ LOC. + +Нужно строить отдельный index по Codex runtime/session events, не только native tool traces. Это уже вариант 3. + +### Recommendation + +Для варианта 2: + +- first PR: Codex skipped with explicit `codex_member_wide_not_supported`; +- optional second PR: partial native tool trace with honest UI label; +- do not present Codex native trace as full logs. + +## 4. Parser Cache Safety + +### Facts + +`BoardTaskExactLogStrictParser.parseFiles()` делает: + +```ts +this.cache.retainOnly(new Set(uniquePaths)); +``` + +`BoardTaskExactLogsParseCache` delegates to `BoardTaskActivityParseCache`, whose `retainOnly()` removes both: + +- parsed cache entries; +- active `inFlight` parse promises outside the retained file set. + +Это значит: если один service instance парсит task files, а другой потом member files на том же parser instance, второй вызов может вычистить не только cache первого, но и его active parse dedupe. В худшем случае это не ломает correctness напрямую, но создает duplicate reads, timing-sensitive cache churn и flaky performance под параллельными reload. + +### Correct Implementation + +🎯 9 🛡️ 9 🧠 3 +Примерно 20-60 LOC. + +Для v2: + +- `ClaudeMemberTranscriptStreamSource` получает собственный `BoardTaskExactLogStrictParser`. +- Не шарить parser instance с `BoardTaskLogStreamService`. +- Не инжектить task-stream parser в тестах как "удобный shared mock", потому что это маскирует ownership rule. +- Если понадобится общий parser, сначала redesign cache API: `retainOnly()` должен стать owner-scoped или LRU-based. + +Future improvement: + +- заменить `retainOnly()` на bounded LRU cache; +- либо добавить namespace/owner key для task/member cache ownership; +- тогда task/member services смогут безопасно шарить parser. + +Но это не нужно для первого релиза. + +## 5. API Shape Refinement + +### Previous Draft + +Изначально было достаточно: + +```ts +getMemberLogStream(teamName, memberName, options?) +``` + +### Updated API + +После research лучше зафиксировать: + +```ts +getMemberLogStream( + teamName: string, + memberName: string, + options?: { + limitSegments?: number; + since?: string; + laneId?: string; + forceRefresh?: boolean; + } +): Promise +``` + +Почему `laneId` в options: + +- renderer уже знает выбранного member object; +- это самый дешевый и точный path для OpenCode secondary lanes; +- main все равно валидирует strings; +- если поля нет, main может fallback to no-lane best-effort. + +Почему `forceRefresh` в options: + +- `discoverProjectSessions()` кэширует discovery на 30 секунд; +- same-team `log-source-change` означает, что session ids/source context could have changed; +- renderer can pass `forceRefresh: true` only for that event path; +- regular task-log-change reloads can keep the cache. + +Почему не передавать весь member: + +- меньше IPC surface; +- меньше drift между renderer snapshot и main truth; +- no need to trust UI for provider selection. + +## 6. Correct First Implementation Sequence + +Updated safest order: + +1. Create `src/features/member-log-stream` with contracts, core/domain, core/application, main composition, preload bridge and renderer public entrypoints. +2. Add feature-owned `MemberLogStreamResponse`, `MemberLogStreamSegment.source`, response metadata, warnings and `ResolvedTeamMember` runtime/lane fields. Do not extend or broaden `BoardTaskLogStreamResponse.source`. +3. Add feature IPC channels, feature preload methods, browser fallback and optional compatibility methods on `api.teams` for `getMemberLogStream` plus `setMemberLogStreamTracking`. +4. Add IPC validation helpers in the feature input adapter or shared guards, including exact-preserving optional `laneId` validation. Register feature handlers append-only without shifting existing `initializeTeamHandlers()` positional dependencies. +5. Add core source-port interfaces and budget constants. +6. Extend `MemberLogFileRef` with optional `kind`/`sizeBytes`/`messageCount`, add backward-compatible `findRecentMemberLogFileRefsByMember()` third-arg options parsing, apply `mtimeSinceMs` to lead refs, augment requested members into attribution, keep `messageCount` cheap-only, then add ref dedupe tests. +7. Add `ClaudeMemberTranscriptStreamSource` using `findRecentMemberLogFileRefsByMember`, ref dedupe and dedicated parser instance owned by member stream. +8. Extract provider-neutral `ParsedMessage` hygiene helpers and keep board/task JSON cleanup scoped. +9. Add pair-aware message-window trimming and content-size truncation before chunk build. +10. Extract `OpenCodeRuntimeProjectionMapper` from `OpenCodeTaskLogStreamSource` without moving task-window logic. Do not use the narrower stall-monitor mapper as the base. +11. Add `GetMemberLogStreamUseCase` with merge, sort, budget, truncation and identical active request join layer. +12. Add source-local OpenCode member TTL cache and in-flight join. +13. Export/instantiate the feature facade in main composition and register/remove the new IPC handler. +14. Add renderer feature `ExecutionLogStreamView` render-only extraction with shape-preserving normalization and caller-provided segment key strategy. +15. Add `MemberLogStreamSection` with tracking activation, reload coalescing, actor/source identity separation and without importing old `MemberLogsTab`. +16. Wire `MemberDetailDialog` gate-on/gate-off boundary with old `MemberLogsTab` fallback, importing only `@features/member-log-stream/renderer`. +17. Add OpenCode bridge `laneId` and `timeoutMs`. +18. Add `OpenCodeMemberRuntimeStreamSource` using the shared mapper, while leaving stall-monitor mapper migration as a separate optional cleanup. +19. Add coverage/warnings and backend truncation semantics. +20. Keep Codex skipped in first PR. +21. Optional follow-up PR: `CodexNativeTraceReader.readMemberRuns`. + +## 7. Revised Risk Ratings + +| Risk | Before | After research | Notes | +| --- | ---: | ---: | --- | +| Showing another member's Claude logs | 🛡️ 7 | 🛡️ 8 | Existing finder has strong attribution precedence | +| OpenCode wrong lane | 🛡️ 6 | 🛡️ 8 | Pass laneId from snapshot, respect orchestrator ambiguity | +| Codex completeness claim | 🛡️ 5 | 🛡️ 8 | Mark skipped/partial honestly | +| Codex member-wide scan cost | 🛡️ 4 | 🛡️ 9 | First PR does not scan task-keyed trace tree for member-wide owner history | +| Parser cache regression | 🛡️ 6 | 🛡️ 9 | Dedicated parser instance avoids parsed cache eviction | +| Parser in-flight eviction | 🛡️ 5 | 🛡️ 9 | Same ownership rule avoids deleting another stream's active parse dedupe | +| UI copy drift from task stream | 🛡️ 7 | 🛡️ 8 | Generic view extraction avoids duplicated renderer logic | +| Popup freeze on large history | 🛡️ 4 | 🛡️ 8 | Backend budget avoids rendering audit-sized logs | +| Architecture drift | 🛡️ 5 | 🛡️ 9 | Canonical feature slice plus source ports keeps provider logic isolated and follows repo standard | +| Feature boundary deep imports | 🛡️ 5 | 🛡️ 9 | Generic `src/features/*` lint guard rails plus public-entrypoint imports protect Clean Architecture direction | +| IPC lane validation | 🛡️ 6 | 🛡️ 8.5 | Dedicated lane validator handles `secondary:opencode:*` safely without rewriting exact ids | +| Cumulative subagent duplicates | 🛡️ 5 | 🛡️ 8 | Ref metadata plus pre-parse dedupe avoids repeated turns | +| OpenCode mapper drift | 🛡️ 6 | 🛡️ 8.5 | Shared projection mapper avoids copy-pasted conversion | +| Wrong OpenCode mapper base | 🛡️ 5 | 🛡️ 9 | Extract from task source, not from the narrower stall-monitor mapper | +| Finder metadata overreach | 🛡️ 5 | 🛡️ 8 | `kind`/`sizeBytes` stay cheap; no full parse just for `messageCount` | +| Oversized single segment | 🛡️ 4 | 🛡️ 8 | Message budget catches huge chunks that `maxChunks` cannot | +| Live refresh misses | 🛡️ 6 | 🛡️ 8 | Same-team `task-log-change` reload covers task freshness signals | +| IPC dependency shift | 🛡️ 5 | 🛡️ 8 | Append-only service injection avoids breaking positional handler setup | +| Browser fallback compile drift | 🛡️ 6 | 🛡️ 8 | Full empty response keeps feature API and any compatibility delegate satisfied outside Electron | +| Removed member attribution | 🛡️ 5 | 🛡️ 8 | Requested member names are added to finder attribution set | +| Finder options compatibility | 🛡️ 6 | 🛡️ 9 | Backward-compatible third-arg parser keeps advisory and live tests stable | +| Lead mtime window bypass | 🛡️ 6 | 🛡️ 9 | Apply `mtimeSinceMs` to lead transcript before pushing lead ref | +| Mixed source context loss | 🛡️ 6 | 🛡️ 8 | Member segment metadata gives UI safe provider/session labels | +| Segment key collisions | 🛡️ 6 | 🛡️ 8.5 | Generic view supports task default key and member source-aware override | +| OpenCode popup delay | 🛡️ 5 | 🛡️ 8 | Popup-specific timeout and fail-soft source handling avoid blocking Claude transcript logs | +| Renderer duplicate reloads | 🛡️ 5 | 🛡️ 8.5 | Member section coalesces active loads and runs at most one pending reload | +| Live tracking not activated | 🛡️ 4 | 🛡️ 9 | Dedicated `setMemberLogStreamTracking()` activates `TeamLogSourceTracker` while popup stream is mounted | +| Tracking consumer leak | 🛡️ 5 | 🛡️ 8.5 | Team-level `member_log_stream` consumer uses existing reference counting and cleanup tests | +| Stale discovery after launch | 🛡️ 5 | 🛡️ 8 | `forceRefresh` on `log-source-change` bypasses 30s finder discovery cache | +| Huge single tool output | 🛡️ 4 | 🛡️ 8 | Content-char budgets protect renderer beyond message/chunk count | +| Renderer Date shape drift | 🛡️ 6 | 🛡️ 8.5 | HTTP client already revives ISO dates, remaining risk is mostly test fixtures | +| DTO source union drift | 🛡️ 5 | 🛡️ 9 | Standalone `MemberLogStreamResponse` avoids mutating task `BoardTaskLogStreamResponse.source` semantics | +| Member popup fallback regression | 🛡️ 6 | 🛡️ 9 | Gate at `MemberDetailDialog`, leave shared `MemberLogsTab` and task `Execution Sessions` untouched | +| Resolved member runtime/lane type drift | 🛡️ 6 | 🛡️ 9 | Shared type explicitly declares runtime/lane fields already present at runtime | +| OpenCode bridge call churn | 🛡️ 5 | 🛡️ 8 | Short source-local TTL and in-flight join mirror task source behavior | +| Board/task sanitization leakage | 🛡️ 5 | 🛡️ 9 | Extract provider-neutral message hygiene, keep board JSON cleanup scoped to task stream | +| IPC option typo drift | 🛡️ 5 | 🛡️ 9 | Unknown member-stream options are rejected with an allow-list before service dispatch | +| Participant/source identity drift | 🛡️ 5 | 🛡️ 9 | Actor participants stay actor-based; provider/session labels come from `segment.source` | +| Since-only replacement reload | 🛡️ 5 | 🛡️ 9 | Renderer does full bounded reloads until partial-response merge is explicit | +| Generic view side effects | 🛡️ 5 | 🛡️ 9 | View stays render-only; containers own API calls, gates, tracking and fallback | + +## 8. Test Additions From Research + +Add these tests beyond the original plan: + +- Core domain policy tests run without main/preload/renderer imports. +- Core application use-case tests use fake source/cache/clock/logger ports and no concrete provider adapters. +- Feature IPC tests exercise `registerMemberLogStreamIpc()`/remove handler through feature main input adapter. +- Feature preload tests exercise `createMemberLogStreamBridge()` with contracts-only dependencies. +- Renderer UI tests pass props/view models and do not mock `@renderer/api` or `@renderer/store`. +- Targeted lint for `src/features/member-log-stream` passes under generic feature boundary rules. +- App shell integration imports only `@features/member-log-stream/main`, `@features/member-log-stream/preload`, `@features/member-log-stream/renderer` or `@features/member-log-stream/contracts`. +- `ClaudeMemberTranscriptStreamSource` uses `findRecentMemberLogFileRefsByMember`, not `findMemberLogPaths`. +- It dedupes cumulative subagent refs before parsing and caps after dedupe. +- It augments attribution with requested member names so removed/historical members can still resolve. +- It enforces global `maxSegments` and `maxChunks`. +- It enforces `maxSourceMessages` and `maxMessagesPerSegment`. +- It enforces content-char budgets without mutating parser cache arrays. +- It trims oversized message windows without orphaning tool results. +- It truncates oversized content while preserving ids needed for tool linking. +- It preserves ordinary JSON-looking tool outputs unless the content budget requires truncation. +- It does not apply board/task JSON cleanup globally to member stream messages. +- It creates stable segment ids from `sessionId/fileFingerprint/startTimestamp`. +- It returns `MemberLogStreamSegment.source` labels without absolute file paths. +- It hashes file fingerprints and does not expose absolute paths in ids/warnings/metadata. +- It does not share parser cache with task stream. +- It uses a member-owned parser so member `parseFiles().retainOnly()` cannot clear task-stream `inFlight` parse entries. +- Task-stream parse in-flight dedupe still works while member stream parses a different file set. +- First-PR Codex skipped adapter does not call `CodexNativeTraceReader.readTaskRuns()` and does not scan `processed//` directories. +- `MemberLogFileRef` optional `kind`/`sizeBytes`/`messageCount` remains backward compatible with advisory callers. +- `MemberLogFileRef.messageCount` is not computed by full parsing inside `TeamMemberLogsFinder`. +- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` keeps legacy numeric/null third-arg behavior. +- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` supports object options `{ mtimeSinceMs, forceRefresh }`. +- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` passes `forceRefresh` through to `discoverProjectSessions()`. +- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` applies `mtimeSinceMs` to lead transcript refs. +- `OpenCodeRuntimeProjectionMapper` preserves tool calls, tool results, content blocks, `sourceToolUseID`, `sourceToolAssistantUUID`, `toolUseResult`, `isMeta`, and sanitized text content. +- `OpenCodeTaskLogStreamSource` and `OpenCodeMemberRuntimeStreamSource` both call `OpenCodeRuntimeProjectionMapper`. +- Mapper fixtures prove non-string content blocks are not dropped like the stall-monitor mapper would drop them. +- `ResolvedTeamMember` exposes runtime/lane fields in TS: `providerBackendId`, `selectedFastMode`, `resolvedFastMode`, `laneId`, `laneKind`, `laneOwnerProviderId`. +- `MemberLogStreamSection` passes `laneId` for OpenCode member only when `laneOwnerProviderId === 'opencode'`. +- `MemberLogStreamSection` does not pass stale lane-like fields for non-OpenCode members. +- `MemberLogStreamSection` enables `setMemberLogStreamTracking(teamName, true)` while mounted and disables it on unmount. +- `MemberLogStreamSection` disables tracking for the previous team when `teamName` changes. +- `MemberLogStreamSection` coalesces duplicate background reloads while one request is active. +- `OpenCodeMemberRuntimeStreamSource` joins duplicate in-flight bridge calls. +- `OpenCodeMemberRuntimeStreamSource` bypasses completed cache on `forceRefresh` but still joins in-flight work. +- `MemberDetailDialog` renders `MemberLogStreamSection` when renderer gate is on. +- `MemberDetailDialog` renders old `MemberLogsTab` when renderer gate is off. +- `MemberDetailDialog` first-load stream error keeps Logs active and shows explicit old logs fallback. +- `ExecutionSessionsSection` remains unchanged and still renders legacy `MemberLogsTab`. +- `MemberLogStreamSection` reloads on same-team `log-source-change` and `task-log-change`, but not `tool-activity`. +- `MemberLogStreamSection` passes `forceRefresh: true` for `log-source-change` reloads only. +- `MemberLogStreamSection` does not pass `since` during renderer background replacement reloads in the first PR. +- `ExecutionLogStreamView` tests cover Date-shaped chunks and document whether JSON-like chunk normalization is supported. +- `ExecutionLogStreamView` preserves task tail-growth expanded state with task default keys. +- `ExecutionLogStreamView` supports a member source-aware `buildSegmentRenderKey` override to avoid provider/session collisions. +- `ExecutionLogStreamView` preserves unknown stream fields and member `segment.source` while normalizing chunks. +- Shared type tests or compile checks keep `MemberLogStreamResponse` standalone and keep `BoardTaskLogStreamResponse.source` task-only. +- `MemberLogStreamSection` renders provider/session/lane labels from `segment.source`, not from participant labels. +- `MemberLogStreamSection` keeps actor participant identity stable for one selected member across multiple providers/sessions. +- A member stream segment with `actor.memberName` and no `Process[]` still renders tool/output items. +- IPC rejects invalid `since`, clamps `limitSegments`, and accepts colon-containing lane ids. +- IPC accepts `primary` as a lane id. +- IPC preserves exact lane id casing/punctuation when passing options to the service. +- IPC rejects lane ids with control characters, NUL, newline, `/`, `\` or length over 256. +- IPC accepts boolean `forceRefresh` and rejects non-boolean values. +- IPC rejects unknown `getMemberLogStream` option keys before dispatching to the service. +- IPC validation helpers do not import non-exported `ValidationResult`. +- IPC registers/removes feature `MEMBER_LOG_STREAM_GET` channel. +- IPC registers/removes feature `MEMBER_LOG_STREAM_SET_TRACKING` channel. +- IPC handler calls the feature facade/use case with normalized options. +- IPC tracking handler validates `teamName` and boolean `enabled`, then maps to `TeamLogSourceTracker` consumer `member_log_stream`. +- `TeamLogSourceTracker` treats `member_log_stream` as a separate consumer from `task_log_stream`. +- Multiple member stream mounts for one team keep the watcher alive until all member consumers disable tracking. +- Disabling `member_log_stream` does not stop tracking while `task_log_stream`, `change_presence`, `tool_activity` or `stall_monitor` is still active. +- Existing `initializeTeamHandlers()` positional setup remains unchanged for task stream and exact-log services. +- Existing task stream IPC handler test remains a wiring smoke test proving the feature did not move legacy handler ownership. +- Browser-mode `httpClient` returns a complete empty member stream response. +- `ClaudeMultimodelBridgeService.getOpenCodeTranscript()` appends `--lane` only when provided. +- `ClaudeMultimodelBridgeService.getOpenCodeTranscript()` honors member-popup `timeoutMs`. +- OpenCode timeout/runtime missing becomes a warning and does not fail Claude transcript rendering. +- OpenCode ambiguity error becomes warning, not failed member popup. +- Codex member stream coverage is `skipped` unless explicit partial trace phase is implemented. + +## Final Recommendation + +Грамотная реализация варианта 2: + +🎯 8.5 🛡️ 8.5 🧠 6 +Примерно 1500-2300 LOC вместе с тестами. + +First implementation should be: + +- Claude transcript stream complete enough for selected member; +- OpenCode runtime stream lane-aware and safe; +- Codex native explicitly skipped or partial-only behind separate follow-up; +- old member logs fallback kept; +- fallback decision kept at `MemberDetailDialog`, not inside shared `MemberLogsTab`; +- task `Execution Sessions` kept unchanged; +- feature gated; +- member stream tracking activated while popup Logs stream is mounted; +- backend budget enforced before renderer; +- renderer duplicate reloads coalesced while one member stream request is active; +- provider-neutral message hygiene extracted separately from board/task JSON sanitization; +- pair-aware message trimming used for oversized single segments; +- content-size budgets applied before chunk build; +- cumulative subagent refs deduped before parse; +- requested member names added to finder attribution set; +- finder third arg extended through backward-compatible numeric/null/object parser; +- `mtimeSinceMs` applied to lead transcript refs too; +- `src/features/member-log-stream` follows canonical feature layout with contracts/core/main/preload/renderer public entrypoints; +- app shell imports only public feature entrypoints and does not deep-import source adapters or use case internals; +- member segments include safe provider/session metadata; +- member stream uses source-aware segment render keys; +- OpenCode projection conversion extracted into shared mapper; +- OpenCode runtime transcript calls protected by source-local TTL and in-flight join; +- feature IPC registration is separate from legacy `initializeTeamHandlers()` service injection; +- browser fallback returns a complete empty stream response; +- `log-source-change` reloads use `forceRefresh`; +- source-port architecture used inside the canonical member-log-stream feature slice; +- no orchestrator code changes. + +Это дает максимальный прирост UX с низким риском неправильных логов. diff --git a/src/features/member-log-stream/contracts/api.ts b/src/features/member-log-stream/contracts/api.ts new file mode 100644 index 00000000..01480ada --- /dev/null +++ b/src/features/member-log-stream/contracts/api.ts @@ -0,0 +1,10 @@ +import type { MemberLogStreamRequestOptions, MemberLogStreamResponse } from './dto'; + +export interface MemberLogStreamApi { + getMemberLogStream( + teamName: string, + memberName: string, + options?: MemberLogStreamRequestOptions + ): Promise; + setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise; +} diff --git a/src/features/member-log-stream/contracts/channels.ts b/src/features/member-log-stream/contracts/channels.ts new file mode 100644 index 00000000..88799ef5 --- /dev/null +++ b/src/features/member-log-stream/contracts/channels.ts @@ -0,0 +1,2 @@ +export const MEMBER_LOG_STREAM_GET = 'member-log-stream:getMemberLogStream'; +export const MEMBER_LOG_STREAM_SET_TRACKING = 'member-log-stream:setTracking'; diff --git a/src/features/member-log-stream/contracts/dto.ts b/src/features/member-log-stream/contracts/dto.ts new file mode 100644 index 00000000..2d05c0f5 --- /dev/null +++ b/src/features/member-log-stream/contracts/dto.ts @@ -0,0 +1,72 @@ +import type { BoardTaskLogParticipant, BoardTaskLogSegment } from '@shared/types'; + +export type MemberLogStreamProvider = + | 'claude_transcript' + | 'opencode_runtime' + | 'codex_native_trace'; + +export type MemberLogStreamSource = + | 'member_transcript' + | 'member_mixed_runtime' + | 'member_runtime_only' + | 'member_empty'; + +export interface MemberLogStreamRequestOptions { + limitSegments?: number; + since?: string; + laneId?: string; + forceRefresh?: boolean; +} + +export interface MemberLogStreamCoverage { + provider: MemberLogStreamProvider; + status: 'included' | 'partial' | 'skipped'; + reason?: string; +} + +export interface MemberLogStreamWarning { + code: + | 'opencode_ambiguous_lane' + | 'opencode_missing_runtime_session' + | 'opencode_runtime_unavailable' + | 'opencode_runtime_timeout' + | 'codex_member_wide_not_supported' + | 'large_log_window_limited' + | 'segment_message_window_limited' + | 'message_content_limited' + | 'unreadable_transcript_file'; + message: string; +} + +export interface MemberLogStreamMetadata { + scannedTranscriptFileCount: number; + includedTranscriptFileCount: number; + droppedSegmentCount: number; + droppedChunkCount: number; + droppedMessageCount: number; +} + +export interface MemberLogStreamSegmentSource { + provider: MemberLogStreamProvider; + label: string; + sessionId?: string; + laneId?: string; + messageCount?: number; + truncated?: boolean; +} + +export interface MemberLogStreamSegment extends BoardTaskLogSegment { + source: MemberLogStreamSegmentSource; +} + +export interface MemberLogStreamResponse { + participants: BoardTaskLogParticipant[]; + defaultFilter: string; + segments: MemberLogStreamSegment[]; + source: MemberLogStreamSource; + coverage: MemberLogStreamCoverage[]; + warnings: MemberLogStreamWarning[]; + truncated: boolean; + generatedAt: string; + metadata: MemberLogStreamMetadata; +} diff --git a/src/features/member-log-stream/contracts/index.ts b/src/features/member-log-stream/contracts/index.ts new file mode 100644 index 00000000..41e0bc74 --- /dev/null +++ b/src/features/member-log-stream/contracts/index.ts @@ -0,0 +1,4 @@ +export type * from './api'; +export * from './channels'; +export type * from './dto'; +export * from './normalize'; diff --git a/src/features/member-log-stream/contracts/normalize.ts b/src/features/member-log-stream/contracts/normalize.ts new file mode 100644 index 00000000..d528d75e --- /dev/null +++ b/src/features/member-log-stream/contracts/normalize.ts @@ -0,0 +1,44 @@ +import type { MemberLogStreamResponse } from './dto'; + +export function createEmptyMemberLogStreamResponse( + generatedAt = new Date().toISOString() +): MemberLogStreamResponse { + return { + participants: [], + defaultFilter: 'all', + segments: [], + source: 'member_empty', + coverage: [], + warnings: [], + truncated: false, + generatedAt, + metadata: { + scannedTranscriptFileCount: 0, + includedTranscriptFileCount: 0, + droppedSegmentCount: 0, + droppedChunkCount: 0, + droppedMessageCount: 0, + }, + }; +} + +export function normalizeMemberLogStreamResponse( + response: MemberLogStreamResponse | null | undefined +): MemberLogStreamResponse { + if (!response) { + return createEmptyMemberLogStreamResponse(); + } + + return { + ...createEmptyMemberLogStreamResponse(response.generatedAt), + ...response, + participants: Array.isArray(response.participants) ? response.participants : [], + segments: Array.isArray(response.segments) ? response.segments : [], + coverage: Array.isArray(response.coverage) ? response.coverage : [], + warnings: Array.isArray(response.warnings) ? response.warnings : [], + metadata: { + ...createEmptyMemberLogStreamResponse(response.generatedAt).metadata, + ...(response.metadata ?? {}), + }, + }; +} diff --git a/src/features/member-log-stream/core/application/ports/ClockPort.ts b/src/features/member-log-stream/core/application/ports/ClockPort.ts new file mode 100644 index 00000000..b7b6878b --- /dev/null +++ b/src/features/member-log-stream/core/application/ports/ClockPort.ts @@ -0,0 +1,3 @@ +export interface ClockPort { + now(): number; +} diff --git a/src/features/member-log-stream/core/application/ports/LoggerPort.ts b/src/features/member-log-stream/core/application/ports/LoggerPort.ts new file mode 100644 index 00000000..f25abe6b --- /dev/null +++ b/src/features/member-log-stream/core/application/ports/LoggerPort.ts @@ -0,0 +1,5 @@ +export interface LoggerPort { + debug?(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; +} diff --git a/src/features/member-log-stream/core/application/ports/MemberLogStreamSource.ts b/src/features/member-log-stream/core/application/ports/MemberLogStreamSource.ts new file mode 100644 index 00000000..d40ea9f3 --- /dev/null +++ b/src/features/member-log-stream/core/application/ports/MemberLogStreamSource.ts @@ -0,0 +1,40 @@ +import type { + MemberLogStreamCoverage, + MemberLogStreamProvider, + MemberLogStreamSegment, + MemberLogStreamWarning, +} from '../../../contracts'; +import type { MemberLogStreamBudget } from '../../domain/models/MemberLogStreamBudget'; +import type { BoardTaskLogParticipant } from '@shared/types'; + +export interface MemberLogStreamSourceInput { + teamName: string; + memberName: string; + laneId?: string; + budget: MemberLogStreamBudget; + sinceMs?: number | null; + forceRefresh?: boolean; +} + +export interface MemberLogStreamSourceMetadata { + scannedTranscriptFileCount?: number; + includedTranscriptFileCount?: number; + droppedSegmentCount?: number; + droppedChunkCount?: number; + droppedMessageCount?: number; +} + +export interface MemberLogStreamSourceResult { + provider: MemberLogStreamProvider; + status: MemberLogStreamCoverage['status']; + reason?: string; + participants: BoardTaskLogParticipant[]; + segments: MemberLogStreamSegment[]; + warnings: MemberLogStreamWarning[]; + metadata?: MemberLogStreamSourceMetadata; +} + +export interface MemberLogStreamSource { + readonly provider: MemberLogStreamProvider; + load(input: MemberLogStreamSourceInput): Promise; +} diff --git a/src/features/member-log-stream/core/application/ports/MemberLogStreamTrackingPort.ts b/src/features/member-log-stream/core/application/ports/MemberLogStreamTrackingPort.ts new file mode 100644 index 00000000..07bc65ff --- /dev/null +++ b/src/features/member-log-stream/core/application/ports/MemberLogStreamTrackingPort.ts @@ -0,0 +1,3 @@ +export interface MemberLogStreamTrackingPort { + setTracking(teamName: string, enabled: boolean): Promise; +} diff --git a/src/features/member-log-stream/core/application/use-cases/GetMemberLogStreamUseCase.ts b/src/features/member-log-stream/core/application/use-cases/GetMemberLogStreamUseCase.ts new file mode 100644 index 00000000..913ea4cc --- /dev/null +++ b/src/features/member-log-stream/core/application/use-cases/GetMemberLogStreamUseCase.ts @@ -0,0 +1,144 @@ +import { createEmptyMemberLogStreamResponse } from '../../../contracts'; +import { + clampMemberLogStreamSegmentLimit, + DEFAULT_MEMBER_LOG_STREAM_BUDGET, +} from '../../domain/models/MemberLogStreamBudget'; +import { buildMemberLogStreamResponse } from '../../domain/policies/memberLogStreamMergePolicy'; + +import type { MemberLogStreamResponse } from '../../../contracts'; +import type { MemberLogStreamBudget } from '../../domain/models/MemberLogStreamBudget'; +import type { ClockPort } from '../ports/ClockPort'; +import type { LoggerPort } from '../ports/LoggerPort'; +import type { + MemberLogStreamSource, + MemberLogStreamSourceResult, +} from '../ports/MemberLogStreamSource'; + +export interface GetMemberLogStreamInput { + teamName: string; + memberName: string; + limitSegments?: number; + sinceMs?: number | null; + laneId?: string; + forceRefresh?: boolean; +} + +interface GetMemberLogStreamUseCaseDeps { + sources: readonly MemberLogStreamSource[]; + clock: ClockPort; + logger: LoggerPort; + budget?: Partial; +} + +function stableInputKey(input: GetMemberLogStreamInput, limitSegments: number): string { + return JSON.stringify([ + input.teamName, + input.memberName, + limitSegments, + input.sinceMs ?? null, + input.laneId ?? '', + input.forceRefresh === true, + ]); +} + +export class GetMemberLogStreamUseCase { + private readonly budget: MemberLogStreamBudget; + private readonly inFlight = new Map>(); + + constructor(private readonly deps: GetMemberLogStreamUseCaseDeps) { + this.budget = { ...DEFAULT_MEMBER_LOG_STREAM_BUDGET, ...(deps.budget ?? {}) }; + } + + async execute(input: GetMemberLogStreamInput): Promise { + const limitSegments = clampMemberLogStreamSegmentLimit(input.limitSegments, this.budget); + const key = stableInputKey(input, limitSegments); + const existing = this.inFlight.get(key); + if (existing) { + return existing; + } + + const promise = this.buildResponse(input, limitSegments).finally(() => { + this.inFlight.delete(key); + }); + this.inFlight.set(key, promise); + return promise; + } + + private async buildResponse( + input: GetMemberLogStreamInput, + limitSegments: number + ): Promise { + if (this.deps.sources.length === 0) { + return createEmptyMemberLogStreamResponse(new Date(this.deps.clock.now()).toISOString()); + } + + const sourceInput = { + teamName: input.teamName, + memberName: input.memberName, + laneId: input.laneId, + budget: this.budget, + sinceMs: input.sinceMs, + forceRefresh: input.forceRefresh, + }; + + const settled = await Promise.all( + this.deps.sources.map(async (source): Promise => { + try { + return await source.load(sourceInput); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.deps.logger.warn( + `Member log stream source ${source.provider} failed for ${input.teamName}/${input.memberName}: ${message}` + ); + return { + provider: source.provider, + status: 'skipped', + reason: message, + participants: [], + segments: [], + warnings: [ + { + code: + source.provider === 'opencode_runtime' + ? 'opencode_runtime_unavailable' + : 'unreadable_transcript_file', + message, + }, + ], + }; + } + }) + ); + + const metadata = { + scannedTranscriptFileCount: 0, + includedTranscriptFileCount: 0, + droppedSegmentCount: 0, + droppedChunkCount: 0, + droppedMessageCount: 0, + }; + + for (const result of settled) { + metadata.scannedTranscriptFileCount += result.metadata?.scannedTranscriptFileCount ?? 0; + metadata.includedTranscriptFileCount += result.metadata?.includedTranscriptFileCount ?? 0; + metadata.droppedSegmentCount += result.metadata?.droppedSegmentCount ?? 0; + metadata.droppedChunkCount += result.metadata?.droppedChunkCount ?? 0; + metadata.droppedMessageCount += result.metadata?.droppedMessageCount ?? 0; + } + + return buildMemberLogStreamResponse({ + participants: settled.flatMap((result) => result.participants), + segments: settled.flatMap((result) => result.segments), + coverage: settled.map((result) => ({ + provider: result.provider, + status: result.status, + ...(result.reason ? { reason: result.reason } : {}), + })), + warnings: settled.flatMap((result) => result.warnings), + generatedAt: new Date(this.deps.clock.now()).toISOString(), + budget: this.budget, + limitSegments, + metadata, + }); + } +} diff --git a/src/features/member-log-stream/core/application/use-cases/SetMemberLogStreamTrackingUseCase.ts b/src/features/member-log-stream/core/application/use-cases/SetMemberLogStreamTrackingUseCase.ts new file mode 100644 index 00000000..6590c7dd --- /dev/null +++ b/src/features/member-log-stream/core/application/use-cases/SetMemberLogStreamTrackingUseCase.ts @@ -0,0 +1,9 @@ +import type { MemberLogStreamTrackingPort } from '../ports/MemberLogStreamTrackingPort'; + +export class SetMemberLogStreamTrackingUseCase { + constructor(private readonly tracking: MemberLogStreamTrackingPort) {} + + async execute(teamName: string, enabled: boolean): Promise { + await this.tracking.setTracking(teamName, enabled); + } +} diff --git a/src/features/member-log-stream/core/application/use-cases/__tests__/GetMemberLogStreamUseCase.test.ts b/src/features/member-log-stream/core/application/use-cases/__tests__/GetMemberLogStreamUseCase.test.ts new file mode 100644 index 00000000..358d24d1 --- /dev/null +++ b/src/features/member-log-stream/core/application/use-cases/__tests__/GetMemberLogStreamUseCase.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { GetMemberLogStreamUseCase } from '../GetMemberLogStreamUseCase'; + +import type { MemberLogStreamSegment } from '../../../../contracts'; +import type { + MemberLogStreamSource, + MemberLogStreamSourceResult, +} from '../../ports/MemberLogStreamSource'; +import type { BoardTaskLogParticipant } from '@shared/types'; + +const generatedAt = Date.parse('2026-02-01T00:00:00.000Z'); + +const participant: BoardTaskLogParticipant = { + key: 'member:alice', + label: 'alice', + role: 'member', + isLead: false, + isSidechain: false, +}; + +function segment(id: string): MemberLogStreamSegment { + return { + id, + participantKey: participant.key, + actor: { + memberName: 'alice', + role: 'member', + sessionId: `session-${id}`, + isSidechain: false, + }, + startTimestamp: '2026-02-01T00:00:00.000Z', + endTimestamp: '2026-02-01T00:00:00.000Z', + chunks: [], + source: { + provider: 'claude_transcript', + label: 'Claude transcript', + sessionId: `session-${id}`, + }, + }; +} + +function includedResult(id: string): MemberLogStreamSourceResult { + return { + provider: 'claude_transcript', + status: 'included', + participants: [participant], + segments: [segment(id)], + warnings: [], + metadata: { + scannedTranscriptFileCount: 1, + includedTranscriptFileCount: 1, + }, + }; +} + +describe('GetMemberLogStreamUseCase', () => { + it('keeps the stream fail-soft when one source throws', async () => { + const logger = { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }; + const useCase = new GetMemberLogStreamUseCase({ + sources: [ + { + provider: 'claude_transcript', + load: vi.fn().mockResolvedValue(includedResult('ok')), + }, + { + provider: 'opencode_runtime', + load: vi.fn().mockRejectedValue(new Error('runtime down')), + }, + ], + clock: { now: () => generatedAt }, + logger, + }); + + const response = await useCase.execute({ + teamName: 'alpha-team', + memberName: 'alice', + }); + + expect(response.segments.map((item) => item.id)).toEqual(['ok']); + expect(response.coverage).toEqual([ + { provider: 'claude_transcript', status: 'included' }, + { provider: 'opencode_runtime', status: 'skipped', reason: 'runtime down' }, + ]); + expect(response.warnings).toEqual([ + { code: 'opencode_runtime_unavailable', message: 'runtime down' }, + ]); + expect(response.generatedAt).toBe('2026-02-01T00:00:00.000Z'); + expect(logger.warn).toHaveBeenCalledWith( + 'Member log stream source opencode_runtime failed for alpha-team/alice: runtime down' + ); + }); + + it('joins identical in-flight requests and releases the key after completion', async () => { + const resolveLoad: ((value: MemberLogStreamSourceResult) => void)[] = []; + const load = vi.fn( + () => + new Promise((resolve) => { + resolveLoad.push(resolve); + }) + ); + const source: MemberLogStreamSource = { + provider: 'claude_transcript', + load, + }; + const useCase = new GetMemberLogStreamUseCase({ + sources: [source], + clock: { now: () => generatedAt }, + logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + }); + + const first = useCase.execute({ + teamName: 'alpha-team', + memberName: 'alice', + limitSegments: 5, + forceRefresh: true, + }); + const second = useCase.execute({ + teamName: 'alpha-team', + memberName: 'alice', + limitSegments: 5, + forceRefresh: true, + }); + + expect(load).toHaveBeenCalledTimes(1); + resolveLoad[0]?.(includedResult('joined')); + + const [firstResponse, secondResponse] = await Promise.all([first, second]); + expect(firstResponse.segments.map((item) => item.id)).toEqual(['joined']); + expect(secondResponse.segments.map((item) => item.id)).toEqual(['joined']); + + const third = useCase.execute({ + teamName: 'alpha-team', + memberName: 'alice', + limitSegments: 5, + forceRefresh: true, + }); + + expect(load).toHaveBeenCalledTimes(2); + resolveLoad[1]?.(includedResult('after-release')); + await expect(third).resolves.toMatchObject({ + segments: [{ id: 'after-release' } as MemberLogStreamSegment], + }); + }); +}); diff --git a/src/features/member-log-stream/core/domain/models/MemberLogStreamBudget.ts b/src/features/member-log-stream/core/domain/models/MemberLogStreamBudget.ts new file mode 100644 index 00000000..12999854 --- /dev/null +++ b/src/features/member-log-stream/core/domain/models/MemberLogStreamBudget.ts @@ -0,0 +1,35 @@ +export interface MemberLogStreamBudget { + maxTranscriptFiles: number; + maxSegments: number; + maxChunks: number; + maxSourceMessages: number; + maxMessagesPerSegment: number; + maxTotalContentChars: number; + maxMessageContentChars: number; + maxToolResultContentChars: number; + openCodeMessageLimit: number; + openCodeTimeoutMs: number; +} + +export const DEFAULT_MEMBER_LOG_STREAM_BUDGET: MemberLogStreamBudget = { + maxTranscriptFiles: 40, + maxSegments: 30, + maxChunks: 250, + maxSourceMessages: 1200, + maxMessagesPerSegment: 300, + maxTotalContentChars: 800_000, + maxMessageContentChars: 80_000, + maxToolResultContentChars: 120_000, + openCodeMessageLimit: 400, + openCodeTimeoutMs: 5_000, +}; + +export function clampMemberLogStreamSegmentLimit( + requested: number | undefined, + budget: MemberLogStreamBudget = DEFAULT_MEMBER_LOG_STREAM_BUDGET +): number { + if (typeof requested !== 'number' || !Number.isFinite(requested)) { + return budget.maxSegments; + } + return Math.max(1, Math.min(80, Math.floor(requested), budget.maxSegments)); +} diff --git a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogStreamMergePolicy.test.ts b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogStreamMergePolicy.test.ts new file mode 100644 index 00000000..e860fd68 --- /dev/null +++ b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogStreamMergePolicy.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_MEMBER_LOG_STREAM_BUDGET } from '../../models/MemberLogStreamBudget'; +import { buildMemberLogStreamResponse } from '../memberLogStreamMergePolicy'; + +import type { MemberLogStreamSegment } from '../../../../contracts'; +import type { BoardTaskLogParticipant } from '@shared/types'; + +const participant: BoardTaskLogParticipant = { + key: 'member:alice', + label: 'alice', + role: 'member', + isLead: false, + isSidechain: false, +}; + +function segment( + id: string, + timestamp: string, + provider: MemberLogStreamSegment['source']['provider'] = 'claude_transcript' +): MemberLogStreamSegment { + return { + id, + participantKey: participant.key, + actor: { + memberName: 'alice', + role: 'member', + sessionId: `session-${id}`, + isSidechain: false, + }, + startTimestamp: timestamp, + endTimestamp: timestamp, + chunks: [], + source: { + provider, + label: provider, + sessionId: `session-${id}`, + }, + }; +} + +describe('buildMemberLogStreamResponse', () => { + it('sorts segments chronologically, keeps the recent limit, and marks bounded windows as truncated', () => { + const response = buildMemberLogStreamResponse({ + participants: [participant, participant], + segments: [ + segment('newest', '2026-01-01T00:03:00.000Z'), + segment('oldest', '2026-01-01T00:01:00.000Z'), + segment('middle', '2026-01-01T00:02:00.000Z'), + ], + coverage: [ + { provider: 'codex_native_trace', status: 'skipped' }, + { provider: 'claude_transcript', status: 'included' }, + ], + warnings: [], + generatedAt: '2026-01-01T00:04:00.000Z', + budget: DEFAULT_MEMBER_LOG_STREAM_BUDGET, + limitSegments: 2, + metadata: { + scannedTranscriptFileCount: 3, + includedTranscriptFileCount: 3, + droppedSegmentCount: 0, + droppedChunkCount: 0, + droppedMessageCount: 0, + }, + }); + + expect(response.segments.map((item) => item.id)).toEqual(['middle', 'newest']); + expect(response.participants).toEqual([participant]); + expect(response.coverage.map((item) => item.provider)).toEqual([ + 'claude_transcript', + 'codex_native_trace', + ]); + expect(response.truncated).toBe(true); + expect(response.metadata.droppedSegmentCount).toBe(1); + expect(response.warnings).toEqual([ + { + code: 'large_log_window_limited', + message: 'Showing a bounded recent member log stream to keep the popup responsive.', + }, + ]); + }); + + it('classifies mixed transcript and runtime streams without relying on coverage-only data', () => { + const mixed = buildMemberLogStreamResponse({ + participants: [participant], + segments: [ + segment('claude', '2026-01-01T00:01:00.000Z', 'claude_transcript'), + segment('opencode', '2026-01-01T00:02:00.000Z', 'opencode_runtime'), + ], + coverage: [ + { provider: 'claude_transcript', status: 'included' }, + { provider: 'opencode_runtime', status: 'included' }, + { provider: 'codex_native_trace', status: 'skipped' }, + ], + warnings: [], + generatedAt: '2026-01-01T00:03:00.000Z', + budget: DEFAULT_MEMBER_LOG_STREAM_BUDGET, + limitSegments: 10, + metadata: { + scannedTranscriptFileCount: 1, + includedTranscriptFileCount: 1, + droppedSegmentCount: 0, + droppedChunkCount: 0, + droppedMessageCount: 0, + }, + }); + + expect(mixed.source).toBe('member_mixed_runtime'); + }); +}); diff --git a/src/features/member-log-stream/core/domain/policies/memberLogStreamMergePolicy.ts b/src/features/member-log-stream/core/domain/policies/memberLogStreamMergePolicy.ts new file mode 100644 index 00000000..84def383 --- /dev/null +++ b/src/features/member-log-stream/core/domain/policies/memberLogStreamMergePolicy.ts @@ -0,0 +1,147 @@ +import type { + MemberLogStreamCoverage, + MemberLogStreamProvider, + MemberLogStreamResponse, + MemberLogStreamSegment, + MemberLogStreamSource, + MemberLogStreamWarning, +} from '../../../contracts'; +import type { MemberLogStreamBudget } from '../models/MemberLogStreamBudget'; +import type { BoardTaskLogParticipant } from '@shared/types'; + +export const MEMBER_LOG_STREAM_PROVIDER_ORDER: readonly MemberLogStreamProvider[] = [ + 'claude_transcript', + 'opencode_runtime', + 'codex_native_trace', +]; + +function getSegmentStartMs(segment: MemberLogStreamSegment): number { + const parsed = Date.parse(segment.startTimestamp); + return Number.isFinite(parsed) ? parsed : 0; +} + +function dedupeParticipants( + participants: readonly BoardTaskLogParticipant[] +): BoardTaskLogParticipant[] { + const deduped = new Map(); + for (const participant of participants) { + if (!deduped.has(participant.key)) { + deduped.set(participant.key, participant); + } + } + return [...deduped.values()]; +} + +export function inferMemberLogStreamSource( + segments: readonly MemberLogStreamSegment[] +): MemberLogStreamSource { + if (segments.length === 0) { + return 'member_empty'; + } + + const hasTranscript = segments.some((segment) => segment.source.provider === 'claude_transcript'); + const hasRuntime = segments.some((segment) => segment.source.provider === 'opencode_runtime'); + + if (hasTranscript && hasRuntime) { + return 'member_mixed_runtime'; + } + if (hasRuntime) { + return 'member_runtime_only'; + } + return 'member_transcript'; +} + +export function buildMemberLogStreamResponse(input: { + participants: readonly BoardTaskLogParticipant[]; + segments: readonly MemberLogStreamSegment[]; + coverage: readonly MemberLogStreamCoverage[]; + warnings: readonly MemberLogStreamWarning[]; + generatedAt: string; + budget: MemberLogStreamBudget; + limitSegments: number; + metadata: { + scannedTranscriptFileCount: number; + includedTranscriptFileCount: number; + droppedSegmentCount: number; + droppedChunkCount: number; + droppedMessageCount: number; + }; +}): MemberLogStreamResponse { + const warnings = [...input.warnings]; + const sorted = [...input.segments].sort((left, right) => { + const byTime = getSegmentStartMs(left) - getSegmentStartMs(right); + return byTime !== 0 ? byTime : left.id.localeCompare(right.id); + }); + + let droppedSegmentCount = input.metadata.droppedSegmentCount; + let droppedChunkCount = input.metadata.droppedChunkCount; + let limitedSegments = sorted; + const maxSegments = Math.min(input.limitSegments, input.budget.maxSegments); + if (limitedSegments.length > maxSegments) { + droppedSegmentCount += limitedSegments.length - maxSegments; + limitedSegments = limitedSegments.slice(-maxSegments); + } + + const totalChunks = limitedSegments.reduce((sum, segment) => sum + segment.chunks.length, 0); + if (totalChunks > input.budget.maxChunks) { + const retained: MemberLogStreamSegment[] = []; + let remaining = input.budget.maxChunks; + for (const segment of [...limitedSegments].reverse()) { + if (remaining <= 0) { + droppedSegmentCount += 1; + continue; + } + if (segment.chunks.length <= remaining) { + retained.push(segment); + remaining -= segment.chunks.length; + continue; + } + const keptChunks = segment.chunks.slice(-remaining); + droppedChunkCount += segment.chunks.length - keptChunks.length; + retained.push({ + ...segment, + chunks: keptChunks, + source: { ...segment.source, truncated: true }, + }); + remaining = 0; + } + const retainedInDisplayOrder = [...retained].reverse(); + limitedSegments = retainedInDisplayOrder; + } + + const truncated = + droppedSegmentCount > input.metadata.droppedSegmentCount || + droppedChunkCount > input.metadata.droppedChunkCount || + input.metadata.droppedMessageCount > 0 || + limitedSegments.some((segment) => segment.source.truncated); + + if (truncated && !warnings.some((warning) => warning.code === 'large_log_window_limited')) { + warnings.push({ + code: 'large_log_window_limited', + message: 'Showing a bounded recent member log stream to keep the popup responsive.', + }); + } + + const participants = dedupeParticipants(input.participants); + return { + participants, + defaultFilter: participants.length === 1 ? (participants[0]?.key ?? 'all') : 'all', + segments: limitedSegments, + source: inferMemberLogStreamSource(limitedSegments), + coverage: [...input.coverage].sort( + (left, right) => + MEMBER_LOG_STREAM_PROVIDER_ORDER.indexOf(left.provider) - + MEMBER_LOG_STREAM_PROVIDER_ORDER.indexOf(right.provider) + ), + warnings, + truncated, + generatedAt: input.generatedAt, + metadata: { + scannedTranscriptFileCount: input.metadata.scannedTranscriptFileCount, + includedTranscriptFileCount: input.metadata.includedTranscriptFileCount, + droppedSegmentCount, + droppedChunkCount, + droppedMessageCount: input.metadata.droppedMessageCount, + }, + }; +} diff --git a/src/features/member-log-stream/main/adapters/input/ipc/__tests__/registerMemberLogStreamIpc.test.ts b/src/features/member-log-stream/main/adapters/input/ipc/__tests__/registerMemberLogStreamIpc.test.ts new file mode 100644 index 00000000..322ce182 --- /dev/null +++ b/src/features/member-log-stream/main/adapters/input/ipc/__tests__/registerMemberLogStreamIpc.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { MEMBER_LOG_STREAM_GET, MEMBER_LOG_STREAM_SET_TRACKING } from '../../../../../contracts'; +import { + registerMemberLogStreamIpc, + removeMemberLogStreamIpc, +} from '../registerMemberLogStreamIpc'; + +import type { MemberLogStreamResponse } from '../../../../../contracts'; +import type { MemberLogStreamFeatureFacade } from '../../../../composition/createMemberLogStreamFeature'; +import type { IpcMainInvokeEvent } from 'electron'; + +vi.mock('@shared/utils/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +function emptyResponse(): MemberLogStreamResponse { + return { + participants: [], + defaultFilter: 'all', + segments: [], + source: 'member_empty', + coverage: [], + warnings: [], + truncated: false, + generatedAt: '2026-03-01T00:00:00.000Z', + metadata: { + scannedTranscriptFileCount: 0, + includedTranscriptFileCount: 0, + droppedSegmentCount: 0, + droppedChunkCount: 0, + droppedMessageCount: 0, + }, + }; +} + +function createFakeIpcMain(): { + handlers: Map unknown>; + ipcMain: { + handle: ReturnType; + removeHandler: ReturnType; + }; +} { + const handlers = new Map unknown>(); + return { + handlers, + ipcMain: { + handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => { + handlers.set(channel, handler); + }), + removeHandler: vi.fn((channel: string) => { + handlers.delete(channel); + }), + }, + }; +} + +describe('registerMemberLogStreamIpc', () => { + it('validates and normalizes getMemberLogStream options before calling the feature facade', async () => { + const { handlers, ipcMain } = createFakeIpcMain(); + const getMemberLogStream = vi.fn().mockResolvedValue(emptyResponse()); + const feature: MemberLogStreamFeatureFacade = { + getMemberLogStream, + setMemberLogStreamTracking: vi.fn(), + }; + + registerMemberLogStreamIpc(ipcMain as never, feature); + const result = await handlers.get(MEMBER_LOG_STREAM_GET)?.( + {} as IpcMainInvokeEvent, + 'alpha-team', + 'alice', + { + limitSegments: 200, + since: '2026-03-01T12:34:56.000Z', + laneId: ' secondary:opencode:alice ', + forceRefresh: true, + } + ); + + expect(result).toEqual({ success: true, data: emptyResponse() }); + expect(getMemberLogStream).toHaveBeenCalledWith({ + teamName: 'alpha-team', + memberName: 'alice', + limitSegments: 80, + sinceMs: Date.parse('2026-03-01T12:34:56.000Z'), + laneId: 'secondary:opencode:alice', + forceRefresh: true, + }); + }); + + it('rejects unknown options and unsafe runtime lane ids', async () => { + const { handlers, ipcMain } = createFakeIpcMain(); + const getMemberLogStream = vi.fn().mockResolvedValue(emptyResponse()); + const feature: MemberLogStreamFeatureFacade = { + getMemberLogStream, + setMemberLogStreamTracking: vi.fn(), + }; + + registerMemberLogStreamIpc(ipcMain as never, feature); + const get = handlers.get(MEMBER_LOG_STREAM_GET)!; + + await expect( + get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { unknown: true }) + ).resolves.toEqual({ + success: false, + error: 'Unknown getMemberLogStream option: unknown', + }); + await expect( + get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { laneId: '../bad' }) + ).resolves.toEqual({ + success: false, + error: 'laneId contains invalid characters', + }); + expect(getMemberLogStream).not.toHaveBeenCalled(); + }); + + it('accepts primary lane ids and rejects malformed optional values', async () => { + const { handlers, ipcMain } = createFakeIpcMain(); + const getMemberLogStream = vi.fn().mockResolvedValue(emptyResponse()); + const feature: MemberLogStreamFeatureFacade = { + getMemberLogStream, + setMemberLogStreamTracking: vi.fn(), + }; + + registerMemberLogStreamIpc(ipcMain as never, feature); + const get = handlers.get(MEMBER_LOG_STREAM_GET)!; + + await expect( + get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { laneId: 'primary' }) + ).resolves.toEqual({ success: true, data: emptyResponse() }); + expect(getMemberLogStream).toHaveBeenCalledWith({ + teamName: 'alpha-team', + memberName: 'alice', + laneId: 'primary', + }); + getMemberLogStream.mockClear(); + + await expect( + get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { since: 'not-a-date' }) + ).resolves.toEqual({ + success: false, + error: 'since must be a valid timestamp', + }); + await expect( + get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { forceRefresh: 'true' }) + ).resolves.toEqual({ + success: false, + error: 'forceRefresh must be a boolean', + }); + await expect( + get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { laneId: 'bad\nlane' }) + ).resolves.toEqual({ + success: false, + error: 'laneId contains invalid characters', + }); + await expect( + get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { laneId: 'x'.repeat(257) }) + ).resolves.toEqual({ + success: false, + error: 'laneId exceeds max length (256)', + }); + expect(getMemberLogStream).not.toHaveBeenCalled(); + }); + + it('validates tracking calls and unregisters both handlers', async () => { + const { handlers, ipcMain } = createFakeIpcMain(); + const setMemberLogStreamTracking = vi.fn().mockResolvedValue(undefined); + const feature: MemberLogStreamFeatureFacade = { + getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()), + setMemberLogStreamTracking, + }; + + registerMemberLogStreamIpc(ipcMain as never, feature); + const setTracking = handlers.get(MEMBER_LOG_STREAM_SET_TRACKING)!; + + await expect(setTracking({} as IpcMainInvokeEvent, 'alpha-team', true)).resolves.toEqual({ + success: true, + }); + await expect(setTracking({} as IpcMainInvokeEvent, 'alpha-team', 'yes')).resolves.toEqual({ + success: false, + error: 'enabled must be a boolean', + }); + expect(setMemberLogStreamTracking).toHaveBeenCalledWith('alpha-team', true); + + removeMemberLogStreamIpc(ipcMain as never); + + expect(handlers.has(MEMBER_LOG_STREAM_GET)).toBe(false); + expect(handlers.has(MEMBER_LOG_STREAM_SET_TRACKING)).toBe(false); + }); +}); diff --git a/src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts b/src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts new file mode 100644 index 00000000..f7a50ce2 --- /dev/null +++ b/src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts @@ -0,0 +1,183 @@ +import { validateMemberName, validateTeamName } from '@main/ipc/guards'; +import { createLogger } from '@shared/utils/logger'; + +import { + MEMBER_LOG_STREAM_GET, + MEMBER_LOG_STREAM_SET_TRACKING, + normalizeMemberLogStreamResponse, +} from '../../../../contracts'; + +import type { MemberLogStreamRequestOptions, MemberLogStreamResponse } from '../../../../contracts'; +import type { MemberLogStreamFeatureFacade } from '../../../composition/createMemberLogStreamFeature'; +import type { IpcResult } from '@shared/types'; +import type { IpcMain, IpcMainInvokeEvent } from 'electron'; + +const logger = createLogger('Feature:MemberLogStream:IPC'); +const ALLOWED_OPTION_KEYS = new Set(['limitSegments', 'since', 'laneId', 'forceRefresh']); + +interface ValidationResult { + valid: boolean; + value?: T; + error?: string; +} + +function validateOptionalRuntimeLaneId(value: unknown): ValidationResult { + if (value == null) return { valid: true, value: undefined }; + if (typeof value !== 'string') return { valid: false, error: 'laneId must be a string' }; + const trimmed = value.trim(); + if (!trimmed) return { valid: true, value: undefined }; + if (trimmed.length > 256) return { valid: false, error: 'laneId exceeds max length (256)' }; + if ( + trimmed.includes('/') || + trimmed.includes('\\') || + [...trimmed].some((char) => { + const code = char.charCodeAt(0); + return code <= 31 || code === 127; + }) + ) { + return { valid: false, error: 'laneId contains invalid characters' }; + } + return { valid: true, value: trimmed }; +} + +function normalizeOptions(options: unknown): ValidationResult<{ + limitSegments?: number; + sinceMs?: number | null; + laneId?: string; + forceRefresh?: boolean; +}> { + if (options == null) { + return { valid: true, value: {} }; + } + if (typeof options !== 'object' || Array.isArray(options)) { + return { valid: false, error: 'options must be an object' }; + } + + const record = options as Record; + for (const key of Object.keys(record)) { + if (!ALLOWED_OPTION_KEYS.has(key)) { + return { valid: false, error: `Unknown getMemberLogStream option: ${key}` }; + } + } + + let limitSegments: number | undefined; + if (record.limitSegments != null) { + if (typeof record.limitSegments !== 'number' || !Number.isFinite(record.limitSegments)) { + return { valid: false, error: 'limitSegments must be a finite number' }; + } + limitSegments = Math.max(1, Math.min(80, Math.floor(record.limitSegments))); + } + + let sinceMs: number | null | undefined; + if (record.since != null) { + if (typeof record.since !== 'string') { + return { valid: false, error: 'since must be an ISO timestamp string' }; + } + const parsed = Date.parse(record.since); + if (!Number.isFinite(parsed)) { + return { valid: false, error: 'since must be a valid timestamp' }; + } + sinceMs = parsed; + } + + const lane = validateOptionalRuntimeLaneId(record.laneId); + if (!lane.valid) { + return { valid: false, error: lane.error }; + } + + let forceRefresh: boolean | undefined; + if (record.forceRefresh != null) { + if (typeof record.forceRefresh !== 'boolean') { + return { valid: false, error: 'forceRefresh must be a boolean' }; + } + forceRefresh = record.forceRefresh; + } + + return { + valid: true, + value: { + ...(limitSegments !== undefined ? { limitSegments } : {}), + ...(sinceMs !== undefined ? { sinceMs } : {}), + ...(lane.value !== undefined ? { laneId: lane.value } : {}), + ...(forceRefresh !== undefined ? { forceRefresh } : {}), + }, + }; +} + +export function registerMemberLogStreamIpc( + ipcMain: IpcMain, + feature: MemberLogStreamFeatureFacade +): void { + ipcMain.handle( + MEMBER_LOG_STREAM_GET, + async ( + _event: IpcMainInvokeEvent, + teamName: unknown, + memberName: unknown, + options?: MemberLogStreamRequestOptions + ): Promise> => { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) { + return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + } + const vMember = validateMemberName(memberName); + if (!vMember.valid) { + return { success: false, error: vMember.error ?? 'Invalid memberName' }; + } + const vOptions = normalizeOptions(options); + if (!vOptions.valid) { + return { success: false, error: vOptions.error ?? 'Invalid options' }; + } + + try { + const response = await feature.getMemberLogStream({ + teamName: vTeam.value!, + memberName: vMember.value!, + ...vOptions.value!, + }); + return { success: true, data: normalizeMemberLogStreamResponse(response) }; + } catch (error) { + logger.error('Failed to load member log stream', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to load member log stream', + }; + } + } + ); + + ipcMain.handle( + MEMBER_LOG_STREAM_SET_TRACKING, + async ( + _event: IpcMainInvokeEvent, + teamName: unknown, + enabled: unknown + ): Promise> => { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) { + return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + } + if (typeof enabled !== 'boolean') { + return { success: false, error: 'enabled must be a boolean' }; + } + try { + await feature.setMemberLogStreamTracking(vTeam.value!, enabled); + return { success: true }; + } catch (error) { + logger.error('Failed to update member log stream tracking', error); + return { + success: false, + error: + error instanceof Error + ? error.message + : 'Failed to update member log stream tracking', + }; + } + } + ); +} + +export function removeMemberLogStreamIpc(ipcMain: IpcMain): void { + ipcMain.removeHandler(MEMBER_LOG_STREAM_GET); + ipcMain.removeHandler(MEMBER_LOG_STREAM_SET_TRACKING); +} diff --git a/src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource.ts b/src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource.ts new file mode 100644 index 00000000..cb255dd6 --- /dev/null +++ b/src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource.ts @@ -0,0 +1,214 @@ +import { applyMemberLogMessageBudget } from '../../../infrastructure/memberLogMessageBudget'; + +import { + buildMemberActor, + buildMemberParticipant, + buildSegmentId, + normalizeMemberName, + shortHash, + withSegmentSource, +} from './memberLogStreamSourceUtils'; + +import type { MemberLogStreamWarning } from '../../../../contracts'; +import type { LoggerPort } from '../../../../core/application/ports/LoggerPort'; +import type { + MemberLogStreamSource, + MemberLogStreamSourceInput, + MemberLogStreamSourceResult, +} from '../../../../core/application/ports/MemberLogStreamSource'; +import type { BoardTaskExactLogChunkBuilder } from '@main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder'; +import type { BoardTaskExactLogStrictParser } from '@main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser'; +import type { + MemberLogFileRef, + TeamMemberLogsFinder, +} from '@main/services/team/TeamMemberLogsFinder'; +import type { ParsedMessage } from '@main/types'; + +function isPreferredRef(candidate: MemberLogFileRef, existing: MemberLogFileRef): boolean { + const candidateMessageCount = candidate.messageCount ?? -1; + const existingMessageCount = existing.messageCount ?? -1; + if (candidateMessageCount !== existingMessageCount) { + return candidateMessageCount > existingMessageCount; + } + + const candidateSize = candidate.sizeBytes ?? -1; + const existingSize = existing.sizeBytes ?? -1; + if (candidateSize !== existingSize) { + return candidateSize > existingSize; + } + + return candidate.mtimeMs > existing.mtimeMs; +} + +function dedupeMemberLogRefs(refs: readonly MemberLogFileRef[]): MemberLogFileRef[] { + const byFilePath = new Map(); + const bySession = new Map(); + const passthrough: MemberLogFileRef[] = []; + + for (const ref of refs) { + if (byFilePath.has(ref.filePath)) continue; + byFilePath.set(ref.filePath, ref); + + if (ref.kind === 'lead_session') { + passthrough.push(ref); + continue; + } + + const key = `${ref.kind ?? 'unknown'}:${normalizeMemberName(ref.memberName)}:${ref.sessionId}`; + const existing = bySession.get(key); + if (!existing || isPreferredRef(ref, existing)) { + bySession.set(key, ref); + } + } + + return [...passthrough, ...bySession.values()].sort((left, right) => { + const byTime = right.mtimeMs - left.mtimeMs; + return byTime !== 0 ? byTime : left.filePath.localeCompare(right.filePath); + }); +} + +function filterSourceMessageBudget( + messages: readonly ParsedMessage[], + remaining: number +): { messages: ParsedMessage[]; dropped: number; limited: boolean } { + if (remaining <= 0) { + return { messages: [], dropped: messages.length, limited: messages.length > 0 }; + } + if (messages.length <= remaining) { + return { messages: [...messages], dropped: 0, limited: false }; + } + return { + messages: messages.slice(-remaining), + dropped: messages.length - remaining, + limited: true, + }; +} + +export class ClaudeMemberTranscriptStreamSource implements MemberLogStreamSource { + readonly provider = 'claude_transcript' as const; + + constructor( + private readonly logsFinder: TeamMemberLogsFinder, + private readonly parser: BoardTaskExactLogStrictParser, + private readonly chunkBuilder: BoardTaskExactLogChunkBuilder, + private readonly logger: LoggerPort + ) {} + + async load(input: MemberLogStreamSourceInput): Promise { + const warnings: MemberLogStreamWarning[] = []; + const refs = await this.logsFinder.findRecentMemberLogFileRefsByMember( + input.teamName, + [input.memberName], + { + mtimeSinceMs: input.sinceMs ?? null, + forceRefresh: input.forceRefresh === true, + } + ); + const dedupedRefs = dedupeMemberLogRefs(refs); + const cappedRefs = dedupedRefs.slice(0, input.budget.maxTranscriptFiles); + const droppedRefCount = Math.max(0, dedupedRefs.length - cappedRefs.length); + if (droppedRefCount > 0) { + warnings.push({ + code: 'large_log_window_limited', + message: `Showing ${cappedRefs.length} recent transcript files for this member.`, + }); + } + + const parsedByPath = await this.parser.parseFiles(cappedRefs.map((ref) => ref.filePath)); + const participant = buildMemberParticipant(input.memberName); + const segments = []; + let remainingSourceMessages = input.budget.maxSourceMessages; + let includedTranscriptFileCount = 0; + let droppedMessageCount = 0; + let contentLimited = false; + let windowLimited = false; + + for (const ref of cappedRefs) { + const parsedMessages = parsedByPath.get(ref.filePath) ?? []; + if (parsedMessages.length === 0) continue; + + const sourceBudgeted = filterSourceMessageBudget(parsedMessages, remainingSourceMessages); + remainingSourceMessages -= sourceBudgeted.messages.length; + droppedMessageCount += sourceBudgeted.dropped; + windowLimited = windowLimited || sourceBudgeted.limited; + + const budgeted = applyMemberLogMessageBudget(sourceBudgeted.messages, input.budget); + droppedMessageCount += budgeted.droppedMessageCount; + contentLimited = contentLimited || budgeted.contentLimited; + windowLimited = windowLimited || budgeted.segmentWindowLimited; + if (budgeted.messages.length === 0) continue; + + const chunks = this.chunkBuilder.buildBundleChunks(budgeted.messages); + if (chunks.length === 0) continue; + + const first = budgeted.messages[0]; + const last = budgeted.messages[budgeted.messages.length - 1]; + if (!first || !last) continue; + + includedTranscriptFileCount += 1; + const role = ref.kind === 'lead_session' ? 'lead' : 'member'; + segments.push( + withSegmentSource( + { + id: buildSegmentId({ + provider: this.provider, + teamName: input.teamName, + memberName: input.memberName, + sessionId: ref.sessionId, + fingerprint: shortHash(`${ref.filePath}:${ref.mtimeMs}:${ref.sizeBytes ?? ''}`), + startTimestamp: first.timestamp.toISOString(), + }), + participantKey: participant.key, + actor: buildMemberActor({ + memberName: input.memberName, + sessionId: ref.sessionId, + role, + }), + startTimestamp: first.timestamp.toISOString(), + endTimestamp: last.timestamp.toISOString(), + chunks, + }, + { + provider: this.provider, + label: role === 'lead' ? 'Claude lead transcript' : 'Claude transcript', + sessionId: ref.sessionId, + messageCount: budgeted.messages.length, + truncated: budgeted.droppedMessageCount > 0 || budgeted.contentLimited, + } + ) + ); + } + + if (windowLimited) { + warnings.push({ + code: 'segment_message_window_limited', + message: 'Some transcript sessions were trimmed to recent messages.', + }); + } + if (contentLimited) { + warnings.push({ + code: 'message_content_limited', + message: 'Some large message content was truncated before rendering.', + }); + } + + this.logger.debug?.( + `Claude member log stream ${input.teamName}/${input.memberName}: refs=${refs.length}, segments=${segments.length}` + ); + + return { + provider: this.provider, + status: segments.length > 0 ? 'included' : 'skipped', + reason: segments.length > 0 ? undefined : 'no_member_transcripts', + participants: segments.length > 0 ? [participant] : [], + segments, + warnings, + metadata: { + scannedTranscriptFileCount: refs.length, + includedTranscriptFileCount, + droppedSegmentCount: droppedRefCount, + droppedMessageCount, + }, + }; + } +} diff --git a/src/features/member-log-stream/main/adapters/output/sources/CodexNativeMemberTraceStreamSource.ts b/src/features/member-log-stream/main/adapters/output/sources/CodexNativeMemberTraceStreamSource.ts new file mode 100644 index 00000000..8c9a5301 --- /dev/null +++ b/src/features/member-log-stream/main/adapters/output/sources/CodexNativeMemberTraceStreamSource.ts @@ -0,0 +1,41 @@ +import { isLeadMember } from '@shared/utils/leadDetection'; + +import type { + MemberLogStreamSource, + MemberLogStreamSourceInput, + MemberLogStreamSourceResult, +} from '../../../../core/application/ports/MemberLogStreamSource'; +import type { TeamConfigReader } from '@main/services/team/TeamConfigReader'; + +export class CodexNativeMemberTraceStreamSource implements MemberLogStreamSource { + readonly provider = 'codex_native_trace' as const; + + constructor(private readonly configReader: TeamConfigReader) {} + + async load(input: MemberLogStreamSourceInput): Promise { + const config = await this.configReader.getConfig(input.teamName).catch(() => null); + const member = config?.members?.find( + (item) => item.name.trim().toLowerCase() === input.memberName.trim().toLowerCase() + ); + const isCodexMember = + member?.providerId === 'codex' || + member?.providerBackendId === 'codex-native' || + (member ? false : isLeadMember({ name: input.memberName })); + + return { + provider: this.provider, + status: 'skipped', + reason: 'codex_member_wide_not_supported', + participants: [], + segments: [], + warnings: isCodexMember + ? [ + { + code: 'codex_member_wide_not_supported', + message: 'Codex member-wide native trace is not available in this variant yet.', + }, + ] + : [], + }; + } +} diff --git a/src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimeStreamSource.ts b/src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimeStreamSource.ts new file mode 100644 index 00000000..7c2fa732 --- /dev/null +++ b/src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimeStreamSource.ts @@ -0,0 +1,230 @@ +import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; +import { mapOpenCodeRuntimeTranscriptMessagesToParsedMessages } from '@main/services/team/taskLogs/stream/OpenCodeRuntimeProjectionMapper'; + +import { applyMemberLogMessageBudget } from '../../../infrastructure/memberLogMessageBudget'; + +import { + buildMemberActor, + buildMemberParticipant, + buildSegmentId, + normalizeMemberName, + withSegmentSource, +} from './memberLogStreamSourceUtils'; + +import type { MemberLogStreamWarning } from '../../../../contracts'; +import type { + MemberLogStreamSource, + MemberLogStreamSourceInput, + MemberLogStreamSourceResult, +} from '../../../../core/application/ports/MemberLogStreamSource'; +import type { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService'; +import type { BoardTaskExactLogChunkBuilder } from '@main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder'; + +interface BinaryResolverLike { + resolve(): Promise; +} + +const CACHE_TTL_MS = 1_500; + +function classifyOpenCodeError(error: unknown): MemberLogStreamWarning { + const message = error instanceof Error ? error.message : String(error); + const normalized = message.toLowerCase(); + if (normalized.includes('timed out') || normalized.includes('timeout')) { + return { + code: 'opencode_runtime_timeout', + message: 'OpenCode runtime transcript timed out; showing other member logs only.', + }; + } + if (normalized.includes('--lane') || normalized.includes('multiple') || normalized.includes('ambiguous')) { + return { + code: 'opencode_ambiguous_lane', + message: 'OpenCode runtime session is ambiguous without a safe lane id.', + }; + } + return { + code: 'opencode_runtime_unavailable', + message: `OpenCode runtime transcript is unavailable: ${message}`, + }; +} + +export class OpenCodeMemberRuntimeStreamSource implements MemberLogStreamSource { + readonly provider = 'opencode_runtime' as const; + private readonly cache = new Map(); + private readonly inFlight = new Map>(); + + constructor( + private readonly runtimeBridge: ClaudeMultimodelBridgeService, + private readonly chunkBuilder: BoardTaskExactLogChunkBuilder, + private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver + ) {} + + async load(input: MemberLogStreamSourceInput): Promise { + const cacheKey = [ + input.teamName, + normalizeMemberName(input.memberName), + input.laneId ?? '', + input.budget.openCodeMessageLimit, + ].join('::'); + + if (!input.forceRefresh) { + const cached = this.cache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.result; + } + } + + const existing = this.inFlight.get(cacheKey); + if (existing) { + return existing; + } + + const promise = this.buildResult(input) + .then((result) => { + this.cache.set(cacheKey, { expiresAt: Date.now() + CACHE_TTL_MS, result }); + return result; + }) + .finally(() => { + this.inFlight.delete(cacheKey); + }); + this.inFlight.set(cacheKey, promise); + return promise; + } + + private async buildResult(input: MemberLogStreamSourceInput): Promise { + const binaryPath = await this.binaryResolver.resolve(); + if (!binaryPath) { + return this.skipped('opencode_runtime_unavailable', 'OpenCode runtime bridge is unavailable.'); + } + + try { + const transcript = await this.runtimeBridge.getOpenCodeTranscript(binaryPath, { + teamId: input.teamName, + memberName: input.memberName, + limit: input.budget.openCodeMessageLimit, + laneId: input.laneId, + timeoutMs: input.budget.openCodeTimeoutMs, + }); + const projectedMessages = transcript?.logProjection?.messages ?? []; + const parsedMessages = mapOpenCodeRuntimeTranscriptMessagesToParsedMessages(projectedMessages) + .sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime()); + if (parsedMessages.length === 0) { + return { + provider: this.provider, + status: 'skipped', + reason: 'opencode_missing_runtime_session', + participants: [], + segments: [], + warnings: [], + }; + } + + const budgeted = applyMemberLogMessageBudget(parsedMessages, input.budget); + if (budgeted.messages.length === 0) { + return { + provider: this.provider, + status: 'skipped', + reason: 'opencode_no_renderable_chunks', + participants: [], + segments: [], + warnings: [], + }; + } + + const chunks = this.chunkBuilder.buildBundleChunks(budgeted.messages); + if (chunks.length === 0) { + return { + provider: this.provider, + status: 'skipped', + reason: 'opencode_no_renderable_chunks', + participants: [], + segments: [], + warnings: [], + }; + } + + const first = budgeted.messages[0]; + const last = budgeted.messages[budgeted.messages.length - 1]; + if (!first || !last) { + return this.skipped('opencode_missing_runtime_session', 'OpenCode runtime projection was empty.'); + } + + const participant = buildMemberParticipant(input.memberName); + const sessionId = transcript?.sessionId ?? first.sessionId ?? `opencode:${normalizeMemberName(input.memberName)}`; + const segment = withSegmentSource( + { + id: buildSegmentId({ + provider: this.provider, + teamName: input.teamName, + memberName: input.memberName, + sessionId, + fingerprint: `${sessionId}:${input.laneId ?? ''}:${budgeted.messages.length}`, + startTimestamp: first.timestamp.toISOString(), + }), + participantKey: participant.key, + actor: buildMemberActor({ + memberName: input.memberName, + sessionId, + role: 'member', + }), + startTimestamp: first.timestamp.toISOString(), + endTimestamp: last.timestamp.toISOString(), + chunks, + }, + { + provider: this.provider, + label: 'OpenCode runtime', + sessionId, + ...(input.laneId ? { laneId: input.laneId } : {}), + messageCount: budgeted.messages.length, + truncated: + budgeted.droppedMessageCount > 0 || + budgeted.segmentWindowLimited || + budgeted.contentLimited, + } + ); + + const warnings: MemberLogStreamWarning[] = []; + if (budgeted.segmentWindowLimited) { + warnings.push({ + code: 'segment_message_window_limited', + message: 'OpenCode runtime stream was trimmed to recent messages.', + }); + } + if (budgeted.contentLimited) { + warnings.push({ + code: 'message_content_limited', + message: 'Some large OpenCode runtime content was truncated before rendering.', + }); + } + + return { + provider: this.provider, + status: 'included', + participants: [participant], + segments: [segment], + warnings, + metadata: { + droppedMessageCount: budgeted.droppedMessageCount, + }, + }; + } catch (error) { + const warning = classifyOpenCodeError(error); + return this.skipped(warning.code, warning.message, warning); + } + } + + private skipped( + code: MemberLogStreamWarning['code'], + reason: string, + warning: MemberLogStreamWarning = { code, message: reason } + ): MemberLogStreamSourceResult { + return { + provider: this.provider, + status: 'skipped', + reason, + participants: [], + segments: [], + warnings: [warning], + }; + } +} diff --git a/src/features/member-log-stream/main/adapters/output/sources/__tests__/memberLogSources.test.ts b/src/features/member-log-stream/main/adapters/output/sources/__tests__/memberLogSources.test.ts new file mode 100644 index 00000000..179f3706 --- /dev/null +++ b/src/features/member-log-stream/main/adapters/output/sources/__tests__/memberLogSources.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { DEFAULT_MEMBER_LOG_STREAM_BUDGET } from '../../../../../core/domain/models/MemberLogStreamBudget'; +import { ClaudeMemberTranscriptStreamSource } from '../ClaudeMemberTranscriptStreamSource'; +import { CodexNativeMemberTraceStreamSource } from '../CodexNativeMemberTraceStreamSource'; +import { OpenCodeMemberRuntimeStreamSource } from '../OpenCodeMemberRuntimeStreamSource'; + +import type { MemberLogStreamSourceInput } from '../../../../../core/application/ports/MemberLogStreamSource'; +import type { EnhancedChunk, ParsedMessage } from '@main/types'; + +function parsedMessage(uuid: string, timestamp: string): ParsedMessage { + return { + uuid, + parentUuid: null, + type: 'assistant', + timestamp: new Date(timestamp), + role: 'assistant', + content: `message ${uuid}`, + isSidechain: true, + isMeta: false, + sessionId: 'session-1', + toolCalls: [], + toolResults: [], + }; +} + +function fakeChunk(id: string): EnhancedChunk { + return { + id, + chunkType: 'ai', + startTime: new Date('2026-04-04T00:00:00.000Z'), + endTime: new Date('2026-04-04T00:00:01.000Z'), + durationMs: 1_000, + metrics: { + durationMs: 1_000, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + messageCount: 1, + }, + responses: [], + processes: [], + sidechainMessages: [], + toolExecutions: [], + semanticSteps: [], + rawMessages: [], + }; +} + +function sourceInput(overrides: Partial = {}): MemberLogStreamSourceInput { + return { + teamName: 'alpha-team', + memberName: 'alice', + budget: DEFAULT_MEMBER_LOG_STREAM_BUDGET, + ...overrides, + }; +} + +describe('ClaudeMemberTranscriptStreamSource', () => { + it('dedupes cumulative subagent refs by member/session before parsing and keeps path-safe segment ids', async () => { + const parseFiles = vi.fn().mockImplementation(async (paths: string[]) => { + const parsed = new Map(); + parsed.set('/transcripts/larger.jsonl', [ + parsedMessage('msg-1', '2026-04-04T00:00:00.000Z'), + parsedMessage('msg-2', '2026-04-04T00:01:00.000Z'), + ]); + expect(paths).toEqual(['/transcripts/larger.jsonl']); + return parsed; + }); + const chunkBuilder = { + buildBundleChunks: vi.fn(() => [fakeChunk('chunk-1')]), + }; + const source = new ClaudeMemberTranscriptStreamSource( + { + findRecentMemberLogFileRefsByMember: vi.fn().mockResolvedValue([ + { + memberName: 'alice', + sessionId: 'session-1', + filePath: '/transcripts/smaller.jsonl', + mtimeMs: 10, + sizeBytes: 1_000, + messageCount: 1, + kind: 'subagent', + }, + { + memberName: 'alice', + sessionId: 'session-1', + filePath: '/transcripts/larger.jsonl', + mtimeMs: 20, + sizeBytes: 5_000, + messageCount: 10, + kind: 'subagent', + }, + ]), + } as never, + { parseFiles } as never, + chunkBuilder as never, + { warn: vi.fn(), error: vi.fn(), debug: vi.fn() } + ); + + const result = await source.load(sourceInput()); + + expect(result.status).toBe('included'); + expect(parseFiles).toHaveBeenCalledWith(['/transcripts/larger.jsonl']); + expect(result.segments).toHaveLength(1); + expect(result.segments[0]?.id).not.toContain('/transcripts'); + expect(result.segments[0]?.source).toMatchObject({ + provider: 'claude_transcript', + sessionId: 'session-1', + messageCount: 2, + }); + }); +}); + +describe('OpenCodeMemberRuntimeStreamSource', () => { + it('enforces member message and content budgets before building OpenCode chunks', async () => { + const getOpenCodeTranscript = vi.fn().mockResolvedValue({ + sessionId: 'opencode-session', + logProjection: { + messages: [0, 1, 2].map((index) => ({ + uuid: `opencode-${index}`, + parentUuid: index === 0 ? null : `opencode-${index - 1}`, + type: 'assistant', + timestamp: `2026-04-04T00:00:0${index}.000Z`, + role: 'assistant', + content: `long OpenCode runtime message ${index} ${'x'.repeat(80)}`, + toolCalls: [], + toolResults: [], + isMeta: false, + sessionId: 'opencode-session', + })), + }, + }); + const buildBundleChunks = vi.fn((_: ParsedMessage[]) => [ + fakeChunk('opencode-budgeted-chunk'), + ]); + const source = new OpenCodeMemberRuntimeStreamSource( + { getOpenCodeTranscript } as never, + { buildBundleChunks } as never, + { resolve: vi.fn().mockResolvedValue('/mock/orchestrator') } + ); + + const result = await source.load( + sourceInput({ + budget: { + ...DEFAULT_MEMBER_LOG_STREAM_BUDGET, + maxMessagesPerSegment: 2, + maxTotalContentChars: 60, + maxMessageContentChars: 40, + }, + }) + ); + + expect(result.status).toBe('included'); + expect(result.metadata?.droppedMessageCount).toBe(1); + expect(result.warnings.map((warning) => warning.code)).toEqual( + expect.arrayContaining(['segment_message_window_limited', 'message_content_limited']) + ); + expect(result.segments[0]?.source).toMatchObject({ + provider: 'opencode_runtime', + messageCount: 2, + truncated: true, + }); + expect(buildBundleChunks).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ uuid: 'opencode-1' }), + expect.objectContaining({ uuid: 'opencode-2' }), + ]) + ); + expect(JSON.stringify(buildBundleChunks.mock.calls[0]?.[0])).toContain( + '[content truncated by member log stream budget]' + ); + }); + + it('joins active bridge calls, uses TTL cache, and lets forceRefresh bypass completed cache only', async () => { + const getOpenCodeTranscript = vi.fn().mockResolvedValue({ + sessionId: 'opencode-session', + logProjection: { + messages: [ + { + uuid: 'opencode-1', + parentUuid: null, + type: 'assistant', + timestamp: '2026-04-04T00:00:00.000Z', + role: 'assistant', + content: 'hello', + toolCalls: [], + toolResults: [], + isMeta: false, + sessionId: 'opencode-session', + }, + ], + }, + }); + const source = new OpenCodeMemberRuntimeStreamSource( + { getOpenCodeTranscript } as never, + { buildBundleChunks: vi.fn(() => [fakeChunk('opencode-chunk')]) } as never, + { resolve: vi.fn().mockResolvedValue('/mock/orchestrator') } + ); + const input = sourceInput({ laneId: 'secondary:opencode:alice' }); + + const [first, second] = await Promise.all([source.load(input), source.load(input)]); + + expect(first.status).toBe('included'); + expect(second.status).toBe('included'); + expect(getOpenCodeTranscript).toHaveBeenCalledTimes(1); + + await source.load(input); + expect(getOpenCodeTranscript).toHaveBeenCalledTimes(1); + + await source.load({ ...input, forceRefresh: true }); + expect(getOpenCodeTranscript).toHaveBeenCalledTimes(2); + expect(getOpenCodeTranscript).toHaveBeenLastCalledWith( + '/mock/orchestrator', + expect.objectContaining({ + teamId: 'alpha-team', + memberName: 'alice', + laneId: 'secondary:opencode:alice', + timeoutMs: DEFAULT_MEMBER_LOG_STREAM_BUDGET.openCodeTimeoutMs, + }) + ); + }); + + it('reports ambiguous OpenCode lane errors as skipped provider warnings', async () => { + const source = new OpenCodeMemberRuntimeStreamSource( + { + getOpenCodeTranscript: vi.fn().mockRejectedValue(new Error('multiple records, pass --lane')), + } as never, + { buildBundleChunks: vi.fn(() => [fakeChunk('opencode-chunk')]) } as never, + { resolve: vi.fn().mockResolvedValue('/mock/orchestrator') } + ); + + const result = await source.load(sourceInput()); + + expect(result).toMatchObject({ + provider: 'opencode_runtime', + status: 'skipped', + warnings: [ + { + code: 'opencode_ambiguous_lane', + message: 'OpenCode runtime session is ambiguous without a safe lane id.', + }, + ], + }); + }); +}); + +describe('CodexNativeMemberTraceStreamSource', () => { + it('returns an honest skipped warning for Codex members only', async () => { + const codexSource = new CodexNativeMemberTraceStreamSource({ + getConfig: vi.fn().mockResolvedValue({ + members: [{ name: 'alice', providerId: 'codex' }], + }), + } as never); + const nonCodexSource = new CodexNativeMemberTraceStreamSource({ + getConfig: vi.fn().mockResolvedValue({ + members: [{ name: 'alice', providerId: 'opencode' }], + }), + } as never); + + await expect(codexSource.load(sourceInput())).resolves.toMatchObject({ + status: 'skipped', + warnings: [{ code: 'codex_member_wide_not_supported' }], + }); + await expect(nonCodexSource.load(sourceInput())).resolves.toMatchObject({ + status: 'skipped', + warnings: [], + }); + }); +}); diff --git a/src/features/member-log-stream/main/adapters/output/sources/memberLogStreamSourceUtils.ts b/src/features/member-log-stream/main/adapters/output/sources/memberLogStreamSourceUtils.ts new file mode 100644 index 00000000..5dcbc046 --- /dev/null +++ b/src/features/member-log-stream/main/adapters/output/sources/memberLogStreamSourceUtils.ts @@ -0,0 +1,75 @@ +import { createHash } from 'crypto'; + +import type { + MemberLogStreamProvider, + MemberLogStreamSegmentSource, +} from '../../../../contracts'; +import type { + BoardTaskLogActor, + BoardTaskLogParticipant, + BoardTaskLogSegment, +} from '@shared/types'; + +export function normalizeMemberName(value: string): string { + return value.trim().toLowerCase(); +} + +export function normalizeTeamName(value: string): string { + return value.trim().toLowerCase(); +} + +export function buildMemberParticipant( + memberName: string, + role: 'member' | 'lead' = 'member' +): BoardTaskLogParticipant { + const isLead = role === 'lead'; + return { + key: `member:${normalizeMemberName(memberName)}`, + label: memberName, + role, + isLead, + isSidechain: !isLead, + }; +} + +export function buildMemberActor(input: { + memberName: string; + sessionId: string; + role?: 'member' | 'lead'; +}): BoardTaskLogActor { + const role = input.role ?? 'member'; + return { + memberName: input.memberName, + role, + sessionId: input.sessionId, + isSidechain: role !== 'lead', + }; +} + +export function shortHash(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 12); +} + +export function buildSegmentId(input: { + provider: MemberLogStreamProvider; + teamName: string; + memberName: string; + sessionId: string; + fingerprint: string; + startTimestamp: string; +}): string { + return [ + input.provider, + normalizeTeamName(input.teamName), + normalizeMemberName(input.memberName), + input.sessionId, + shortHash(`${input.fingerprint}:${input.startTimestamp}`), + ].join(':'); +} + +export function withSegmentSource( + segment: T, + source: MemberLogStreamSegmentSource +): T & { source: MemberLogStreamSegmentSource } { + return { ...segment, source }; +} diff --git a/src/features/member-log-stream/main/composition/createMemberLogStreamFeature.ts b/src/features/member-log-stream/main/composition/createMemberLogStreamFeature.ts new file mode 100644 index 00000000..7cbf3c3f --- /dev/null +++ b/src/features/member-log-stream/main/composition/createMemberLogStreamFeature.ts @@ -0,0 +1,75 @@ +import { BoardTaskExactLogChunkBuilder } from '@main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder'; +import { BoardTaskExactLogStrictParser } from '@main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser'; +import { TeamConfigReader } from '@main/services/team/TeamConfigReader'; + +import { createEmptyMemberLogStreamResponse } from '../../contracts'; +import { GetMemberLogStreamUseCase } from '../../core/application/use-cases/GetMemberLogStreamUseCase'; +import { SetMemberLogStreamTrackingUseCase } from '../../core/application/use-cases/SetMemberLogStreamTrackingUseCase'; +import { ClaudeMemberTranscriptStreamSource } from '../adapters/output/sources/ClaudeMemberTranscriptStreamSource'; +import { CodexNativeMemberTraceStreamSource } from '../adapters/output/sources/CodexNativeMemberTraceStreamSource'; +import { OpenCodeMemberRuntimeStreamSource } from '../adapters/output/sources/OpenCodeMemberRuntimeStreamSource'; +import { isMemberLogStreamReadEnabled } from '../featureGates'; + +import type { MemberLogStreamResponse } from '../../contracts'; +import type { LoggerPort } from '../../core/application/ports/LoggerPort'; +import type { MemberLogStreamTrackingPort } from '../../core/application/ports/MemberLogStreamTrackingPort'; +import type { GetMemberLogStreamInput } from '../../core/application/use-cases/GetMemberLogStreamUseCase'; +import type { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService'; +import type { TeamLogSourceTracker } from '@main/services/team/TeamLogSourceTracker'; +import type { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder'; + +export interface MemberLogStreamFeatureFacade { + getMemberLogStream(input: GetMemberLogStreamInput): Promise; + setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise; +} + +class TeamLogSourceTrackerMemberStreamPort implements MemberLogStreamTrackingPort { + constructor(private readonly tracker: TeamLogSourceTracker) {} + + async setTracking(teamName: string, enabled: boolean): Promise { + if (enabled) { + await this.tracker.enableTracking(teamName, 'member_log_stream'); + return; + } + await this.tracker.disableTracking(teamName, 'member_log_stream'); + } +} + +export function createMemberLogStreamFeature(deps: { + logsFinder: TeamMemberLogsFinder; + logSourceTracker: TeamLogSourceTracker; + runtimeBridge: ClaudeMultimodelBridgeService; + configReader?: TeamConfigReader; + logger: LoggerPort; +}): MemberLogStreamFeatureFacade { + const chunkBuilder = new BoardTaskExactLogChunkBuilder(); + const sources = [ + new ClaudeMemberTranscriptStreamSource( + deps.logsFinder, + new BoardTaskExactLogStrictParser(), + chunkBuilder, + deps.logger + ), + new OpenCodeMemberRuntimeStreamSource(deps.runtimeBridge, chunkBuilder), + new CodexNativeMemberTraceStreamSource(deps.configReader ?? new TeamConfigReader()), + ]; + const getUseCase = new GetMemberLogStreamUseCase({ + sources, + clock: { now: () => Date.now() }, + logger: deps.logger, + }); + const trackingUseCase = new SetMemberLogStreamTrackingUseCase( + new TeamLogSourceTrackerMemberStreamPort(deps.logSourceTracker) + ); + + return { + getMemberLogStream: async (input) => { + if (!isMemberLogStreamReadEnabled()) { + return createEmptyMemberLogStreamResponse(); + } + return getUseCase.execute(input); + }, + setMemberLogStreamTracking: (teamName, enabled) => + trackingUseCase.execute(teamName, enabled), + }; +} diff --git a/src/features/member-log-stream/main/featureGates.ts b/src/features/member-log-stream/main/featureGates.ts new file mode 100644 index 00000000..29221150 --- /dev/null +++ b/src/features/member-log-stream/main/featureGates.ts @@ -0,0 +1,18 @@ +function readEnabledFlag(value: string | undefined, defaultValue: boolean): boolean { + if (value == null) { + return defaultValue; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') { + return false; + } + if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') { + return true; + } + return defaultValue; +} + +export function isMemberLogStreamReadEnabled(): boolean { + return readEnabledFlag(process.env.CLAUDE_TEAM_MEMBER_LOG_STREAM_READ_ENABLED, true); +} diff --git a/src/features/member-log-stream/main/index.ts b/src/features/member-log-stream/main/index.ts new file mode 100644 index 00000000..6eb427c3 --- /dev/null +++ b/src/features/member-log-stream/main/index.ts @@ -0,0 +1,8 @@ +export { + registerMemberLogStreamIpc, + removeMemberLogStreamIpc, +} from './adapters/input/ipc/registerMemberLogStreamIpc'; +export { + createMemberLogStreamFeature, + type MemberLogStreamFeatureFacade, +} from './composition/createMemberLogStreamFeature'; diff --git a/src/features/member-log-stream/main/infrastructure/__tests__/memberLogMessageBudget.test.ts b/src/features/member-log-stream/main/infrastructure/__tests__/memberLogMessageBudget.test.ts new file mode 100644 index 00000000..98f8623c --- /dev/null +++ b/src/features/member-log-stream/main/infrastructure/__tests__/memberLogMessageBudget.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_MEMBER_LOG_STREAM_BUDGET } from '../../../core/domain/models/MemberLogStreamBudget'; +import { applyMemberLogMessageBudget } from '../memberLogMessageBudget'; + +import type { MemberLogStreamBudget } from '../../../core/domain/models/MemberLogStreamBudget'; +import type { ParsedMessage } from '@main/types'; + +function budget(overrides: Partial): MemberLogStreamBudget { + return { ...DEFAULT_MEMBER_LOG_STREAM_BUDGET, ...overrides }; +} + +function message(overrides: Partial): ParsedMessage { + return { + uuid: overrides.uuid ?? 'msg-1', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-04-01T00:00:00.000Z'), + content: '', + isSidechain: true, + isMeta: false, + toolCalls: [], + toolResults: [], + ...overrides, + }; +} + +describe('applyMemberLogMessageBudget', () => { + it('truncates oversized toolUseResult content, preserves ids, and reports content limiting', () => { + const result = applyMemberLogMessageBudget( + [ + message({ + type: 'user', + role: 'user', + isMeta: true, + sourceToolUseID: 'tool-1', + toolUseResult: { + toolUseId: 'tool-1', + content: 'x'.repeat(200), + stdout: 'y'.repeat(200), + }, + }), + ], + budget({ + maxToolResultContentChars: 80, + maxTotalContentChars: 120, + }) + ); + + const toolUseResult = result.messages[0]?.toolUseResult; + + expect(result.contentLimited).toBe(true); + expect(toolUseResult?.toolUseId).toBe('tool-1'); + expect(String(toolUseResult?.content)).toContain( + '[content truncated by member log stream budget]' + ); + expect(String(toolUseResult?.stdout)).toContain( + '[content truncated by member log stream budget]' + ); + }); + + it('drops orphan tool results after window trimming instead of rendering unpaired results', () => { + const result = applyMemberLogMessageBudget( + [ + message({ + uuid: 'assistant-1', + toolCalls: [{ id: 'tool-1', name: 'Bash', input: {}, isTask: false }], + }), + message({ + uuid: 'result-1', + type: 'user', + role: 'user', + isMeta: true, + sourceToolUseID: 'tool-1', + toolResults: [{ toolUseId: 'tool-1', content: 'done', isError: false }], + }), + ], + budget({ maxMessagesPerSegment: 1 }) + ); + + expect(result.segmentWindowLimited).toBe(true); + expect(result.messages).toEqual([]); + expect(result.droppedMessageCount).toBe(2); + }); + + it('keeps JSON-looking output visible when it does not exceed the content budget', () => { + const result = applyMemberLogMessageBudget( + [message({ content: '{"status":"ok","value":42}' })], + budget({ + maxMessageContentChars: 1_000, + maxTotalContentChars: 1_000, + }) + ); + + expect(result.contentLimited).toBe(false); + expect(result.messages[0]?.content).toBe('{"status":"ok","value":42}'); + }); +}); diff --git a/src/features/member-log-stream/main/infrastructure/memberLogMessageBudget.ts b/src/features/member-log-stream/main/infrastructure/memberLogMessageBudget.ts new file mode 100644 index 00000000..e20c6d5b --- /dev/null +++ b/src/features/member-log-stream/main/infrastructure/memberLogMessageBudget.ts @@ -0,0 +1,255 @@ +import type { MemberLogStreamBudget } from '../../core/domain/models/MemberLogStreamBudget'; +import type { ContentBlock, ParsedMessage, ToolResult, ToolUseResultData } from '@main/types'; + +export interface MessageBudgetResult { + messages: ParsedMessage[]; + droppedMessageCount: number; + segmentWindowLimited: boolean; + contentLimited: boolean; +} + +const CONTENT_LIMIT_SUFFIX = '\n\n[content truncated by member log stream budget]'; +const TOOL_RESULT_ID_KEYS = new Set([ + 'id', + 'toolUseId', + 'tool_use_id', + 'sourceToolUseID', + 'sourceToolAssistantUUID', + 'uuid', + 'parentUuid', +]); + +function truncateString(value: string, limit: number): { value: string; truncated: boolean } { + if (value.length <= limit) { + return { value, truncated: false }; + } + const allowed = Math.max(0, limit - CONTENT_LIMIT_SUFFIX.length); + return { value: `${value.slice(0, allowed)}${CONTENT_LIMIT_SUFFIX}`, truncated: true }; +} + +function buildAssistantToolUseIds(messages: readonly ParsedMessage[]): Set { + const ids = new Set(); + for (const message of messages) { + if (message.type !== 'assistant') { + continue; + } + for (const toolCall of message.toolCalls) { + ids.add(toolCall.id); + } + if (Array.isArray(message.content)) { + for (const block of message.content) { + if (block.type === 'tool_use') { + ids.add(block.id); + } + } + } + } + return ids; +} + +function dropOrphanToolResults(messages: readonly ParsedMessage[]): ParsedMessage[] { + const assistantToolUseIds = buildAssistantToolUseIds(messages); + return messages.filter((message) => { + if (!message.isMeta && message.toolResults.length === 0 && !message.sourceToolUseID) { + return true; + } + const toolUseIds = [ + message.sourceToolUseID, + ...message.toolResults.map((toolResult) => toolResult.toolUseId), + ].filter((value): value is string => typeof value === 'string' && value.length > 0); + if (toolUseIds.length === 0) { + return true; + } + return toolUseIds.some((toolUseId) => assistantToolUseIds.has(toolUseId)); + }); +} + +function trimMessageWindow( + messages: readonly ParsedMessage[], + maxMessages: number +): { messages: ParsedMessage[]; droppedMessageCount: number; limited: boolean } { + if (messages.length <= maxMessages) { + return { messages: [...messages], droppedMessageCount: 0, limited: false }; + } + const sliced = messages.slice(-maxMessages); + const paired = dropOrphanToolResults(sliced); + return { + messages: paired, + droppedMessageCount: messages.length - paired.length, + limited: true, + }; +} + +function truncateContentBlock( + block: ContentBlock, + budget: MemberLogStreamBudget, + total: { remaining: number } +): { block: ContentBlock; truncated: boolean } { + if (total.remaining <= 0) { + if (block.type === 'text') { + return { block: { ...block, text: CONTENT_LIMIT_SUFFIX.trim() }, truncated: true }; + } + if (block.type === 'thinking') { + return { block: { ...block, thinking: CONTENT_LIMIT_SUFFIX.trim() }, truncated: true }; + } + if (block.type === 'tool_result') { + return { block: { ...block, content: CONTENT_LIMIT_SUFFIX.trim() }, truncated: true }; + } + return { block, truncated: false }; + } + + if (block.type === 'text') { + const limit = Math.min(budget.maxMessageContentChars, total.remaining); + const truncated = truncateString(block.text, limit); + total.remaining -= truncated.value.length; + return { block: { ...block, text: truncated.value }, truncated: truncated.truncated }; + } + + if (block.type === 'thinking') { + const limit = Math.min(budget.maxMessageContentChars, total.remaining); + const truncated = truncateString(block.thinking, limit); + total.remaining -= truncated.value.length; + return { block: { ...block, thinking: truncated.value }, truncated: truncated.truncated }; + } + + if (block.type === 'tool_result') { + if (typeof block.content === 'string') { + const limit = Math.min(budget.maxToolResultContentChars, total.remaining); + const truncated = truncateString(block.content, limit); + total.remaining -= truncated.value.length; + return { block: { ...block, content: truncated.value }, truncated: truncated.truncated }; + } + const nested = block.content.map((item) => truncateContentBlock(item, budget, total)); + return { + block: { ...block, content: nested.map((item) => item.block) }, + truncated: nested.some((item) => item.truncated), + }; + } + + return { block, truncated: false }; +} + +function truncateToolResult( + toolResult: ToolResult, + budget: MemberLogStreamBudget, + total: { remaining: number } +): { toolResult: ToolResult; truncated: boolean } { + if (typeof toolResult.content !== 'string') { + return { toolResult, truncated: false }; + } + const limit = Math.min(budget.maxToolResultContentChars, Math.max(0, total.remaining)); + const truncated = truncateString(toolResult.content, limit); + total.remaining -= truncated.value.length; + return { + toolResult: { ...toolResult, content: truncated.value }, + truncated: truncated.truncated, + }; +} + +function truncateUnknownToolResultValue( + value: unknown, + budget: MemberLogStreamBudget, + total: { remaining: number }, + key?: string +): { value: unknown; truncated: boolean } { + if (typeof value === 'string') { + if (key && TOOL_RESULT_ID_KEYS.has(key)) { + return { value, truncated: false }; + } + const limit = Math.min(budget.maxToolResultContentChars, Math.max(0, total.remaining)); + const truncated = truncateString(value, limit); + total.remaining = Math.max(0, total.remaining - truncated.value.length); + return { value: truncated.value, truncated: truncated.truncated }; + } + + if (Array.isArray(value)) { + let truncated = false; + const mapped = value.map((item) => { + const result = truncateUnknownToolResultValue(item, budget, total); + truncated = truncated || result.truncated; + return result.value; + }); + return { value: mapped, truncated }; + } + + if (value && typeof value === 'object') { + let truncated = false; + const mapped: Record = {}; + for (const [childKey, childValue] of Object.entries(value)) { + const result = truncateUnknownToolResultValue(childValue, budget, total, childKey); + truncated = truncated || result.truncated; + mapped[childKey] = result.value; + } + return { value: mapped, truncated }; + } + + return { value, truncated: false }; +} + +function truncateToolUseResult( + toolUseResult: ToolUseResultData | undefined, + budget: MemberLogStreamBudget, + total: { remaining: number } +): { toolUseResult: ToolUseResultData | undefined; truncated: boolean } { + if (!toolUseResult) { + return { toolUseResult, truncated: false }; + } + const result = truncateUnknownToolResultValue(toolUseResult, budget, total); + return { + toolUseResult: result.value as ToolUseResultData, + truncated: result.truncated, + }; +} + +function truncateMessageContent( + message: ParsedMessage, + budget: MemberLogStreamBudget, + total: { remaining: number } +): { message: ParsedMessage; truncated: boolean } { + let truncated = false; + let content: ParsedMessage['content']; + if (typeof message.content === 'string') { + const limit = Math.min(budget.maxMessageContentChars, Math.max(0, total.remaining)); + const result = truncateString(message.content, limit); + total.remaining -= result.value.length; + truncated = result.truncated; + content = result.value; + } else { + const mapped = message.content.map((block) => truncateContentBlock(block, budget, total)); + truncated = mapped.some((item) => item.truncated); + content = mapped.map((item) => item.block); + } + + const toolResults = message.toolResults.map((toolResult) => + truncateToolResult(toolResult, budget, total) + ); + const toolUseResult = truncateToolUseResult(message.toolUseResult, budget, total); + + return { + message: { + ...message, + content, + toolResults: toolResults.map((item) => item.toolResult), + ...(toolUseResult.toolUseResult ? { toolUseResult: toolUseResult.toolUseResult } : {}), + }, + truncated: + truncated || + toolResults.some((item) => item.truncated) || + toolUseResult.truncated, + }; +} + +export function applyMemberLogMessageBudget( + messages: readonly ParsedMessage[], + budget: MemberLogStreamBudget +): MessageBudgetResult { + const windowed = trimMessageWindow(messages, budget.maxMessagesPerSegment); + const total = { remaining: budget.maxTotalContentChars }; + const truncated = windowed.messages.map((message) => truncateMessageContent(message, budget, total)); + return { + messages: truncated.map((item) => item.message), + droppedMessageCount: windowed.droppedMessageCount, + segmentWindowLimited: windowed.limited, + contentLimited: truncated.some((item) => item.truncated), + }; +} diff --git a/src/features/member-log-stream/preload/__tests__/createMemberLogStreamBridge.test.ts b/src/features/member-log-stream/preload/__tests__/createMemberLogStreamBridge.test.ts new file mode 100644 index 00000000..d6d955f1 --- /dev/null +++ b/src/features/member-log-stream/preload/__tests__/createMemberLogStreamBridge.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + MEMBER_LOG_STREAM_GET, + MEMBER_LOG_STREAM_SET_TRACKING, +} from '../../contracts'; +import { createMemberLogStreamBridge } from '../createMemberLogStreamBridge'; + +const mocks = vi.hoisted(() => ({ + ipcRenderer: { + invoke: vi.fn(), + }, +})); + +vi.mock('electron', () => ({ + ipcRenderer: mocks.ipcRenderer, +})); + +describe('createMemberLogStreamBridge', () => { + beforeEach(() => { + mocks.ipcRenderer.invoke.mockReset(); + }); + + it('forwards member log stream IPC requests and normalizes response payloads', async () => { + mocks.ipcRenderer.invoke.mockResolvedValueOnce({ + success: true, + data: { + participants: [], + segments: [], + generatedAt: '2026-04-02T00:00:00.000Z', + }, + }); + const bridge = createMemberLogStreamBridge(); + + const response = await bridge.getMemberLogStream('alpha-team', 'alice', { + limitSegments: 30, + laneId: 'secondary:opencode:alice', + forceRefresh: true, + }); + + expect(response).toMatchObject({ + participants: [], + segments: [], + source: 'member_empty', + generatedAt: '2026-04-02T00:00:00.000Z', + metadata: { + scannedTranscriptFileCount: 0, + includedTranscriptFileCount: 0, + droppedSegmentCount: 0, + droppedChunkCount: 0, + droppedMessageCount: 0, + }, + }); + expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith( + MEMBER_LOG_STREAM_GET, + 'alpha-team', + 'alice', + { + limitSegments: 30, + laneId: 'secondary:opencode:alice', + forceRefresh: true, + } + ); + }); + + it('forwards tracking calls and throws IPC errors', async () => { + mocks.ipcRenderer.invoke + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ success: false, error: 'bad lane' }); + const bridge = createMemberLogStreamBridge(); + + await expect(bridge.setMemberLogStreamTracking('alpha-team', true)).resolves.toBeUndefined(); + await expect(bridge.getMemberLogStream('alpha-team', 'alice')).rejects.toThrow('bad lane'); + + expect(mocks.ipcRenderer.invoke).toHaveBeenNthCalledWith( + 1, + MEMBER_LOG_STREAM_SET_TRACKING, + 'alpha-team', + true + ); + }); +}); diff --git a/src/features/member-log-stream/preload/createMemberLogStreamBridge.ts b/src/features/member-log-stream/preload/createMemberLogStreamBridge.ts new file mode 100644 index 00000000..fa971599 --- /dev/null +++ b/src/features/member-log-stream/preload/createMemberLogStreamBridge.ts @@ -0,0 +1,42 @@ +import { ipcRenderer } from 'electron'; + +import { + MEMBER_LOG_STREAM_GET, + MEMBER_LOG_STREAM_SET_TRACKING, + normalizeMemberLogStreamResponse, +} from '../contracts'; + +import type { + MemberLogStreamApi, + MemberLogStreamRequestOptions, + MemberLogStreamResponse, +} from '../contracts'; +import type { IpcResult } from '@shared/types'; + +async function invokeIpcWithResult(channel: string, ...args: unknown[]): Promise { + const result = (await ipcRenderer.invoke(channel, ...args)) as IpcResult; + if (!result.success) { + throw new Error(result.error ?? 'Unknown error'); + } + return result.data as T; +} + +export function createMemberLogStreamBridge(): MemberLogStreamApi { + return { + getMemberLogStream: async ( + teamName: string, + memberName: string, + options?: MemberLogStreamRequestOptions + ): Promise => + normalizeMemberLogStreamResponse( + await invokeIpcWithResult( + MEMBER_LOG_STREAM_GET, + teamName, + memberName, + options + ) + ), + setMemberLogStreamTracking: (teamName: string, enabled: boolean): Promise => + invokeIpcWithResult(MEMBER_LOG_STREAM_SET_TRACKING, teamName, enabled), + }; +} diff --git a/src/features/member-log-stream/preload/index.ts b/src/features/member-log-stream/preload/index.ts new file mode 100644 index 00000000..e5c78985 --- /dev/null +++ b/src/features/member-log-stream/preload/index.ts @@ -0,0 +1 @@ +export { createMemberLogStreamBridge } from './createMemberLogStreamBridge'; diff --git a/src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx b/src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx new file mode 100644 index 00000000..fe3aed25 --- /dev/null +++ b/src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx @@ -0,0 +1,78 @@ +import { useEffect, useMemo } from 'react'; + +import { useStore } from '@renderer/store'; +import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; + +import { useMemberLogStream } from '../hooks/useMemberLogStream'; +import { ExecutionLogStreamView } from '../ui/ExecutionLogStreamView'; + +import type { MemberLogStreamSegment } from '../../contracts'; +import type { ResolvedTeamMember } from '@shared/types'; + +interface MemberLogStreamSectionProps { + teamName: string; + member: ResolvedTeamMember; + enabled?: boolean; + onInitialLoadErrorChange?: (hasError: boolean) => void; +} + +function describeMemberStream(): string { + return 'Member-scoped transcript and runtime logs rendered with the same execution-log components used in Task Log Stream.'; +} + +function getSegmentMetaLabel(segment: MemberLogStreamSegment): string { + const details = [segment.source.label]; + if (segment.source.laneId) { + details.push(`lane ${segment.source.laneId}`); + } else if (segment.source.sessionId) { + details.push(`session ${segment.source.sessionId.slice(0, 8)}`); + } + return details.join(' · '); +} + +function buildMemberSegmentRenderKey(segment: MemberLogStreamSegment): string { + const firstChunkId = segment.chunks[0]?.id; + return `${segment.id}:${firstChunkId ?? segment.startTimestamp}`; +} + +export function MemberLogStreamSection({ + teamName, + member, + enabled = true, + onInitialLoadErrorChange, +}: Readonly): React.JSX.Element { + const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName)); + const { stream, loading, error } = useMemberLogStream({ teamName, member, enabled }); + const hasInitialLoadError = Boolean(error && !stream && !loading); + const boundedHistoryNote = useMemo(() => { + if (!stream) return null; + const isBounded = + stream.truncated || + stream.warnings.some((warning) => warning.code === 'large_log_window_limited'); + return isBounded ? 'Showing a bounded recent member log stream.' : null; + }, [stream]); + + useEffect(() => { + onInitialLoadErrorChange?.(hasInitialLoadError); + }, [hasInitialLoadError, onInitialLoadErrorChange]); + + return ( + + ); +} diff --git a/src/features/member-log-stream/renderer/hooks/__tests__/useMemberLogStream.test.tsx b/src/features/member-log-stream/renderer/hooks/__tests__/useMemberLogStream.test.tsx new file mode 100644 index 00000000..3d9fb6a1 --- /dev/null +++ b/src/features/member-log-stream/renderer/hooks/__tests__/useMemberLogStream.test.tsx @@ -0,0 +1,326 @@ +import React, { act, useEffect } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useMemberLogStream } from '../useMemberLogStream'; + +import type { MemberLogStreamResponse } from '../../../contracts'; +import type { ResolvedTeamMember } from '@shared/types'; + +const apiMock = vi.hoisted(() => ({ + memberLogStream: { + getMemberLogStream: vi.fn(), + setMemberLogStreamTracking: vi.fn(), + }, + teams: { + onTeamChange: vi.fn(), + }, +})); + +vi.mock('@renderer/api', () => ({ + api: apiMock, +})); + +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} + +function member(name: string): ResolvedTeamMember { + return { + name, + status: 'idle', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }; +} + +function response(generatedAt: string): MemberLogStreamResponse { + return { + participants: [], + defaultFilter: 'all', + segments: [], + source: 'member_empty', + coverage: [], + warnings: [], + truncated: false, + generatedAt, + metadata: { + scannedTranscriptFileCount: 0, + includedTranscriptFileCount: 0, + droppedSegmentCount: 0, + droppedChunkCount: 0, + droppedMessageCount: 0, + }, + }; +} + +const HookProbe = ({ + teamName, + selectedMember, + enabled = true, + onState, +}: { + teamName: string; + selectedMember: ResolvedTeamMember; + enabled?: boolean; + onState: (state: ReturnType) => void; +}): React.JSX.Element | null => { + const state = useMemberLogStream({ teamName, member: selectedMember, enabled }); + useEffect(() => { + onState(state); + }, [onState, state]); + return null; +}; + +describe('useMemberLogStream', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiMock.memberLogStream.getMemberLogStream.mockReset(); + apiMock.memberLogStream.setMemberLogStreamTracking.mockReset(); + apiMock.memberLogStream.setMemberLogStreamTracking.mockResolvedValue(undefined); + apiMock.teams.onTeamChange.mockReset(); + apiMock.teams.onTeamChange.mockReturnValue(() => undefined); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('does not let an older in-flight member request drive a pending reload after member key changes', async () => { + const aliceLoad = createDeferred(); + const bobLoad = createDeferred(); + apiMock.memberLogStream.getMemberLogStream + .mockReturnValueOnce(aliceLoad.promise) + .mockReturnValueOnce(bobLoad.promise); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onState = vi.fn((_: ReturnType) => undefined); + const latestState = (): ReturnType | undefined => + onState.mock.calls.at(-1)?.[0]; + + await act(async () => { + root.render( + + ); + await Promise.resolve(); + }); + + await act(async () => { + root.render( + + ); + await Promise.resolve(); + }); + + const requestedMembers = apiMock.memberLogStream.getMemberLogStream.mock.calls.map( + (call: unknown[]) => String(call[1]) + ); + expect(requestedMembers).toEqual(['alice', 'bob']); + + await act(async () => { + aliceLoad.resolve(response('2026-04-03T00:00:00.000Z')); + await Promise.resolve(); + }); + + expect(latestState()?.stream).toBeNull(); + + await act(async () => { + bobLoad.resolve(response('2026-04-03T00:01:00.000Z')); + await Promise.resolve(); + }); + + expect(latestState()?.stream?.generatedAt).toBe('2026-04-03T00:01:00.000Z'); + + act(() => { + root.unmount(); + }); + }); + + it('reloads on same-team log events with forceRefresh only for source changes', async () => { + vi.useFakeTimers(); + let teamChangeListener: + | ((event: unknown, data: { teamName: string; type: string }) => void) + | null = null; + apiMock.teams.onTeamChange.mockImplementation((callback) => { + teamChangeListener = callback as typeof teamChangeListener; + return () => undefined; + }); + apiMock.memberLogStream.getMemberLogStream + .mockResolvedValueOnce(response('2026-04-03T00:00:00.000Z')) + .mockResolvedValueOnce(response('2026-04-03T00:01:00.000Z')) + .mockResolvedValueOnce(response('2026-04-03T00:02:00.000Z')); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + undefined} + /> + ); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(1); + + await act(async () => { + teamChangeListener?.(null, { teamName: 'other-team', type: 'log-source-change' }); + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + await act(async () => { + teamChangeListener?.(null, { teamName: 'alpha-team', type: 'tool-activity' }); + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(1); + + await act(async () => { + teamChangeListener?.(null, { teamName: 'alpha-team', type: 'log-source-change' }); + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(2); + expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenLastCalledWith( + 'alpha-team', + 'alice', + expect.objectContaining({ forceRefresh: true }) + ); + + await act(async () => { + teamChangeListener?.(null, { teamName: 'alpha-team', type: 'task-log-change' }); + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(3); + expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenLastCalledWith( + 'alpha-team', + 'alice', + expect.not.objectContaining({ forceRefresh: true }) + ); + + act(() => { + root.unmount(); + }); + }); + + it('releases stale in-flight state when the section is disabled before a request finishes', async () => { + const firstLoad = createDeferred(); + apiMock.memberLogStream.getMemberLogStream + .mockReturnValueOnce(firstLoad.promise) + .mockResolvedValueOnce(response('2026-04-03T00:02:00.000Z')); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onState = vi.fn((_: ReturnType) => undefined); + const latestState = (): ReturnType | undefined => + onState.mock.calls.at(-1)?.[0]; + const selectedMember = member('alice'); + + await act(async () => { + root.render( + + ); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(1); + + await act(async () => { + root.render( + + ); + await Promise.resolve(); + }); + + await act(async () => { + firstLoad.resolve(response('2026-04-03T00:01:00.000Z')); + await Promise.resolve(); + }); + expect(latestState()?.stream).toBeNull(); + + await act(async () => { + root.render( + + ); + await Promise.resolve(); + }); + + expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(2); + expect(latestState()?.stream?.generatedAt).toBe('2026-04-03T00:02:00.000Z'); + + act(() => { + root.unmount(); + }); + }); + + it('passes an OpenCode lane only for OpenCode-owned members', async () => { + apiMock.memberLogStream.getMemberLogStream.mockResolvedValue( + response('2026-04-03T00:00:00.000Z') + ); + const staleLaneMember: ResolvedTeamMember = { + ...member('alice'), + providerId: 'anthropic', + laneId: 'secondary:opencode:alice', + laneOwnerProviderId: 'opencode', + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + undefined} + /> + ); + await Promise.resolve(); + }); + + const request = apiMock.memberLogStream.getMemberLogStream.mock.calls[0] as + | [string, string, { laneId?: unknown }] + | undefined; + expect(request?.[0]).toBe('alpha-team'); + expect(request?.[1]).toBe('alice'); + expect(request?.[2].laneId).toBeUndefined(); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/src/features/member-log-stream/renderer/hooks/useMemberLogStream.ts b/src/features/member-log-stream/renderer/hooks/useMemberLogStream.ts new file mode 100644 index 00000000..96adf5e6 --- /dev/null +++ b/src/features/member-log-stream/renderer/hooks/useMemberLogStream.ts @@ -0,0 +1,197 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { api } from '@renderer/api'; + +import { + type MemberLogStreamRequestOptions, + type MemberLogStreamResponse, + normalizeMemberLogStreamResponse, +} from '../../contracts'; +import { normalizeExecutionLogStream } from '../ui/ExecutionLogStreamView'; + +import type { ResolvedTeamMember } from '@shared/types'; + +const LIVE_RELOAD_DEBOUNCE_MS = 650; + +function getSafeOpenCodeLaneId(member: ResolvedTeamMember): string | undefined { + if (member.providerId !== 'opencode') return undefined; + if (member.laneOwnerProviderId !== 'opencode') return undefined; + const laneId = member.laneId?.trim(); + return laneId ? laneId : undefined; +} + +export function useMemberLogStream(input: { + teamName: string; + member: ResolvedTeamMember; + enabled?: boolean; +}): { + stream: MemberLogStreamResponse | null; + loading: boolean; + error: string | null; + reload: (options?: { forceRefresh?: boolean; background?: boolean }) => Promise; +} { + const enabled = input.enabled ?? true; + const [stream, setStream] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const streamRef = useRef(null); + const activeLoadKeyRef = useRef(null); + const pendingReloadRef = useRef<{ key: string; forceRefresh?: boolean } | null>(null); + const reloadTimerRef = useRef | null>(null); + const requestSeqRef = useRef(0); + const memberName = input.member.name; + const openCodeLaneId = getSafeOpenCodeLaneId(input.member); + const streamKey = `${input.teamName}:${memberName}:${openCodeLaneId ?? ''}`; + + useEffect(() => { + streamRef.current = stream; + }, [stream]); + + const loadStream = useCallback( + async (options?: { forceRefresh?: boolean; background?: boolean }): Promise => { + if (!enabled) return; + + if (activeLoadKeyRef.current === streamKey) { + const existingPending = pendingReloadRef.current; + pendingReloadRef.current = { + key: streamKey, + forceRefresh: + (existingPending?.key === streamKey && existingPending.forceRefresh) || + options?.forceRefresh, + }; + return; + } + + activeLoadKeyRef.current = streamKey; + const background = options?.background ?? false; + const hadExistingStream = streamRef.current != null; + const requestSeq = requestSeqRef.current + 1; + requestSeqRef.current = requestSeq; + + if (!background) setLoading(true); + setError((prev) => (background ? prev : null)); + + try { + const requestOptions: MemberLogStreamRequestOptions = { + limitSegments: 30, + ...(options?.forceRefresh ? { forceRefresh: true } : {}), + }; + if (openCodeLaneId) { + requestOptions.laneId = openCodeLaneId; + } + + const response = normalizeExecutionLogStream( + normalizeMemberLogStreamResponse( + await api.memberLogStream.getMemberLogStream(input.teamName, memberName, requestOptions) + ) + ); + if (requestSeqRef.current !== requestSeq) return; + + setStream(response); + setError(null); + } catch (loadError) { + if (requestSeqRef.current !== requestSeq) return; + if (!background || streamRef.current == null) { + setError( + loadError instanceof Error ? loadError.message : 'Failed to load member log stream' + ); + setStream(null); + } + } finally { + const isCurrentRequest = + requestSeqRef.current === requestSeq && activeLoadKeyRef.current === streamKey; + if (isCurrentRequest && (!background || !hadExistingStream)) { + setLoading(false); + } + if (isCurrentRequest) { + activeLoadKeyRef.current = null; + } + const pending = pendingReloadRef.current; + if (pending?.key === streamKey) { + pendingReloadRef.current = null; + } + if (isCurrentRequest && pending?.key === streamKey && enabled) { + void loadStream({ background: true, forceRefresh: pending.forceRefresh }); + } + } + }, + [enabled, input.teamName, memberName, openCodeLaneId, streamKey] + ); + + useEffect(() => { + requestSeqRef.current += 1; + setStream(null); + streamRef.current = null; + setError(null); + setLoading(enabled); + pendingReloadRef.current = null; + activeLoadKeyRef.current = null; + if (reloadTimerRef.current) { + clearTimeout(reloadTimerRef.current); + reloadTimerRef.current = null; + } + if (enabled) { + void loadStream(); + } + }, [enabled, streamKey, loadStream]); + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + void api.memberLogStream + .setMemberLogStreamTracking(input.teamName, true) + .catch(() => undefined); + return () => { + if (cancelled) return; + cancelled = true; + void api.memberLogStream + .setMemberLogStreamTracking(input.teamName, false) + .catch(() => undefined); + }; + }, [enabled, input.teamName]); + + useEffect(() => { + if (!enabled) return; + + const scheduleReload = (forceRefresh: boolean): void => { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; + if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current); + reloadTimerRef.current = setTimeout(() => { + reloadTimerRef.current = null; + void loadStream({ background: true, forceRefresh }); + }, LIVE_RELOAD_DEBOUNCE_MS); + }; + + const unsubscribe = api.teams.onTeamChange?.((_event, event) => { + if (event.teamName !== input.teamName) return; + if (event.type === 'log-source-change') { + scheduleReload(true); + return; + } + if (event.type === 'task-log-change') { + scheduleReload(false); + } + }); + + const handleVisibilityChange = (): void => { + if (document.visibilityState === 'visible') scheduleReload(false); + }; + + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', handleVisibilityChange); + } + + return () => { + if (reloadTimerRef.current) { + clearTimeout(reloadTimerRef.current); + reloadTimerRef.current = null; + } + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', handleVisibilityChange); + } + if (typeof unsubscribe === 'function') unsubscribe(); + }; + }, [enabled, input.teamName, loadStream]); + + return { stream, loading, error, reload: loadStream }; +} diff --git a/src/features/member-log-stream/renderer/index.ts b/src/features/member-log-stream/renderer/index.ts new file mode 100644 index 00000000..c9cc9831 --- /dev/null +++ b/src/features/member-log-stream/renderer/index.ts @@ -0,0 +1,7 @@ +export { MemberLogStreamSection } from './adapters/MemberLogStreamSection'; +export { + buildDefaultExecutionSegmentRenderKey, + ExecutionLogStreamView, + normalizeExecutionLogStream, +} from './ui/ExecutionLogStreamView'; +export { isMemberLogStreamUiEnabled } from './utils/featureGates'; diff --git a/src/features/member-log-stream/renderer/ui/ExecutionLogStreamView.tsx b/src/features/member-log-stream/renderer/ui/ExecutionLogStreamView.tsx new file mode 100644 index 00000000..217156a1 --- /dev/null +++ b/src/features/member-log-stream/renderer/ui/ExecutionLogStreamView.tsx @@ -0,0 +1,369 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { MemberBadge } from '@renderer/components/team/MemberBadge'; +import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; +import { + getTeamColorSet, + getThemedBadge, + getThemedBorder, + getThemedText, +} from '@renderer/constants/teamColors'; +import { useTheme } from '@renderer/hooks/useTheme'; +import { asEnhancedChunkArray } from '@renderer/types/data'; +import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { isLeadMember } from '@shared/utils/leadDetection'; +import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react'; + +import type { + BoardTaskLogActor, + BoardTaskLogParticipant, + BoardTaskLogSegment, + ResolvedTeamMember, +} from '@shared/types'; + +interface ExecutionLogStreamLike { + participants: BoardTaskLogParticipant[]; + defaultFilter: string; + segments: BoardTaskLogSegment[]; +} + +interface ParticipantVisual { + name: string; + color?: string; +} + +export interface ExecutionLogStreamViewProps { + title: string; + description: string; + stream: TStream | null; + loading: boolean; + error: string | null; + teamName: string; + teamMembers: readonly ResolvedTeamMember[]; + loadingText: string; + emptyTitle: string; + emptyDescription: string; + selectionResetKey: string; + boundedHistoryNote?: string | null; + forceSegmentHeaders?: boolean; + buildSegmentRenderKey?: (segment: TStream['segments'][number]) => string; + getSegmentMetaLabel?: (segment: TStream['segments'][number]) => string | null; +} + +function formatRelativeTime(isoString: string): string { + const date = new Date(isoString); + const diffMs = Date.now() - date.getTime(); + const diffMin = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMin / 60); + const diffDays = Math.floor(diffHours / 24); + + if (!Number.isFinite(diffMs)) return '--'; + if (diffMin < 1) return 'just now'; + if (diffMin < 60) return `${diffMin}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + return `${diffDays}d ago`; +} + +function actorLabel(actor: BoardTaskLogActor): string { + if (actor.memberName) return actor.memberName; + if (actor.role === 'lead' || actor.isSidechain === false) return 'lead session'; + if (actor.agentId) return `member ${actor.agentId.slice(0, 8)}`; + return `member session ${actor.sessionId.slice(0, 8)}`; +} + +export function normalizeExecutionLogStream( + response: TStream +): TStream { + return { + ...response, + segments: response.segments.map((segment) => ({ + ...segment, + chunks: asEnhancedChunkArray(segment.chunks) ?? [], + })), + }; +} + +export function buildDefaultExecutionSegmentRenderKey(segment: BoardTaskLogSegment): string { + const firstChunkId = segment.chunks[0]?.id; + if (firstChunkId) { + return `${segment.participantKey}:${firstChunkId}`; + } + return `${segment.participantKey}:${segment.startTimestamp}`; +} + +function buildParticipantVisualMap( + stream: ExecutionLogStreamLike | null, + members: readonly ResolvedTeamMember[], + memberColorMap: ReadonlyMap +): Map { + const visuals = new Map(); + const leadMember = members.find((member) => isLeadMember(member)); + + for (const participant of stream?.participants ?? []) { + const matchingSegment = stream?.segments.find( + (segment) => segment.participantKey === participant.key + ); + const name = + matchingSegment?.actor.memberName ?? + (participant.isLead ? leadMember?.name : undefined) ?? + participant.label; + + visuals.set(participant.key, { + name, + color: memberColorMap.get(name) ?? memberColorMap.get(participant.label), + }); + } + + for (const segment of stream?.segments ?? []) { + if (visuals.has(segment.participantKey)) continue; + const name = segment.actor.memberName ?? actorLabel(segment.actor); + visuals.set(segment.participantKey, { name, color: memberColorMap.get(name) }); + } + + return visuals; +} + +const SegmentMarker = ({ + segment, + visual, + teamName, + metaLabel, +}: { + segment: TSegment; + visual?: ParticipantVisual; + teamName: string; + metaLabel?: string | null; +}): React.JSX.Element => ( +
+ {visual ? ( + + ) : null} + {metaLabel ? {metaLabel} : null} + + + {formatRelativeTime(segment.endTimestamp)} + +
+); + +const SegmentBlock = ({ + segment, + showHeader, + teamName, + visual, + metaLabel, +}: { + segment: TSegment; + showHeader: boolean; + teamName: string; + visual?: ParticipantVisual; + metaLabel?: string | null; +}): React.JSX.Element => ( +
+ {showHeader ? ( + + ) : null} + +
+); + +const ParticipantFilterChip = ({ + label, + selected, + visual, + teamName, + onClick, +}: { + label: string; + selected: boolean; + visual?: ParticipantVisual; + teamName: string; + onClick: () => void; +}): React.JSX.Element => { + const { isLight } = useTheme(); + const colors = getTeamColorSet(visual?.color ?? ''); + const borderColor = selected ? getThemedBorder(colors, isLight) : 'var(--color-border)'; + const backgroundColor = selected ? getThemedBadge(colors, isLight) : 'transparent'; + const textColor = selected ? getThemedText(colors, isLight) : 'var(--color-text-muted)'; + + return ( + + ); +}; + +export function ExecutionLogStreamView({ + title, + description, + stream, + loading, + error, + teamName, + teamMembers, + loadingText, + emptyTitle, + emptyDescription, + selectionResetKey, + boundedHistoryNote, + forceSegmentHeaders = false, + buildSegmentRenderKey, + getSegmentMetaLabel, +}: Readonly>): React.JSX.Element { + const [selectedParticipantKey, setSelectedParticipantKey] = useState('all'); + const participants = stream?.participants ?? []; + const memberColorMap = useMemo(() => buildMemberColorMap([...teamMembers]), [teamMembers]); + const participantVisuals = useMemo( + () => buildParticipantVisualMap(stream, teamMembers, memberColorMap), + [memberColorMap, stream, teamMembers] + ); + + useEffect(() => { + if (!stream) { + setSelectedParticipantKey('all'); + return; + } + setSelectedParticipantKey(stream.defaultFilter); + }, [selectionResetKey, stream]); + + useEffect(() => { + if (!stream) return; + const availableParticipantKeys = new Set([ + 'all', + ...stream.participants.map((participant) => participant.key), + ]); + setSelectedParticipantKey((prev) => + availableParticipantKeys.has(prev) ? prev : stream.defaultFilter + ); + }, [stream]); + + const showChips = participants.length > 1; + const visibleSegments = useMemo(() => { + const source = stream?.segments ?? []; + const filtered = + selectedParticipantKey === 'all' + ? source + : source.filter((segment) => segment.participantKey === selectedParticipantKey); + return [...filtered].reverse(); + }, [selectedParticipantKey, stream?.segments]); + + const showSegmentHeaders = + forceSegmentHeaders || + participants.length > 1 || + (selectedParticipantKey !== 'all' && visibleSegments.length > 1); + const renderKey = buildSegmentRenderKey ?? buildDefaultExecutionSegmentRenderKey; + + if (loading) { + return ( +
+

+ {title} +

+
+ + {loadingText} +
+
+ ); + } + + if (error) { + return ( +
+

+ {title} +

+
+ + {error} +
+
+ ); + } + + return ( +
+

+ {title} +

+

{description}

+ {boundedHistoryNote ? ( +

{boundedHistoryNote}

+ ) : null} + + {showChips ? ( +
+ + {participants.map((participant) => ( + setSelectedParticipantKey(participant.key)} + /> + ))} +
+ ) : null} + + {visibleSegments.length === 0 ? ( +
+ + {emptyTitle} +

{emptyDescription}

+
+ ) : ( +
+ {visibleSegments.map((segment) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/features/member-log-stream/renderer/utils/featureGates.ts b/src/features/member-log-stream/renderer/utils/featureGates.ts new file mode 100644 index 00000000..d584844f --- /dev/null +++ b/src/features/member-log-stream/renderer/utils/featureGates.ts @@ -0,0 +1,18 @@ +function readEnabledFlag(value: unknown, defaultValue: boolean): boolean { + if (typeof value !== 'string') { + return defaultValue; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') { + return false; + } + if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') { + return true; + } + return defaultValue; +} + +export function isMemberLogStreamUiEnabled(): boolean { + return readEnabledFlag(import.meta.env.VITE_MEMBER_LOG_STREAM_UI_ENABLED, true); +} diff --git a/src/main/index.ts b/src/main/index.ts index d6722651..37e66234 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -30,6 +30,11 @@ import { type CodexModelCatalogFeatureFacade, createCodexModelCatalogFeature, } from '@features/codex-model-catalog/main'; +import { + createMemberLogStreamFeature, + registerMemberLogStreamIpc, + removeMemberLogStreamIpc, +} from '@features/member-log-stream/main'; import { buildMemberWorkSyncRuntimeTurnSettledEnvironment, createMemberWorkSyncFeature, @@ -49,6 +54,7 @@ import { removeRuntimeProviderManagementIpc, type RuntimeProviderManagementFeatureFacade, } from '@features/runtime-provider-management/main'; +import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService'; import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy'; import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService'; import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository'; @@ -1138,6 +1144,13 @@ async function initializeServices(): Promise { undefined, teamTranscriptSourceLocator ); + const memberLogStreamFeature = createMemberLogStreamFeature({ + logsFinder: teamMemberLogsFinder, + logSourceTracker: teamLogSourceTracker, + runtimeBridge: new ClaudeMultimodelBridgeService(), + configReader: taskLogConfigReader, + logger: createLogger('Feature:MemberLogStream'), + }); const teamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService( teamMemberLogsFinder ); @@ -1483,6 +1496,7 @@ async function initializeServices(): Promise { registerRecentProjectsIpc(ipcMain, recentProjectsFeature); registerRuntimeProviderManagementIpc(ipcMain, runtimeProviderManagementFeature); registerMemberWorkSyncIpc(ipcMain, memberWorkSyncFeature); + registerMemberLogStreamIpc(ipcMain, memberLogStreamFeature); // Forward SSH state changes to renderer and HTTP SSE clients sshConnectionManager.on('state-change', (status: unknown) => { @@ -1672,6 +1686,7 @@ async function shutdownServices(): Promise { removeRecentProjectsIpc(ipcMain); removeRuntimeProviderManagementIpc(ipcMain); removeMemberWorkSyncIpc(ipcMain); + removeMemberLogStreamIpc(ipcMain); }); await runShutdownStep('team backup dispose', () => teamBackupService?.dispose()); diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 44b7ca31..ceef49d8 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -925,6 +925,8 @@ export class ClaudeMultimodelBridgeService { teamId: string; memberName: string; limit?: number; + laneId?: string; + timeoutMs?: number; } ): Promise { const { env } = await this.buildCliEnv(binaryPath); @@ -943,12 +945,15 @@ export class ClaudeMultimodelBridgeService { if (typeof params.limit === 'number') { args.push('--limit', String(params.limit)); } + if (typeof params.laneId === 'string' && params.laneId.trim().length > 0) { + args.push('--lane', params.laneId.trim()); + } const outputDir = await mkdtemp(path.join(tmpdir(), 'opencode-transcript-')); const outputPath = path.join(outputDir, 'transcript.json'); try { await execCli(binaryPath, [...args, '--output', outputPath], { - timeout: PROVIDER_STATUS_TIMEOUT_MS, + timeout: params.timeoutMs ?? PROVIDER_STATUS_TIMEOUT_MS, env, }); const parsed = extractJsonObject( diff --git a/src/main/services/team/TeamLogSourceTracker.ts b/src/main/services/team/TeamLogSourceTracker.ts index 6435907f..48de3c74 100644 --- a/src/main/services/team/TeamLogSourceTracker.ts +++ b/src/main/services/team/TeamLogSourceTracker.ts @@ -39,6 +39,7 @@ export type TeamLogSourceTrackingConsumer = | 'change_presence' | 'tool_activity' | 'task_log_stream' + | 'member_log_stream' | 'stall_monitor'; interface TrackingState { diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 3e1828b9..ccb6eb16 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -111,8 +111,19 @@ export interface MemberLogFileRef { sessionId: string; filePath: string; mtimeMs: number; + sizeBytes?: number; + messageCount?: number; + kind?: 'lead_session' | 'member_session' | 'subagent'; } +type FindRecentMemberLogFileRefsOptions = + | number + | null + | { + mtimeSinceMs?: number | null; + forceRefresh?: boolean; + }; + export interface TeamLogSourceLiveContext { projectDir: string; projectPath?: string; @@ -935,8 +946,15 @@ export class TeamMemberLogsFinder { async findRecentMemberLogFileRefsByMember( teamName: string, memberNames: readonly string[], - mtimeSinceMs?: number | null + options?: FindRecentMemberLogFileRefsOptions ): Promise { + const parsedOptions = + typeof options === 'number' || options === null + ? { mtimeSinceMs: options ?? null, forceRefresh: false } + : { + mtimeSinceMs: options?.mtimeSinceMs ?? null, + forceRefresh: options?.forceRefresh === true, + }; const requestedMembersByKey = new Map(); for (const memberName of memberNames) { const trimmed = memberName.trim(); @@ -952,12 +970,18 @@ export class TeamMemberLogsFinder { return []; } - const discovery = await this.discoverProjectSessions(teamName); + const discovery = await this.discoverProjectSessions(teamName, { + forceRefresh: parsedOptions.forceRefresh, + }); if (!discovery) { return []; } const { projectDir, sessionIds, knownMembers, config } = discovery; + const scopedKnownMembers = new Set(knownMembers); + for (const memberKey of requestedMembersByKey.keys()) { + scopedKnownMembers.add(memberKey); + } const refs: MemberLogFileRef[] = []; const seenFilePaths = new Set(); const pushRef = (ref: MemberLogFileRef): void => { @@ -975,12 +999,17 @@ export class TeamMemberLogsFinder { const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`); try { const stat = await fs.stat(leadJsonl); - if (stat.isFile()) { + if ( + stat.isFile() && + (parsedOptions.mtimeSinceMs == null || stat.mtimeMs >= parsedOptions.mtimeSinceMs) + ) { pushRef({ memberName: requestedMembersByKey.get(leadKey) ?? leadMemberName, sessionId: config.leadSessionId, filePath: leadJsonl, mtimeMs: stat.mtimeMs, + sizeBytes: stat.size, + kind: 'lead_session', }); } } catch { @@ -995,20 +1024,20 @@ export class TeamMemberLogsFinder { if (!stat.isFile()) { return null; } - if (mtimeSinceMs != null && stat.mtimeMs < mtimeSinceMs) { + if (parsedOptions.mtimeSinceMs != null && stat.mtimeMs < parsedOptions.mtimeSinceMs) { return null; } const attribution = candidate.kind === 'subagent' ? await this.getCachedSubagentAttribution( candidate.filePath, - knownMembers, + scopedKnownMembers, stat.mtimeMs ) : await this.getCachedMemberSessionAttribution( candidate.filePath, teamName, - knownMembers, + scopedKnownMembers, stat.mtimeMs ); if (!attribution) { @@ -1024,6 +1053,8 @@ export class TeamMemberLogsFinder { sessionId: candidate.sessionId, filePath: candidate.filePath, mtimeMs: stat.mtimeMs, + sizeBytes: stat.size, + kind: candidate.kind, } satisfies MemberLogFileRef; } catch { return null; diff --git a/src/main/services/team/taskLogs/stream/OpenCodeRuntimeProjectionMapper.ts b/src/main/services/team/taskLogs/stream/OpenCodeRuntimeProjectionMapper.ts new file mode 100644 index 00000000..0bf0f8f8 --- /dev/null +++ b/src/main/services/team/taskLogs/stream/OpenCodeRuntimeProjectionMapper.ts @@ -0,0 +1,126 @@ +import { sanitizeDisplayContent } from '@shared/utils/contentSanitizer'; + +import type { + OpenCodeRuntimeTranscriptLogContentBlock, + OpenCodeRuntimeTranscriptLogMessage, +} from '../../../runtime/ClaudeMultimodelBridgeService'; +import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types'; + +function mapOpenCodeContentBlock( + block: OpenCodeRuntimeTranscriptLogContentBlock +): ContentBlock | null { + switch (block.type) { + case 'text': { + const text = sanitizeDisplayContent(block.text); + return text.length > 0 ? { type: 'text', text } : null; + } + case 'thinking': + return { + type: 'thinking', + thinking: block.thinking, + signature: block.signature, + }; + case 'tool_use': + return { + type: 'tool_use', + id: block.id, + name: block.name, + input: block.input, + }; + case 'tool_result': + return { + type: 'tool_result', + tool_use_id: block.tool_use_id, + content: Array.isArray(block.content) + ? block.content + .map(mapOpenCodeContentBlock) + .filter((item): item is ContentBlock => item !== null) + : block.content, + ...(block.is_error ? { is_error: true } : {}), + }; + default: + return null; + } +} + +function buildToolUseResultData( + message: OpenCodeRuntimeTranscriptLogMessage +): ToolUseResultData | undefined { + if (!message.sourceToolUseID || message.toolResults.length !== 1) { + return undefined; + } + + const toolResult = message.toolResults[0]; + if (!toolResult) { + return undefined; + } + + return { + toolUseId: toolResult.toolUseId, + content: toolResult.content, + isError: toolResult.isError, + }; +} + +export function mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage( + message: OpenCodeRuntimeTranscriptLogMessage +): ParsedMessage | null { + const timestamp = new Date(message.timestamp); + if (Number.isNaN(timestamp.getTime())) { + return null; + } + + const normalizedContent: ContentBlock[] | string = + typeof message.content === 'string' + ? sanitizeDisplayContent(message.content) + : message.content + .map(mapOpenCodeContentBlock) + .filter((item): item is ContentBlock => item !== null); + + const toolCalls = message.toolCalls.map((toolCall) => ({ + id: toolCall.id, + name: toolCall.name, + input: toolCall.input, + isTask: toolCall.isTask, + ...(toolCall.taskDescription ? { taskDescription: toolCall.taskDescription } : {}), + ...(toolCall.taskSubagentType ? { taskSubagentType: toolCall.taskSubagentType } : {}), + })); + + const toolResults = message.toolResults.map((toolResult) => ({ + toolUseId: toolResult.toolUseId, + content: toolResult.content, + isError: toolResult.isError, + })); + const toolUseResult = buildToolUseResultData(message); + + return { + uuid: message.uuid, + parentUuid: message.parentUuid, + type: message.type, + timestamp, + role: message.role, + content: normalizedContent, + model: message.model, + agentName: message.agentName, + isSidechain: true, + isMeta: message.isMeta, + sessionId: message.sessionId, + toolCalls, + toolResults, + ...(message.sourceToolUseID ? { sourceToolUseID: message.sourceToolUseID } : {}), + ...(message.sourceToolAssistantUUID + ? { sourceToolAssistantUUID: message.sourceToolAssistantUUID } + : {}), + ...(toolUseResult ? { toolUseResult } : {}), + ...(message.subtype ? { subtype: message.subtype } : {}), + ...(message.level ? { level: message.level } : {}), + }; +} + +export function mapOpenCodeRuntimeTranscriptMessagesToParsedMessages( + messages: readonly OpenCodeRuntimeTranscriptLogMessage[] +): ParsedMessage[] { + return messages + .map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage) + .filter((message): message is ParsedMessage => message !== null); +} diff --git a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts index bea26f9b..093f9f2e 100644 --- a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts +++ b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts @@ -1,4 +1,3 @@ -import { sanitizeDisplayContent } from '@shared/utils/contentSanitizer'; import { createLogger } from '@shared/utils/logger'; import { ClaudeMultimodelBridgeService } from '../../../runtime/ClaudeMultimodelBridgeService'; @@ -7,17 +6,15 @@ import { ClaudeBinaryResolver } from '../../ClaudeBinaryResolver'; import { TeamTaskReader } from '../../TeamTaskReader'; import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder'; +import { mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage } from './OpenCodeRuntimeProjectionMapper'; import { OpenCodeTaskLogAttributionStore } from './OpenCodeTaskLogAttributionStore'; -import type { - OpenCodeRuntimeTranscriptLogContentBlock, - OpenCodeRuntimeTranscriptLogMessage, -} from '../../../runtime/ClaudeMultimodelBridgeService'; +import type { OpenCodeRuntimeTranscriptLogMessage } from '../../../runtime/ClaudeMultimodelBridgeService'; import type { OpenCodeTaskLogAttributionReader, OpenCodeTaskLogAttributionRecord, } from './OpenCodeTaskLogAttributionStore'; -import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types'; +import type { ParsedMessage } from '@main/types'; import type { BoardTaskLogActor, BoardTaskLogParticipant, @@ -431,7 +428,7 @@ function hasForeignTeamTaskMarker( } return projectedMessages - .map(toParsedMessage) + .map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage) .filter((message): message is ParsedMessage => message !== null) .some((message) => message.toolCalls.some((toolCall) => { @@ -758,7 +755,7 @@ function buildTaskMarkerProjection( ): TaskMarkerProjection | null { const parsedMessages = sortParsedMessagesByTime( projectedMessages - .map(toParsedMessage) + .map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage) .filter((message): message is ParsedMessage => message !== null) ); const taskRefs = buildTaskRefSet(task); @@ -919,7 +916,7 @@ function filterMessagesForAttribution( record: OpenCodeTaskLogAttributionRecord ): ParsedMessage[] { const parsedMessages = messages - .map(toParsedMessage) + .map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage) .filter((message): message is ParsedMessage => message !== null); const hasMessageBounds = Boolean(record.startMessageUuid || record.endMessageUuid); @@ -936,115 +933,6 @@ function filterMessagesForAttribution( .sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime()); } -function mapOpenCodeContentBlock( - block: OpenCodeRuntimeTranscriptLogContentBlock -): ContentBlock | null { - switch (block.type) { - case 'text': { - const text = sanitizeDisplayContent(block.text); - return text.length > 0 ? { type: 'text', text } : null; - } - case 'thinking': - return { - type: 'thinking', - thinking: block.thinking, - signature: block.signature, - }; - case 'tool_use': - return { - type: 'tool_use', - id: block.id, - name: block.name, - input: block.input, - }; - case 'tool_result': - return { - type: 'tool_result', - tool_use_id: block.tool_use_id, - content: Array.isArray(block.content) - ? block.content - .map(mapOpenCodeContentBlock) - .filter((item): item is ContentBlock => item !== null) - : block.content, - ...(block.is_error ? { is_error: true } : {}), - }; - default: - return null; - } -} - -function buildToolUseResultData( - message: OpenCodeRuntimeTranscriptLogMessage -): ToolUseResultData | undefined { - if (!message.sourceToolUseID || message.toolResults.length !== 1) { - return undefined; - } - - const toolResult = message.toolResults[0]; - if (!toolResult) { - return undefined; - } - - return { - toolUseId: toolResult.toolUseId, - content: toolResult.content, - isError: toolResult.isError, - }; -} - -function toParsedMessage(message: OpenCodeRuntimeTranscriptLogMessage): ParsedMessage | null { - const timestamp = new Date(message.timestamp); - if (Number.isNaN(timestamp.getTime())) { - return null; - } - - const normalizedContent: ContentBlock[] | string = - typeof message.content === 'string' - ? sanitizeDisplayContent(message.content) - : message.content - .map(mapOpenCodeContentBlock) - .filter((item): item is ContentBlock => item !== null); - - const toolCalls = message.toolCalls.map((toolCall) => ({ - id: toolCall.id, - name: toolCall.name, - input: toolCall.input, - isTask: toolCall.isTask, - ...(toolCall.taskDescription ? { taskDescription: toolCall.taskDescription } : {}), - ...(toolCall.taskSubagentType ? { taskSubagentType: toolCall.taskSubagentType } : {}), - })); - - const toolResults = message.toolResults.map((toolResult) => ({ - toolUseId: toolResult.toolUseId, - content: toolResult.content, - isError: toolResult.isError, - })); - const toolUseResult = buildToolUseResultData(message); - - return { - uuid: message.uuid, - parentUuid: message.parentUuid, - type: message.type, - timestamp, - role: message.role, - content: normalizedContent, - model: message.model, - agentName: message.agentName, - isSidechain: true, - isMeta: message.isMeta, - sessionId: message.sessionId, - toolCalls, - toolResults, - ...(message.sourceToolUseID ? { sourceToolUseID: message.sourceToolUseID } : {}), - ...(message.sourceToolAssistantUUID - ? { sourceToolAssistantUUID: message.sourceToolAssistantUUID } - : {}), - ...(toolUseResult ? { toolUseResult } : {}), - ...(message.subtype ? { subtype: message.subtype } : {}), - ...(message.level ? { level: message.level } : {}), - }; -} - export class OpenCodeTaskLogStreamSource { private readonly cache = new Map< string, @@ -1187,7 +1075,7 @@ export class OpenCodeTaskLogStreamSource { const filteredMessages = markerProjection?.messages ?? projectedMessages - .map(toParsedMessage) + .map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage) .filter((message): message is ParsedMessage => message !== null) .filter((message) => isWithinTimeWindows(message.timestamp, timeWindows)) .sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime()); diff --git a/src/preload/index.ts b/src/preload/index.ts index 162a9dd4..3880e5d6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,4 +1,5 @@ import { createCodexAccountBridge } from '@features/codex-account/preload'; +import { createMemberLogStreamBridge } from '@features/member-log-stream/preload'; import { createMemberWorkSyncBridge } from '@features/member-work-sync/preload'; import { createRecentProjectsBridge } from '@features/recent-projects/preload'; import { createRuntimeProviderManagementBridge } from '@features/runtime-provider-management/preload'; @@ -478,6 +479,7 @@ const electronAPI: ElectronAPI = { ...createRecentProjectsBridge(), runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer), memberWorkSync: createMemberWorkSyncBridge(ipcRenderer), + memberLogStream: createMemberLogStreamBridge(), getAppVersion: () => ipcRenderer.invoke('get-app-version'), getProjects: () => ipcRenderer.invoke('get-projects'), getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId), diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 52722276..f219c606 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -6,7 +6,10 @@ * to run in a regular browser connected to an HTTP server. */ +import { createEmptyMemberLogStreamResponse } from '@features/member-log-stream/contracts'; + import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; +import type { MemberLogStreamApi } from '@features/member-log-stream/contracts'; import type { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts'; import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts'; import type { @@ -251,6 +254,16 @@ export class HttpAPIClient implements ElectronAPI { getDashboardRecentProjects = (): Promise => this.get('/api/dashboard/recent-projects'); + memberLogStream: MemberLogStreamApi = { + getMemberLogStream: async () => { + console.warn('[HttpAPIClient] getMemberLogStream is not available in browser mode'); + return createEmptyMemberLogStreamResponse(); + }, + setMemberLogStreamTracking: async () => { + // Not available in browser mode - no-op. + }, + }; + getProjects = (): Promise => this.get('/api/projects'); getSessions = (projectId: string): Promise => diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 92458c37..511f0774 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -1,5 +1,9 @@ import { useEffect, useMemo, useState } from 'react'; +import { + isMemberLogStreamUiEnabled, + MemberLogStreamSection, +} from '@features/member-log-stream/renderer'; // import { MemberWorkSyncStatusPanel } from '@features/member-work-sync/renderer'; import { Button } from '@renderer/components/ui/button'; import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog'; @@ -167,6 +171,7 @@ export const MemberDetailDialog = ({ const [activeTab, setActiveTab] = useState(initialTab); const [restarting, setRestarting] = useState(false); const [restartError, setRestartError] = useState(null); + const [showLegacyLogsFallback, setShowLegacyLogsFallback] = useState(false); const runtimeSummary = useMemo( () => @@ -237,6 +242,7 @@ export const MemberDetailDialog = ({ setActiveTab(initialTab); setRestartError(null); setRestarting(false); + setShowLegacyLogsFallback(false); }, [initialTab, member, open]); const { @@ -246,6 +252,7 @@ export const MemberDetailDialog = ({ } = useMemberStats(teamName, member?.name ?? null); const totalTokens = memberStats ? memberStats.inputTokens + memberStats.outputTokens : null; + const memberLogStreamEnabled = isMemberLogStreamUiEnabled(); if (!member) return null; @@ -352,7 +359,26 @@ export const MemberDetailDialog = ({ /> - + {memberLogStreamEnabled ? ( +
+ + {showLegacyLogsFallback ? ( +
+
+ Legacy Logs Fallback +
+ +
+ ) : null} +
+ ) : ( + + )}
diff --git a/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx index aa4d1c02..0975d3e7 100644 --- a/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx +++ b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx @@ -1,28 +1,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { api } from '@renderer/api'; -import { MemberBadge } from '@renderer/components/team/MemberBadge'; -import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; import { - getTeamColorSet, - getThemedBadge, - getThemedBorder, - getThemedText, -} from '@renderer/constants/teamColors'; -import { useTheme } from '@renderer/hooks/useTheme'; + ExecutionLogStreamView, + normalizeExecutionLogStream, +} from '@features/member-log-stream/renderer'; +import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; -import { asEnhancedChunkArray } from '@renderer/types/data'; -import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { isLeadMember } from '@shared/utils/leadDetection'; -import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react'; -import type { - BoardTaskLogActor, - BoardTaskLogSegment, - BoardTaskLogStreamResponse, - ResolvedTeamMember, -} from '@shared/types'; +import type { BoardTaskLogStreamResponse } from '@shared/types'; interface TaskLogStreamSectionProps { teamName: string; @@ -33,54 +19,6 @@ interface TaskLogStreamSectionProps { const LIVE_RELOAD_DEBOUNCE_MS = 350; -function formatRelativeTime(isoString: string): string { - const date = new Date(isoString); - const diffMs = Date.now() - date.getTime(); - const diffMin = Math.floor(diffMs / 60_000); - const diffHours = Math.floor(diffMin / 60); - const diffDays = Math.floor(diffHours / 24); - - if (!Number.isFinite(diffMs)) return '--'; - if (diffMin < 1) return 'just now'; - if (diffMin < 60) return `${diffMin}m ago`; - if (diffHours < 24) return `${diffHours}h ago`; - return `${diffDays}d ago`; -} - -function actorLabel(actor: BoardTaskLogActor): string { - if (actor.memberName) { - return actor.memberName; - } - if (actor.role === 'lead' || actor.isSidechain === false) { - return 'lead session'; - } - if (actor.agentId) { - return `member ${actor.agentId.slice(0, 8)}`; - } - return `member session ${actor.sessionId.slice(0, 8)}`; -} - -function normalizeResponse(response: BoardTaskLogStreamResponse): BoardTaskLogStreamResponse { - return { - participants: response.participants, - defaultFilter: response.defaultFilter, - source: response.source, - runtimeProjection: response.runtimeProjection, - segments: response.segments.map((segment) => ({ - ...segment, - chunks: asEnhancedChunkArray(segment.chunks) ?? [], - })), - }; -} - -function buildStableSegmentRenderKey(segment: BoardTaskLogSegment): string { - const firstChunkId = segment.chunks[0]?.id; - if (firstChunkId) { - return `${segment.participantKey}:${firstChunkId}`; - } - return `${segment.participantKey}:${segment.startTimestamp}`; -} - function describeStreamSource(stream: BoardTaskLogStreamResponse | null): string { if (stream?.source === 'opencode_runtime_attribution') { return 'Task-scoped OpenCode runtime logs projected from explicit task attribution into the same execution-log components used in Logs.'; @@ -109,142 +47,6 @@ function describeStreamSource(stream: BoardTaskLogStreamResponse | null): string return 'Task-scoped transcript logs rendered with the same execution-log components used in Logs.'; } -interface ParticipantVisual { - name: string; - color?: string; -} - -function buildParticipantVisualMap( - stream: BoardTaskLogStreamResponse | null, - members: readonly ResolvedTeamMember[], - memberColorMap: ReadonlyMap -): Map { - const visuals = new Map(); - const leadMember = members.find((member) => isLeadMember(member)); - - for (const participant of stream?.participants ?? []) { - const matchingSegment = stream?.segments.find( - (segment) => segment.participantKey === participant.key - ); - const name = - matchingSegment?.actor.memberName ?? - (participant.isLead ? leadMember?.name : undefined) ?? - participant.label; - - visuals.set(participant.key, { - name, - color: memberColorMap.get(name) ?? memberColorMap.get(participant.label), - }); - } - - for (const segment of stream?.segments ?? []) { - if (visuals.has(segment.participantKey)) { - continue; - } - const name = segment.actor.memberName ?? actorLabel(segment.actor); - visuals.set(segment.participantKey, { - name, - color: memberColorMap.get(name), - }); - } - - return visuals; -} - -const SegmentMarker = ({ - segment, - visual, - teamName, -}: { - segment: BoardTaskLogSegment; - visual?: ParticipantVisual; - teamName: string; -}): React.JSX.Element => { - return ( -
- {visual ? ( - - ) : null} - - - {formatRelativeTime(segment.endTimestamp)} - -
- ); -}; - -const SegmentBlock = ({ - segment, - showHeader, - teamName, - visual, -}: { - segment: BoardTaskLogSegment; - showHeader: boolean; - teamName: string; - visual?: ParticipantVisual; -}): React.JSX.Element => { - return ( -
- {showHeader ? : null} - -
- ); -}; - -const ParticipantFilterChip = ({ - label, - selected, - visual, - teamName, - onClick, -}: { - label: string; - selected: boolean; - visual?: ParticipantVisual; - teamName: string; - onClick: () => void; -}): React.JSX.Element => { - const { isLight } = useTheme(); - const colors = getTeamColorSet(visual?.color ?? ''); - const borderColor = selected ? getThemedBorder(colors, isLight) : 'var(--color-border)'; - const backgroundColor = selected ? getThemedBadge(colors, isLight) : 'transparent'; - const textColor = selected ? getThemedText(colors, isLight) : 'var(--color-text-muted)'; - - return ( - - ); -}; - export const TaskLogStreamSection = ({ teamName, taskId, @@ -254,7 +56,6 @@ export const TaskLogStreamSection = ({ const [stream, setStream] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [selectedParticipantKey, setSelectedParticipantKey] = useState<'all' | string>('all'); const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName)); const requestSeqRef = useRef(0); const streamRef = useRef(null); @@ -265,41 +66,25 @@ export const TaskLogStreamSection = ({ }, [stream]); const loadStream = useCallback( - async (options?: { resetSelection?: boolean; background?: boolean }): Promise => { - const resetSelection = options?.resetSelection ?? false; + async (options?: { background?: boolean }): Promise => { const background = options?.background ?? false; const hadExistingStream = streamRef.current != null; const requestSeq = requestSeqRef.current + 1; requestSeqRef.current = requestSeq; - if (!background) { - setLoading(true); - } + if (!background) setLoading(true); setError((prev) => (background ? prev : null)); try { - const response = normalizeResponse(await api.teams.getTaskLogStream(teamName, taskId)); - if (requestSeqRef.current !== requestSeq) { - return; - } + const response = normalizeExecutionLogStream( + await api.teams.getTaskLogStream(teamName, taskId) + ); + if (requestSeqRef.current !== requestSeq) return; setStream(response); - setSelectedParticipantKey((prev) => { - if (resetSelection) { - return response.defaultFilter; - } - const availableParticipantKeys = new Set([ - 'all', - ...response.participants.map((participant) => participant.key), - ]); - return availableParticipantKeys.has(prev) ? prev : response.defaultFilter; - }); setError(null); } catch (loadError) { - if (requestSeqRef.current !== requestSeq) { - return; - } - + if (requestSeqRef.current !== requestSeq) return; if (!background || streamRef.current == null) { setError( loadError instanceof Error ? loadError.message : 'Failed to load task log stream' @@ -319,13 +104,12 @@ export const TaskLogStreamSection = ({ setStream(null); streamRef.current = null; setError(null); - setSelectedParticipantKey('all'); requestSeqRef.current += 1; if (reloadTimerRef.current) { clearTimeout(reloadTimerRef.current); reloadTimerRef.current = null; } - void loadStream({ resetSelection: true }); + void loadStream(); }, [loadStream]); const previousTaskMetaRef = useRef({ taskId, taskStatus }); @@ -334,10 +118,7 @@ export const TaskLogStreamSection = ({ const previousTaskMeta = previousTaskMetaRef.current; previousTaskMetaRef.current = { taskId, taskStatus }; - if (previousTaskMeta.taskId !== taskId) { - return; - } - + if (previousTaskMeta.taskId !== taskId) return; if ( previousTaskMeta.taskStatus === 'in_progress' && taskStatus && @@ -357,12 +138,8 @@ export const TaskLogStreamSection = ({ } const scheduleReload = (): void => { - if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { - return; - } - if (reloadTimerRef.current) { - clearTimeout(reloadTimerRef.current); - } + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; + if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current); reloadTimerRef.current = setTimeout(() => { reloadTimerRef.current = null; void loadStream({ background: true }); @@ -370,22 +147,15 @@ export const TaskLogStreamSection = ({ }; const unsubscribe = api.teams.onTeamChange?.((_event, event) => { - if (event.teamName !== teamName) { - return; - } + if (event.teamName !== teamName) return; const shouldReload = event.type === 'log-source-change' || (event.type === 'task-log-change' && event.taskId === taskId); - if (!shouldReload) { - return; - } - scheduleReload(); + if (shouldReload) scheduleReload(); }); const handleVisibilityChange = (): void => { - if (document.visibilityState === 'visible') { - scheduleReload(); - } + if (document.visibilityState === 'visible') scheduleReload(); }; if (typeof document !== 'undefined') { @@ -400,115 +170,25 @@ export const TaskLogStreamSection = ({ if (typeof document !== 'undefined') { document.removeEventListener('visibilitychange', handleVisibilityChange); } - if (typeof unsubscribe === 'function') { - unsubscribe(); - } + if (typeof unsubscribe === 'function') unsubscribe(); }; }, [liveEnabled, loadStream, taskId, teamName]); - const participants = stream?.participants ?? []; - const memberColorMap = useMemo(() => buildMemberColorMap(teamMembers), [teamMembers]); - const participantVisuals = useMemo( - () => buildParticipantVisualMap(stream, teamMembers, memberColorMap), - [memberColorMap, stream, teamMembers] - ); - const showChips = participants.length > 1; const streamDescription = useMemo(() => describeStreamSource(stream), [stream]); - const visibleSegments = useMemo(() => { - const source = stream?.segments ?? []; - const filtered = - selectedParticipantKey === 'all' - ? source - : source.filter((segment) => segment.participantKey === selectedParticipantKey); - return [...filtered].reverse(); - }, [selectedParticipantKey, stream?.segments]); - - const showSegmentHeaders = - participants.length > 1 || (selectedParticipantKey !== 'all' && visibleSegments.length > 1); - - if (loading) { - return ( -
-

- Task Log Stream -

-
- - Loading task log stream... -
-
- ); - } - - if (error) { - return ( -
-

- Task Log Stream -

-
- - {error} -
-
- ); - } return ( -
-

- Task Log Stream -

-

{streamDescription}

- - {showChips ? ( -
- - {participants.map((participant) => ( - setSelectedParticipantKey(participant.key)} - /> - ))} -
- ) : null} - - {visibleSegments.length === 0 ? ( -
- - No task log stream yet -

- Task-linked logs will appear here when transcript metadata or runtime projection is - available. -

-
- ) : ( -
- {visibleSegments.map((segment) => ( - - ))} -
- )} -
+ ); }; diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index b52d8fc2..fe4f6d45 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -98,6 +98,7 @@ import type { TerminalAPI } from './terminal'; import type { TmuxAPI } from './tmux'; import type { WaterfallData } from './visualization'; import type { CodexAccountElectronApi } from '@features/codex-account/contracts'; +import type { MemberLogStreamApi } from '@features/member-log-stream/contracts'; import type { MemberWorkSyncMetricsRequest, MemberWorkSyncReportRequest, @@ -904,6 +905,9 @@ export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElec // Member actionable-work sync diagnostics API memberWorkSync: MemberWorkSyncElectronApi; + // Member log stream API + memberLogStream: MemberLogStreamApi; + // tmux runtime diagnostics API tmux: TmuxAPI; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index e42bcf6e..66aa5758 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -807,8 +807,14 @@ export interface ResolvedTeamMember { workflow?: string; isolation?: 'worktree'; providerId?: TeamProviderId; + providerBackendId?: TeamProviderBackendId; model?: string; effort?: EffortLevel; + selectedFastMode?: TeamFastMode; + resolvedFastMode?: boolean; + laneId?: string; + laneKind?: 'primary' | 'secondary'; + laneOwnerProviderId?: TeamProviderId; cwd?: string; /** Set only when member's git branch differs from the lead's branch. */ gitBranch?: string; @@ -908,7 +914,13 @@ export interface TeamViewSnapshot { export type EffortLevel = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'; export type TeamProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode'; -export type TeamProviderBackendId = 'auto' | 'adapter' | 'api' | 'cli-sdk' | 'codex-native'; +export type TeamProviderBackendId = + | 'auto' + | 'adapter' + | 'api' + | 'cli-sdk' + | 'codex-native' + | 'opencode-cli'; export type TeamFastMode = 'inherit' | 'on' | 'off'; export interface ProviderModelLaunchIdentity { diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts index 5c38cb3b..be5796f3 100644 --- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -954,8 +954,8 @@ describe('ClaudeMultimodelBridgeService', () => { messageCount: 2, toolCallCount: 1, errorCount: 0, - latestAssistantText: '/tmp/project', - latestAssistantPreview: '/tmp/project', + latestAssistantText: '/Users/tester/project', + latestAssistantPreview: '/Users/tester/project', messages: [], diagnostics: [], logProjection: { @@ -1027,6 +1027,65 @@ describe('ClaudeMultimodelBridgeService', () => { }); }); + it('passes OpenCode lane and popup timeout to the runtime transcript command', async () => { + execCliMock.mockImplementation(async (_binaryPath, args) => { + const normalizedArgs = Array.isArray(args) ? args.join(' ') : ''; + + if ( + normalizedArgs.startsWith( + 'runtime transcript --json --provider opencode --team team-a --member alice --projection-only --limit 20 --lane secondary:opencode:alice --output ' + ) + ) { + const outputIndex = Array.isArray(args) ? args.indexOf('--output') : -1; + const outputPath = + outputIndex >= 0 && Array.isArray(args) ? String(args[outputIndex + 1] ?? '') : ''; + await writeFile( + outputPath, + JSON.stringify({ + schemaVersion: 1, + providerId: 'opencode', + transcript: { + sessionId: 'session-lane', + durableState: 'idle', + messages: [], + diagnostics: [], + logProjection: { + messages: [], + }, + }, + }), + 'utf8' + ); + return Promise.resolve({ + stdout: '', + stderr: '', + exitCode: 0, + }); + } + + return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`)); + }); + + const { ClaudeMultimodelBridgeService } = + await import('@main/services/runtime/ClaudeMultimodelBridgeService'); + const service = new ClaudeMultimodelBridgeService(); + + const transcript = await service.getOpenCodeTranscript('/mock/agent_teams_orchestrator', { + teamId: 'team-a', + memberName: 'alice', + limit: 20, + laneId: ' secondary:opencode:alice ', + timeoutMs: 1_234, + }); + + expect(transcript?.sessionId).toBe('session-lane'); + expect(execCliMock).toHaveBeenCalledWith( + '/mock/agent_teams_orchestrator', + expect.arrayContaining(['--lane', 'secondary:opencode:alice']), + expect.objectContaining({ timeout: 1_234 }) + ); + }); + it('loads a large real OpenCode projection fixture through output-file transcript delivery', async () => { const fixturePath = path.resolve( process.cwd(), diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index 19ea0cff..2486c21f 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -35,38 +35,42 @@ describe('TeamMemberLogsFinder', () => { await fs.mkdir(projectRoot, { recursive: true }); const projectResolver = { - getLiveBaseContext: vi.fn(async () => ({ - projectDir: projectRoot, - projectId: '-Users-test-live-context', - config, - })), - getContext: vi.fn(async () => { - throw new Error('broad context must not be used for live tracking'); - }), + getLiveBaseContext: vi.fn(() => + Promise.resolve({ + projectDir: projectRoot, + projectId: '-Users-test-live-context', + config, + }) + ), + getContext: vi.fn(() => + Promise.reject(new Error('broad context must not be used for live tracking')) + ), }; const launchStateStore = { - read: vi.fn(async () => ({ - version: 2, - teamName, - updatedAt: '2026-05-03T12:00:00.000Z', - leadSessionId: 'lead-session', - launchPhase: 'active', - expectedMembers: ['bob'], - members: { - bob: { - name: 'bob', - launchState: 'runtime_pending_bootstrap', - agentToolAccepted: true, - runtimeAlive: false, - bootstrapConfirmed: false, - hardFailure: false, - runtimeSessionId: 'runtime-bob', - updatedAt: '2026-05-03T12:00:00.000Z', + read: vi.fn(() => + Promise.resolve({ + version: 2, + teamName, + updatedAt: '2026-05-03T12:00:00.000Z', + leadSessionId: 'lead-session', + launchPhase: 'active', + expectedMembers: ['bob'], + members: { + bob: { + name: 'bob', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + runtimeSessionId: 'runtime-bob', + updatedAt: '2026-05-03T12:00:00.000Z', + }, }, - }, - summary: {}, - teamLaunchState: 'partial_pending', - })), + summary: {}, + teamLaunchState: 'partial_pending', + }) + ), }; const finder = new TeamMemberLogsFinder( @@ -418,6 +422,97 @@ describe('TeamMemberLogsFinder', () => { expect(refs.some((ref) => ref.memberName === 'Tom')).toBe(false); }); + it('applies recent-ref object options to discovery, lead refs, metadata, and requested-member attribution', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'member-stream-ref-options'; + const projectPath = '/Users/test/member-stream-ref-options'; + const projectId = '-Users-test-member-stream-ref-options'; + const leadSessionId = 'lead-session'; + const recentSince = Date.now() - 10 * 60_000; + const old = new Date(Date.now() - 30 * 60_000); + const now = new Date(); + const projectRoot = path.join(tmpDir, 'projects', projectId); + const subagentsDir = path.join(projectRoot, leadSessionId, 'subagents'); + await fs.mkdir(subagentsDir, { recursive: true }); + + const leadPath = path.join(projectRoot, `${leadSessionId}.jsonl`); + await fs.writeFile( + leadPath, + JSON.stringify({ + timestamp: old.toISOString(), + type: 'user', + message: { role: 'user', content: `Lead for team "${teamName}" (${teamName})` }, + }) + '\n', + 'utf8' + ); + await fs.utimes(leadPath, old, old); + + const zoePath = path.join(subagentsDir, 'agent-zoe.jsonl'); + await fs.writeFile( + zoePath, + [ + JSON.stringify({ + timestamp: now.toISOString(), + type: 'user', + message: { + role: 'user', + content: `You are Zoe, a developer on team "${teamName}" (${teamName}).`, + }, + }), + JSON.stringify({ + timestamp: now.toISOString(), + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Ready' }] }, + }), + ].join('\n') + '\n', + 'utf8' + ); + await fs.utimes(zoePath, now, now); + + const projectResolver = { + getContext: vi.fn(() => + Promise.resolve({ + projectDir: projectRoot, + projectId, + sessionIds: [leadSessionId], + config: { + name: teamName, + projectPath, + leadSessionId, + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + }) + ), + }; + const finder = new TeamMemberLogsFinder( + undefined, + undefined, + undefined, + projectResolver as never + ); + + const refs = await finder.findRecentMemberLogFileRefsByMember(teamName, ['team-lead', 'Zoe'], { + mtimeSinceMs: recentSince, + forceRefresh: true, + }); + + expect(projectResolver.getContext).toHaveBeenCalledWith( + teamName, + expect.objectContaining({ forceRefresh: true }) + ); + expect(refs).toEqual([ + expect.objectContaining({ + memberName: 'Zoe', + filePath: zoePath, + kind: 'subagent', + sizeBytes: expect.any(Number), + }), + ]); + expect(refs.some((ref) => ref.filePath === leadPath)).toBe(false); + }); + it('listAttributedSubagentFiles only returns files from the current lead session for live tracking', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); setClaudeBasePathOverride(tmpDir); @@ -452,18 +547,22 @@ describe('TeamMemberLogsFinder', () => { await fs.mkdir(path.join(projectRoot, currentSessionId, 'subagents'), { recursive: true }); await fs.mkdir(path.join(projectRoot, oldSessionId, 'subagents'), { recursive: true }); - const attributedLog = [ - JSON.stringify({ - timestamp: '2026-01-01T00:00:01.000Z', - type: 'user', - message: { role: 'user', content: `You are alice, a developer on team "${teamName}" (${teamName}).` }, - }), - JSON.stringify({ - timestamp: '2026-01-01T00:00:02.000Z', - type: 'assistant', - message: { role: 'assistant', content: [{ type: 'text', text: 'OK' }] }, - }), - ].join('\n') + '\n'; + const attributedLog = + [ + JSON.stringify({ + timestamp: '2026-01-01T00:00:01.000Z', + type: 'user', + message: { + role: 'user', + content: `You are alice, a developer on team "${teamName}" (${teamName}).`, + }, + }), + JSON.stringify({ + timestamp: '2026-01-01T00:00:02.000Z', + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'OK' }] }, + }), + ].join('\n') + '\n'; await fs.writeFile( path.join(projectRoot, currentSessionId, 'subagents', 'agent-current.jsonl'), @@ -1259,7 +1358,11 @@ describe('TeamMemberLogsFinder', () => { message: { role: 'assistant', content: [ - { type: 'tool_use', name: 'TaskUpdate', input: { taskId: '5', status: 'in_progress' } }, + { + type: 'tool_use', + name: 'TaskUpdate', + input: { taskId: '5', status: 'in_progress' }, + }, ], }, }), @@ -1413,7 +1516,11 @@ describe('TeamMemberLogsFinder', () => { message: { role: 'assistant', content: [ - { type: 'tool_use', name: 'TaskUpdate', input: { taskId: '3', status: 'in_progress' } }, + { + type: 'tool_use', + name: 'TaskUpdate', + input: { taskId: '3', status: 'in_progress' }, + }, ], }, }), diff --git a/test/renderer/components/team/members/MemberDetailDialog.test.ts b/test/renderer/components/team/members/MemberDetailDialog.test.ts index 78f905f3..2b8dc1ca 100644 --- a/test/renderer/components/team/members/MemberDetailDialog.test.ts +++ b/test/renderer/components/team/members/MemberDetailDialog.test.ts @@ -6,6 +6,10 @@ import { useStore } from '@renderer/store'; import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; +const memberLogStreamMockState = vi.hoisted(() => ({ + uiEnabled: true, +})); + vi.mock('@renderer/hooks/useMemberStats', () => ({ useMemberStats: () => ({ stats: null, @@ -110,11 +114,32 @@ vi.mock('@renderer/components/team/members/MemberLogsTab', () => ({ MemberLogsTab: () => React.createElement('div', null, 'logs-tab'), })); +vi.mock('@features/member-log-stream/renderer', async () => { + const ReactModule = await import('react'); + return { + isMemberLogStreamUiEnabled: () => memberLogStreamMockState.uiEnabled, + MemberLogStreamSection: ({ + onInitialLoadErrorChange, + }: { + onInitialLoadErrorChange?: (hasError: boolean) => void; + }) => + ReactModule.createElement( + 'button', + { + type: 'button', + onClick: () => onInitialLoadErrorChange?.(true), + }, + 'member-log-stream' + ), + }; +}); + import { MemberDetailDialog } from '@renderer/components/team/members/MemberDetailDialog'; describe('MemberDetailDialog activity count', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + memberLogStreamMockState.uiEnabled = true; useStore.setState({ teamMessagesByName: { 'demo-team': { @@ -139,6 +164,98 @@ describe('MemberDetailDialog activity count', () => { vi.unstubAllGlobals(); }); + it('renders legacy member logs directly when the member log stream UI gate is off', async () => { + memberLogStreamMockState.uiEnabled = false; + const member: ResolvedTeamMember = { + name: 'jack', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberDetailDialog, { + open: true, + member, + teamName: 'demo-team', + members: [member], + tasks: [], + initialTab: 'logs', + onClose: () => undefined, + onSendMessage: () => undefined, + onAssignTask: () => undefined, + onTaskClick: () => undefined, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('logs-tab'); + expect(host.textContent).not.toContain('member-log-stream'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps the stream visible and renders legacy fallback after an initial stream error', async () => { + const member: ResolvedTeamMember = { + name: 'jack', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberDetailDialog, { + open: true, + member, + teamName: 'demo-team', + members: [member], + tasks: [], + initialTab: 'logs', + onClose: () => undefined, + onSendMessage: () => undefined, + onAssignTask: () => undefined, + onTaskClick: () => undefined, + }) + ); + await Promise.resolve(); + }); + + const streamButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('member-log-stream') + ); + expect(streamButton).not.toBeUndefined(); + + await act(async () => { + streamButton?.click(); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('member-log-stream'); + expect(host.textContent).toContain('Legacy Logs Fallback'); + expect(host.textContent).toContain('logs-tab'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('counts task comments in the Activity badge even when messageCount is zero', async () => { const member: ResolvedTeamMember = { name: 'jack', @@ -348,7 +465,7 @@ describe('MemberDetailDialog activity count', () => { messageCount: 0, providerId: 'opencode', }; - const onRestartMember = vi.fn(async () => undefined); + const onRestartMember = vi.fn(() => Promise.resolve(undefined)); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); @@ -424,7 +541,7 @@ describe('MemberDetailDialog activity count', () => { messageCount: 0, providerId: 'opencode', }; - const onRestartMember = vi.fn(async () => undefined); + const onRestartMember = vi.fn(() => Promise.resolve(undefined)); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); diff --git a/test/renderer/components/team/members/MemberLogStreamSection.fixture-e2e.test.tsx b/test/renderer/components/team/members/MemberLogStreamSection.fixture-e2e.test.tsx new file mode 100644 index 00000000..30bc6aba --- /dev/null +++ b/test/renderer/components/team/members/MemberLogStreamSection.fixture-e2e.test.tsx @@ -0,0 +1,378 @@ +/* eslint-disable security/detect-non-literal-fs-filename -- Fixture E2E reads a repo fixture and writes temp JSONL. */ +import { readFile, rm, stat, writeFile, mkdtemp } from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { GetMemberLogStreamUseCase } from '../../../../../src/features/member-log-stream/core/application/use-cases/GetMemberLogStreamUseCase'; +import { + type MemberLogStreamRequestOptions, + type MemberLogStreamResponse, +} from '../../../../../src/features/member-log-stream/contracts'; +import { ClaudeMemberTranscriptStreamSource } from '../../../../../src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource'; +import { OpenCodeMemberRuntimeStreamSource } from '../../../../../src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimeStreamSource'; +import { BoardTaskExactLogChunkBuilder } from '../../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder'; +import { BoardTaskExactLogStrictParser } from '../../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser'; +import { TooltipProvider } from '../../../../../src/renderer/components/ui/tooltip'; + +import type { OpenCodeRuntimeTranscriptResponse } from '../../../../../src/main/services/runtime/ClaudeMultimodelBridgeService'; +import type { MemberLogFileRef } from '../../../../../src/main/services/team/TeamMemberLogsFinder'; +import type { ResolvedTeamMember } from '../../../../../src/shared/types'; + +const TEAM_NAME = 'relay-works-10'; +const MEMBER_NAME = 'jack'; +const LANE_ID = 'secondary:opencode:jack'; +const GENERATED_AT = '2026-04-24T20:40:00.000Z'; +const FIXTURE_PATH = path.resolve( + process.cwd(), + 'test/fixtures/team/opencode/relay-works-10-jack-projection-transcript.json' +); + +const tempDirs: string[] = []; + +const apiState = { + getMemberLogStream: + vi.fn< + ( + teamName: string, + memberName: string, + options?: MemberLogStreamRequestOptions + ) => Promise + >(), + setMemberLogStreamTracking: vi.fn<(teamName: string, enabled: boolean) => Promise>(), + onTeamChange: vi.fn<(callback: (event: unknown, data: unknown) => void) => () => void>(), +}; + +vi.mock('@renderer/api', () => ({ + api: { + memberLogStream: { + getMemberLogStream: (...args: Parameters) => + apiState.getMemberLogStream(...args), + setMemberLogStreamTracking: ( + ...args: Parameters + ) => apiState.setMemberLogStreamTracking(...args), + }, + teams: { + onTeamChange: (...args: Parameters) => + apiState.onTeamChange(...args), + }, + }, +})); + +import { MemberLogStreamSection } from '../../../../../src/features/member-log-stream/renderer'; + +function flushMicrotasks(): Promise { + return Promise.resolve(); +} + +function flushAsyncWork(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +async function waitForText( + host: HTMLElement, + predicate: (text: string) => boolean +): Promise { + let text = ''; + for (let attempt = 0; attempt < 25; attempt += 1) { + await act(async () => { + await flushAsyncWork(); + }); + text = host.textContent ?? ''; + if (predicate(text)) { + return text; + } + } + return text; +} + +async function loadOpenCodeFixtureTranscript(): Promise< + NonNullable +> { + const parsed = JSON.parse( + await readFile(FIXTURE_PATH, 'utf8') + ) as OpenCodeRuntimeTranscriptResponse; + if (parsed.providerId !== 'opencode' || !parsed.transcript) { + throw new Error('Invalid OpenCode transcript fixture'); + } + return parsed.transcript; +} + +async function createClaudeTranscriptRef(): Promise { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'member-log-stream-e2e-')); + tempDirs.push(tempDir); + + const filePath = path.join(tempDir, 'jack-claude-session.jsonl'); + const rows = [ + { + parentUuid: null, + isSidechain: true, + userType: 'external', + cwd: '/Users/tester/project', + sessionId: 'claude-session-jack', + version: '1.0.0', + gitBranch: 'main', + agentName: MEMBER_NAME, + type: 'system', + uuid: 'claude-init', + timestamp: '2026-04-24T20:25:00.000Z', + subtype: 'init', + level: 'info', + isMeta: false, + content: 'member session started', + }, + { + parentUuid: 'claude-init', + isSidechain: true, + userType: 'external', + cwd: '/Users/tester/project', + sessionId: 'claude-session-jack', + version: '1.0.0', + gitBranch: 'main', + agentName: MEMBER_NAME, + type: 'user', + uuid: 'claude-user-1', + timestamp: '2026-04-24T20:25:01.000Z', + isMeta: false, + message: { + role: 'user', + content: 'Collect member-wide evidence for calculator behavior.', + }, + }, + { + parentUuid: 'claude-user-1', + isSidechain: true, + userType: 'external', + cwd: '/Users/tester/project', + sessionId: 'claude-session-jack', + version: '1.0.0', + gitBranch: 'main', + agentName: MEMBER_NAME, + type: 'assistant', + uuid: 'claude-assistant-1', + requestId: 'req-claude-1', + timestamp: '2026-04-24T20:25:03.000Z', + message: { + role: 'assistant', + id: 'msg-claude-1', + type: 'message', + model: 'claude-sonnet-4-5-20250929', + content: [ + { + type: 'text', + text: 'Member-wide Claude transcript final note for Jack.', + }, + ], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 12, output_tokens: 16 }, + }, + }, + ]; + + await writeFile(filePath, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8'); + const fileStat = await stat(filePath); + + return { + memberName: MEMBER_NAME, + sessionId: 'claude-session-jack', + filePath, + mtimeMs: fileStat.mtimeMs, + sizeBytes: fileStat.size, + messageCount: rows.length, + kind: 'subagent', + }; +} + +async function createFixtureUseCase(): Promise<{ + useCase: GetMemberLogStreamUseCase; + getOpenCodeTranscript: ReturnType; + findRecentMemberLogFileRefsByMember: ReturnType; +}> { + const claudeRef = await createClaudeTranscriptRef(); + const openCodeTranscript = await loadOpenCodeFixtureTranscript(); + const findRecentMemberLogFileRefsByMember = vi.fn(() => Promise.resolve([claudeRef])); + const getOpenCodeTranscript = vi.fn(() => Promise.resolve(openCodeTranscript)); + + const chunkBuilder = new BoardTaskExactLogChunkBuilder(); + const sources = [ + new ClaudeMemberTranscriptStreamSource( + { findRecentMemberLogFileRefsByMember } as never, + new BoardTaskExactLogStrictParser(), + chunkBuilder, + { warn: vi.fn(), error: vi.fn(), debug: vi.fn() } + ), + new OpenCodeMemberRuntimeStreamSource( + { getOpenCodeTranscript } as never, + chunkBuilder, + { resolve: vi.fn(() => Promise.resolve('/Users/tester/agent_teams_orchestrator')) } + ), + ]; + + return { + useCase: new GetMemberLogStreamUseCase({ + sources, + clock: { now: () => Date.parse(GENERATED_AT) }, + logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + }), + getOpenCodeTranscript, + findRecentMemberLogFileRefsByMember, + }; +} + +function createMember(): ResolvedTeamMember { + return { + name: MEMBER_NAME, + status: 'idle', + currentTaskId: null, + taskCount: 2, + lastActiveAt: '2026-04-24T20:34:00.000Z', + messageCount: 12, + color: 'blue', + providerId: 'opencode', + laneId: LANE_ID, + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + }; +} + +function stubMatchMedia(): void { + const matchMedia = vi.fn((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + vi.stubGlobal('matchMedia', matchMedia); +} + +function expectCapturedResponse( + value: MemberLogStreamResponse | null +): MemberLogStreamResponse { + expect(value).not.toBeNull(); + return value!; +} + +describe('MemberLogStreamSection real fixture e2e', () => { + afterEach(async () => { + document.body.innerHTML = ''; + apiState.getMemberLogStream.mockReset(); + apiState.setMemberLogStreamTracking.mockReset(); + apiState.onTeamChange.mockReset(); + vi.unstubAllGlobals(); + await Promise.all( + tempDirs.splice(0, tempDirs.length).map((dirPath) => + rm(dirPath, { recursive: true, force: true }) + ) + ); + }); + + it('renders member-wide Claude transcript and OpenCode runtime logs through the member Logs UI', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + stubMatchMedia(); + apiState.onTeamChange.mockImplementation(() => () => undefined); + apiState.setMemberLogStreamTracking.mockResolvedValue(undefined); + + const { useCase, getOpenCodeTranscript, findRecentMemberLogFileRefsByMember } = + await createFixtureUseCase(); + const capturedResponseRef: { current: MemberLogStreamResponse | null } = { current: null }; + apiState.getMemberLogStream.mockImplementation(async (teamName, memberName, options) => { + const response = await useCase.execute({ + teamName, + memberName, + limitSegments: options?.limitSegments, + laneId: options?.laneId, + forceRefresh: options?.forceRefresh, + }); + capturedResponseRef.current = response; + return response; + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement( + TooltipProvider, + null, + React.createElement(MemberLogStreamSection, { + teamName: TEAM_NAME, + member: createMember(), + }) + ) + ); + await flushMicrotasks(); + }); + + const text = await waitForText(host, (content) => + content.includes('Member-wide Claude transcript final note for Jack.') + ); + + expect(text).toContain('Logs'); + expect(text).toContain('Member-scoped transcript and runtime logs'); + expect(text).toContain('Claude transcript'); + expect(text).toContain('OpenCode runtime'); + expect(text).toContain('Calculator behavior'); + expect(text).toContain('Logic smoke check'); + expect(text).toContain('Collect member-wide evidence for calculator behavior.'); + + const capturedResponse = expectCapturedResponse(capturedResponseRef.current); + expect(capturedResponse).toMatchObject({ + source: 'member_mixed_runtime', + defaultFilter: 'member:jack', + generatedAt: GENERATED_AT, + metadata: { + scannedTranscriptFileCount: 1, + includedTranscriptFileCount: 1, + }, + }); + expect(capturedResponse.coverage).toEqual( + expect.arrayContaining([ + { provider: 'claude_transcript', status: 'included' }, + { provider: 'opencode_runtime', status: 'included' }, + ]) + ); + expect(JSON.stringify(capturedResponse.segments)).toContain('Keyboard handlers added'); + expect(apiState.getMemberLogStream).toHaveBeenCalledWith( + TEAM_NAME, + MEMBER_NAME, + expect.objectContaining({ + limitSegments: 30, + laneId: LANE_ID, + }) + ); + expect(findRecentMemberLogFileRefsByMember).toHaveBeenCalledWith( + TEAM_NAME, + [MEMBER_NAME], + expect.objectContaining({ forceRefresh: false }) + ); + expect(getOpenCodeTranscript).toHaveBeenCalledWith( + '/Users/tester/agent_teams_orchestrator', + expect.objectContaining({ + teamId: TEAM_NAME, + memberName: MEMBER_NAME, + laneId: LANE_ID, + limit: 400, + timeoutMs: 5_000, + }) + ); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + + expect(apiState.setMemberLogStreamTracking).toHaveBeenCalledWith(TEAM_NAME, true); + expect(apiState.setMemberLogStreamTracking).toHaveBeenCalledWith(TEAM_NAME, false); + }); +});