# Member Liveness Hardening Plan ## Коротко Нужно исправить кейс, где launch UI висит на `Members joining`, участники выглядят как `starting`, а runtime memory показывает около `2 MB`. По текущему коду это почти наверняка значит, что UI видит tmux pane/shell PID, а не реальный teammate runtime. Главное изменение: разделить "что-то зарегистрировано", "pane/shell жив", "процесс runtime реально найден" и "member сделал bootstrap/check-in". Сейчас эти сигналы частично смешаны через `runtimeAlive`. Рекомендуемый путь: **UI + строгая liveness-модель**. 🎯 9/10 🛡️ 9/10 🧠 7/10 Примерно 650-950 строк production-кода + 350-550 строк тестов. ## Почему не UI-only Топ 3 вариантов: 1. UI-only diagnostics 🎯 7 🛡️ 4 🧠 3 Примерно 180-260 строк. Покажет, что происходит, но backend все равно сможет считать shell живым runtime. Зависание станет понятнее, но не надежнее. 2. UI + строгая liveness-модель 🎯 9 🛡️ 9 🧠 7 Примерно 650-950 строк. Исправляет причину: weak evidence больше не маскирует timeout, UI получает понятные причины, self-heal остается только для надежных сигналов. 3. Полный lease/heartbeat runtime manager 🎯 8 🛡️ 10 🧠 9 Примерно 1200-1800 строк. Самый надежный вариант, но слишком большой для первого фикса. Его лучше делать после варианта 2, когда станут видны реальные runtime-команды и частота edge cases. ## Что проверено в коде Факты, которые важны для плана: - `mcp-server/src/tools/runtimeTools.ts` уже содержит `runtime_bootstrap_checkin` и `runtime_heartbeat`. Это сильный сигнал, его надо сделать главным источником подтверждения. - `agent-teams-controller/src/internal/runtime.js` уже прокидывает `runtimeBootstrapCheckin()` в desktop runtime. - `src/main/services/team/TeamBootstrapStateReader.ts` уже читает `bootstrap-state.json`, `bootstrap-journal.jsonl` и классифицирует stuck bootstrap. Там уже есть важные тайминги: `ACTIVE_BOOTSTRAP_STUCK_CLASSIFICATION_MS = 3 min` и `TERMINAL_BOOTSTRAP_ONLY_PENDING_GRACE_MS = 5 min`. - `TeamProvisioningService.getLiveTeamAgentRuntimeMetadata()` собирает evidence из config/meta/persisted runtime/tmux/process table и прогоняет его через strict resolver. - Для tmux раньше читался только `#{pane_id}\t#{pane_pid}` через `listTmuxPanePidsForCurrentPlatform()`. `pane_pid` часто является shell (`zsh`, `bash`, `sh`), поэтому `2 MB` выглядело логично. - `attachLiveRuntimeMetadataToStatuses()` теперь повышает member до `runtimeAlive: true` только через strong evidence: `confirmed_bootstrap` или `runtime_process`. - `reevaluateMemberLaunchStatus()` больше не доверяет старому `runtimeAlive === true` без live metadata. - `OpenCodeTeamRuntimeAdapter.mapBridgeMemberToRuntimeEvidence()` теперь не выставляет `runtimeAlive: true` для bridge-only `created` или `permission_blocked`. Такие сигналы остаются candidate/pending до bootstrap или OS verification. - `recordOpenCodeRuntimeBootstrapCheckin()` и `recordOpenCodeRuntimeHeartbeat()` уже пишут `confirmed_alive`, `runtimeAlive: true`, `bootstrapConfirmed: true`, `nativeHeartbeat: true` через `updateOpenCodeRuntimeMemberLiveness()`. Значит confirmed state уже есть, надо не дать слабым сигналам выглядеть как он. - `OpenCodeLaunchTransactionStore.canMarkOpenCodeRunReady()` уже требует `member_session_recorded`, `required_tools_proven` и `bootstrap_confirmed`. Это strict readiness precedent, который надо сохранить. - Renderer уже получает оба источника: `memberSpawnStatuses` и `teamAgentRuntimeByTeam`. Но `MemberCard` сейчас получает только `runtimeSummary` строкой, а не сам `TeamAgentRuntimeEntry`. - `teamSlice.areTeamAgentRuntimeEntriesEqual()` должен сравнивать `livenessKind`, `pidSource` и diagnostic fields, иначе UI может не перерендериться при смене strict evidence. - `teamSlice.areMemberSpawnStatusEntriesEqual()` должен сравнивать visible liveness fields (`livenessKind/runtimeDiagnostic`) и продолжать игнорировать timing-only fields. - `areLaunchSummaryCountsEqual()` должен сравнивать aggregate diagnostic counts (`shellOnlyPendingCount`, `runtimeProcessPendingCount`, `runtimeCandidatePendingCount`, `noRuntimePendingCount`, `permissionPendingCount`). UI не должен использовать legacy `runtimeAlivePendingCount` как process evidence. - `TeamAgentRuntimeWatcher` обновляет runtime snapshot раз в 5 секунд, а spawn statuses раз в 2.5 секунды. Диагностические поля должны попадать либо в оба snapshot слоя, либо UX должен быть устойчив к задержке runtime snapshot. - Renderer `member-spawn` event сейчас вызывает refresh spawn statuses, но не runtime snapshot. Если tooltip/detail зависят от `TeamAgentRuntimeSnapshot`, event handler тоже должен запланировать runtime refresh. - Runtime tools принимают `metadata`, но `recordOpenCodeRuntimeBootstrapCheckin()` и `recordOpenCodeRuntimeHeartbeat()` сейчас используют только `diagnostics`. Если runtime присылает PID/version/command в `metadata`, эта информация теряется. - `handleMemberSpawnToolResult()` раньше при reason `already_running` делал `setMemberSpawnStatus(..., "online", ..., "process")`. В strict model это заменено на `waiting` + runtime re-evaluation. - `waitForTmuxPanesToExit()` использует `listTmuxPanePidsForCurrentPlatform()` только как "pane exists" check. Поэтому старый `listPanePids()` wrapper должен остаться ровно pane-existence helper, а не получить новую liveness-семантику. - Для member liveness strict model включена по умолчанию без отдельного env-флага. - `src/shared/types/api.ts`, `src/preload/index.ts` и `src/renderer/api/httpClient.ts` уже прокидывают `getMemberSpawnStatuses()` и `getTeamAgentRuntime()` через shared snapshot types. Новый контракт можно добавить optional fields без нового IPC channel, но browser HTTP fallback должен возвращать валидный старый shape. - `TeamProvisioningService.readUnixProcessTableRows()` сейчас приватный, sync и читает только `pid,command`. Для надежного liveness нужен `ppid`, WSL-aware execution и unit-test seam. Это не должно оставаться приватным ad hoc helper внутри огромного service. - `getLiveTeamAgentRuntimeMetadata()` сейчас читает tmux panes и process table внутри одного метода. После strict model там станет слишком много правил, поэтому план должен вынести pure resolution в отдельный helper/module. ## Главная проблема Текущий `runtimeAlive` слишком широкий: ```text tmux pane exists -> pane_pid is zsh/bash with low RSS -> metadata.alive = true -> MemberSpawnStatusEntry.runtimeAlive = true -> grace timeout does not fail -> UI shows starting/joining for minutes ``` Нужно прекратить использовать один boolean для разных уровней доверия. ## Целевой контракт ### Evidence ladder Сигналы должны оцениваться сверху вниз: 1. `confirmed_bootstrap` Member сделал `member_briefing`, `runtime_bootstrap_checkin`, `runtime_heartbeat`, meaningful inbox heartbeat или успешный bootstrap transcript. Это самый сильный сигнал. 2. `runtime_process` Найден процесс runtime с надежной идентичностью: `--team-name ` + `--agent-id `, или OpenCode bridge вернул валидный `runtimePid`/`sessionId`, и PID жив. 3. `runtime_process_candidate` Найден non-shell descendant под tmux pane, но без строгого identity match. Это diagnostic signal, не strong alive signal в первой реализации. 4. `permission_blocked` Runtime/bridge явно говорит, что требуется permission approval. 5. `shell_only` Tmux pane жив, но foreground command или root pane process выглядит как shell, и runtime child не найден. 6. `registered_only` Member есть в `config.json`/`members.meta.json`, но live process не найден. 7. `stale_metadata` Есть persisted `agentId`, `tmuxPaneId` или `runtimePid`, но live evidence не подтвержден. 8. `not_found` Нет полезных runtime данных. ### Strong vs weak Только эти состояния ставят `runtimeAlive: true`: - `confirmed_bootstrap` - `runtime_process` Эти состояния не ставят `runtimeAlive: true`: - `runtime_process_candidate` - `permission_blocked` - `shell_only` - `registered_only` - `stale_metadata` - `not_found` Почему `runtime_process_candidate` не strong: non-shell child может быть `node`, `script`, `sleep`, wrapper или одноразовая команда. Без `teamName/agentId/sessionId` это слишком рискованно для снятия failure. ## Тайминги Оставить текущий `MEMBER_LAUNCH_GRACE_MS = 90_000` как короткий timeout для отсутствия strong evidence. Добавить отдельный bootstrap stall deadline: ```ts const MEMBER_BOOTSTRAP_STALL_MS = 5 * 60_000; ``` Правила: - После 90 секунд: - `shell_only`, `registered_only`, `stale_metadata`, `not_found` -> `failed_to_start`. - `permission_blocked` -> не hard fail, показать permission UI. - `runtime_process_candidate` -> warning, но не считать ready. - `runtime_process` -> warning `waiting for bootstrap`, но не hard fail на 90 сек. - После 5 минут: - `runtime_process_candidate` без bootstrap -> `failed_to_start`. - `runtime_process` без bootstrap -> `runtimeDiagnosticSeverity: "warning"` и launch banner должен перестать быть мутным: `runtime alive but no bootstrap after 5 min`. Важно: verified runtime process не надо сразу убивать или hard fail-ить только потому, что bootstrap не пришел. Но UI не должен продолжать generic `starting`. ## Rollout mode Строгая модель меняет поведение launch timeout, поэтому изначальный план рассматривал rollout через отдельный флаг. Текущая реализация после hardening включает strict liveness по умолчанию и не содержит старый переключатель режима. Актуальное поведение: | Area | Strict-only behavior | | ------------------------------ | ------------------------------------- | | `livenessKind` | always filled when evidence exists | | UI labels | enabled | | `runtimeAlive` from shell-only | always false | | `already_running` shortcut | waits for strong runtime verification | | timeout self-heal | strong evidence only | | launchDiagnostics | enabled for warning/error states | Operational rollback должен быть отдельным code revert или follow-up setting, а не скрытым env-флагом. ## Structured launch diagnostics Файлы: - `src/shared/types/team.ts` - `src/main/services/team/TeamProvisioningService.ts` - `src/main/services/team/progressPayload.ts` - `src/renderer/components/team/ProvisioningProgressBlock.tsx` `TeamProvisioningProgress` сейчас почти полностью строковый: - `message` - `warnings` - `cliLogsTail` - `assistantOutput` `cliLogsTail` и `assistantOutput` уже специально ограничены (`PROGRESS_LOG_TAIL_LINES`, `PROGRESS_OUTPUT_TAIL_PARTS`), чтобы не провоцировать renderer OOM. Поэтому нельзя решать проблему "непонятно что происходит" простым расширением логов. Добавить маленький структурированный payload: ```ts export interface TeamLaunchDiagnosticItem { id: string; memberName?: string; severity: 'info' | 'warning' | 'error'; code: | 'spawn_accepted' | 'runtime_process_detected' | 'runtime_process_candidate' | 'tmux_shell_only' | 'runtime_not_found' | 'permission_pending' | 'bootstrap_confirmed' | 'bootstrap_stalled' | 'stale_runtime_event_rejected' | 'process_table_unavailable'; label: string; detail?: string; observedAt: string; } export interface TeamProvisioningProgress { // existing fields... launchDiagnostics?: TeamLaunchDiagnosticItem[]; } ``` Bounded contract: - максимум 20 diagnostic items в progress payload; - newest-first или stable sorted by severity/member; - no raw unbounded command strings; - process command must be sanitized/truncated; - member-level details live in `MemberSpawnStatusEntry`/`TeamAgentRuntimeEntry`, progress diagnostics are only summary. Renderer: - `ProvisioningProgressBlock` can render a compact "Diagnostics" disclosure above Live output. - It should show code-specific rows like `bob - shell only - tmux pane foreground command is zsh`. - It should not require opening CLI logs to understand common stuck states. Recommended UI rows: ```text bob shell only tmux pane foreground command is zsh jack waiting for bootstrap verified runtime process, no check-in yet tom no runtime found spawn accepted 94s ago ``` This is separate from `Copy diagnostics`, which can include full sanitized JSON. ## Типы Файл: `src/shared/types/team.ts` ```ts export type TeamAgentRuntimeLivenessKind = | 'confirmed_bootstrap' | 'runtime_process' | 'runtime_process_candidate' | 'permission_blocked' | 'shell_only' | 'registered_only' | 'stale_metadata' | 'not_found'; export type TeamAgentRuntimePidSource = | 'lead_process' | 'tmux_pane' | 'tmux_child' | 'agent_process_table' | 'opencode_bridge' | 'runtime_bootstrap' | 'persisted_metadata'; export type TeamAgentRuntimeDiagnosticSeverity = 'info' | 'warning' | 'error'; export interface TeamAgentRuntimeEntry { memberName: string; alive: boolean; restartable: boolean; backendType?: TeamAgentRuntimeBackendType; providerId?: TeamProviderId; providerBackendId?: TeamProviderBackendId; laneId?: string; laneKind?: 'primary' | 'secondary'; pid?: number; runtimeModel?: string; rssBytes?: number; livenessKind?: TeamAgentRuntimeLivenessKind; pidSource?: TeamAgentRuntimePidSource; processCommand?: string; paneId?: string; panePid?: number; paneCurrentCommand?: string; runtimePid?: number; runtimeSessionId?: string; runtimeLeaseExpiresAt?: string; runtimeLastSeenAt?: string; runtimeDiagnostic?: string; runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; diagnostics?: string[]; updatedAt: string; } ``` В `MemberSpawnStatusEntry` добавить только компактные поля для launch UI: ```ts export interface MemberSpawnStatusEntry { // existing fields runtimeDiagnostic?: string; runtimeDiagnosticSeverity?: 'info' | 'warning' | 'error'; livenessKind?: TeamAgentRuntimeLivenessKind; livenessLastCheckedAt?: string; } ``` Почему `runtimeSessionId` и `runtimeLastSeenAt` важны: - OpenCode runtime tools всегда передают `runtimeSessionId`. - `runtime_bootstrap_checkin` и `runtime_heartbeat` уже являются lease-like сигналом. - Без `runtimeLastSeenAt` UI не сможет отличить "процесс подтвержден 10 секунд назад" от "persisted state висит со вчера". - `runtimeLeaseExpiresAt` можно не включать в Phase 0, но тип стоит заложить сразу, если lease/heartbeat manager будет Phase 5. ## Runtime tool metadata Файлы: - `mcp-server/src/tools/runtimeTools.ts` - `src/main/services/team/TeamProvisioningService.ts` `runtime_bootstrap_checkin` и `runtime_heartbeat` уже принимают `metadata`, но main сейчас не извлекает из нее ничего. Это упущение: OpenCode/runtime может передать полезные low-level детали, которые не стоит парсить из logs. Поддержать bounded metadata: ```ts interface RuntimeToolMetadata { runtimePid?: number; processCommand?: string; runtimeVersion?: string; hostPid?: number; cwd?: string; } ``` Parser: ```ts function parseRuntimeToolMetadata(value: unknown): RuntimeToolMetadata { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {}; } const raw = value as Record; const runtimePid = typeof raw.runtimePid === 'number' && Number.isFinite(raw.runtimePid) && raw.runtimePid > 0 ? Math.trunc(raw.runtimePid) : undefined; const processCommand = typeof raw.processCommand === 'string' ? raw.processCommand.slice(0, 500) : undefined; return { ...(runtimePid ? { runtimePid } : {}), ...(processCommand ? { processCommand } : {}), }; } ``` Security/robustness: - bound string lengths; - ignore nested objects except allowlisted fields; - never put raw metadata into logs/UI; - include sanitized fields in copy diagnostics. `updateOpenCodeRuntimeMemberLiveness()` should accept sanitized metadata: ```ts await this.updateOpenCodeRuntimeMemberLiveness({ teamName, runId, memberName, runtimeSessionId, observedAt, diagnostics: payload.diagnostics, metadata: parseRuntimeToolMetadata(payload.metadata), reason: 'OpenCode runtime bootstrap check-in accepted', }); ``` If metadata has `runtimePid`, still verify it: - PID must be alive now; - command must still look like the expected runtime, if command info is available; - runId/teamName/sessionId must match current tombstone/launch state. Do not trust metadata PID by itself. ## Internal metadata Файл: `src/main/services/team/TeamProvisioningService.ts` Расширить внутренний тип: ```ts interface LiveTeamAgentRuntimeMetadata { alive: boolean; backendType?: TeamAgentRuntimeBackendType; agentId?: string; pid?: number; metricsPid?: number; model?: string; tmuxPaneId?: string; livenessKind?: TeamAgentRuntimeLivenessKind; pidSource?: TeamAgentRuntimePidSource; processCommand?: string; panePid?: number; paneCurrentCommand?: string; runtimeSessionId?: string; diagnostics?: string[]; } ``` Helper: ```ts function isStrongRuntimeEvidence(metadata: LiveTeamAgentRuntimeMetadata | undefined): boolean { return ( metadata?.livenessKind === 'confirmed_bootstrap' || metadata?.livenessKind === 'runtime_process' ); } function isWeakRuntimeEvidence(metadata: LiveTeamAgentRuntimeMetadata | undefined): boolean { return ( metadata?.livenessKind === 'runtime_process_candidate' || metadata?.livenessKind === 'permission_blocked' || metadata?.livenessKind === 'shell_only' || metadata?.livenessKind === 'registered_only' || metadata?.livenessKind === 'stale_metadata' || metadata?.livenessKind === 'not_found' ); } ``` ## Liveness resolver seam Файл: `src/main/services/team/TeamRuntimeLivenessResolver.ts` Не стоит держать весь liveness algorithm внутри `TeamProvisioningService`. Там уже смешаны launch state, persistence, progress, tmux, OpenCode, inbox audit и runtime snapshot. Для надежности и тестов лучше вынести pure resolver. Варианты: 1. Вынести только pure helpers 🎯 8 🛡️ 7 🧠 4 Примерно 120-180 строк. Быстро, но `getLiveTeamAgentRuntimeMetadata()` останется большим orchestration методом. 2. Вынести resolver с input/output контрактом 🎯 9 🛡️ 9 🧠 6 Примерно 220-340 строк. Лучший баланс: service собирает raw facts, resolver принимает facts и возвращает `LiveTeamAgentRuntimeMetadata`. 3. Вынести полноценный runtime monitor service 🎯 8 🛡️ 10 🧠 8 Примерно 500-800 строк. Архитектурно чище, но слишком большой шаг для текущего фикса. Рекомендация: вариант 2. Resolver input: ```ts export interface ResolveTeamMemberRuntimeLivenessInput { teamName: string; memberName: string; agentId?: string; backendType?: TeamAgentRuntimeBackendType; providerId?: TeamProviderId; tmuxPaneId?: string; persistedRuntimePid?: number; persistedRuntimeSessionId?: string; trackedSpawnStatus?: MemberSpawnStatusEntry; openCodeEvidence?: TeamRuntimeMemberLaunchEvidence; pane?: TmuxPaneRuntimeInfo; processRows: readonly RuntimeProcessTableRow[]; nowIso: string; } ``` Resolver output: ```ts export interface ResolvedTeamMemberRuntimeLiveness { alive: boolean; livenessKind: TeamAgentRuntimeLivenessKind; pidSource?: TeamAgentRuntimePidSource; pid?: number; metricsPid?: number; panePid?: number; paneCurrentCommand?: string; processCommand?: string; runtimeSessionId?: string; runtimeDiagnostic: string; runtimeDiagnosticSeverity: TeamAgentRuntimeDiagnosticSeverity; diagnostics: string[]; } ``` `TeamProvisioningService` responsibilities after extraction: - read config/meta/persisted launch/runtime state; - batch-read tmux pane runtime info once; - batch-read process table once; - call resolver per member; - cache and expose the resolved metadata; - invalidate caches on check-in/heartbeat/restart/stop/pane kill. Resolver responsibilities: - classify shell-only vs runtime process vs candidate; - enforce strong/weak evidence rules; - choose `pidSource`; - sanitize diagnostics; - never read filesystem, tmux, process table or stores directly. This seam makes the hardest rules unit-testable without spawning tmux or fake processes. ## Tmux runtime info Файл: `src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts` Сейчас читается только pane PID. Нужно получать больше контекста: ```ts export interface TmuxPaneRuntimeInfo { paneId: string; panePid: number; currentCommand?: string; currentPath?: string; sessionName?: string; windowName?: string; } async listPaneRuntimeInfo(paneIds: readonly string[]): Promise> { const normalizedPaneIds = [...new Set(paneIds.map((paneId) => paneId.trim()).filter(Boolean))]; if (normalizedPaneIds.length === 0) return new Map(); const format = [ '#{pane_id}', '#{pane_pid}', '#{pane_current_command}', '#{pane_current_path}', '#{session_name}', '#{window_name}', ].join('\t'); const result = await this.execTmux(['list-panes', '-a', '-F', format], 3_000); if (result.exitCode !== 0) { throw new Error(result.stderr || 'Failed to list tmux panes'); } const wanted = new Set(normalizedPaneIds); const infoByPaneId = new Map(); for (const line of result.stdout.split('\n')) { const [paneId = '', rawPid = '', currentCommand = '', currentPath = '', sessionName = '', windowName = ''] = line.split('\t'); const normalizedPaneId = paneId.trim(); if (!wanted.has(normalizedPaneId)) continue; const panePid = Number.parseInt(rawPid.trim(), 10); if (!Number.isFinite(panePid) || panePid <= 0) continue; infoByPaneId.set(normalizedPaneId, { paneId: normalizedPaneId, panePid, currentCommand: currentCommand.trim() || undefined, currentPath: currentPath.trim() || undefined, sessionName: sessionName.trim() || undefined, windowName: windowName.trim() || undefined, }); } return infoByPaneId; } ``` Оставить старый метод как wrapper: ```ts async listPanePids(paneIds: readonly string[]): Promise> { const info = await this.listPaneRuntimeInfo(paneIds); return new Map([...info.entries()].map(([paneId, pane]) => [paneId, pane.panePid])); } ``` Compatibility rule: - `listPanePids()` remains "does this pane exist and what is its root pane PID". - It must not imply teammate runtime liveness. - Existing callers like `waitForTmuxPanesToExit()` should keep working without knowing about `livenessKind`. ## Process table Нужен `ppid`, иначе невозможно понять, есть ли runtime child под tmux pane. ```ts interface RuntimeProcessTableRow { pid: number; ppid: number; command: string; } ``` Do not implement this as `readUnixProcessTableRows()` inside `TeamProvisioningService`. The current helper is private, sync and native-only. The strict model needs a testable, platform-aware provider. Recommended shape: ```ts export interface RuntimeProcessTableProvider { listRuntimeProcesses(): Promise; } ``` `TmuxPlatformCommandExecutor` can implement it because it already knows whether the current tmux runtime is native or WSL-backed. На macOS/Linux: ```ts ps -ax -o pid=,ppid=,command= ``` На Windows/WSL важно: `ps` должен выполняться внутри той же WSL distro, где выполняется tmux. Host-side Windows `ps` не увидит Linux children. Практичный вариант: - добавить в `TmuxPlatformCommandExecutor` метод `listRuntimeProcesses()`; - внутри Windows ветки использовать `TmuxWslService` и запускать `wsl -d -e ps -ax -o pid=,ppid=,command=`; - на native платформах использовать обычный `execFile('ps', ...)`. Пример парсинга: ```ts function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow[] { const rows: RuntimeProcessTableRow[] = []; for (const line of output.split('\n')) { const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line); if (!match) continue; const pid = Number.parseInt(match[1], 10); const ppid = Number.parseInt(match[2], 10); const command = match[3]?.trim() ?? ''; if (Number.isFinite(pid) && pid > 0 && Number.isFinite(ppid) && command) { rows.push({ pid, ppid, command }); } } return rows; } ``` Performance contract: - read process table once per runtime snapshot, not once per member; - reuse the same rows for every member resolver call; - respect the existing backend cache TTL around 2 seconds; - if process table read fails, return an explicit diagnostic and do not mark shell-only as strong alive. Failure contract: - `process_table_unavailable` is a warning, not an immediate hard fail by itself; - if tmux pane info is available but process table is unavailable, classify as `shell_only` only when `pane_current_command` is shell-like; - if both tmux and process table are unavailable, classify as `stale_metadata` or `not_found` based on persisted evidence; - do not self-clear a previous failure on provider failure. ### PID freshness and reuse PID alone is not identity. A stale persisted `runtimePid` can be reused by the OS for another process. Rules: - Never treat persisted PID as strong evidence without reading the current process table. - A PID match is strong only if current command identity also matches expected runtime identity. - If possible later, add process start time to the table and compare it with `firstSpawnAcceptedAt`/`runtimeLastSeenAt`. - If process start time is unavailable, use command identity and current run/session identity as the minimum. Optional future row: ```ts interface RuntimeProcessTableRow { pid: number; ppid: number; command: string; startedAtMs?: number; } ``` Do not block Phase 1 on `startedAtMs`; block it on "no PID-only strong evidence". ## Shell detection ```ts const SHELL_COMMAND_NAMES = new Set(['sh', 'bash', 'zsh', 'fish', 'dash', 'login', 'tmux']); function basenameCommand(command: string | undefined): string { const firstToken = command?.trim().split(/\s+/, 1)[0] ?? ''; const base = firstToken.split(/[\\/]/).pop() ?? firstToken; return base.replace(/^-/, '').toLowerCase(); } function isShellLikeCommand(command: string | undefined): boolean { return SHELL_COMMAND_NAMES.has(basenameCommand(command)); } ``` ## Runtime identity matching Текущий `commandContainsCliArgValue()` лучше заменить на helper, который поддерживает оба вида: - `--agent-id abc` - `--agent-id=abc` - quoted values Минимально: ```ts function extractCliArgValues(command: string, argName: string): string[] { const escapedArg = argName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const pattern = new RegExp( `(?:^|\\s)${escapedArg}(?:=|\\s+)("([^"]*)"|'([^']*)'|([^\\s]+))`, 'g' ); const values: string[] = []; for (const match of command.matchAll(pattern)) { const value = (match[2] ?? match[3] ?? match[4] ?? '').trim(); if (value) values.push(value); } return values; } function commandArgEquals(command: string, argName: string, expected: string | undefined): boolean { if (!expected?.trim()) return false; return extractCliArgValues(command, argName).some((value) => value === expected.trim()); } ``` Strong process match: ```ts function isVerifiedRuntimeProcess(params: { row: RuntimeProcessTableRow; teamName: string; agentId?: string; }): boolean { return ( commandArgEquals(params.row.command, '--team-name', params.teamName) && commandArgEquals(params.row.command, '--agent-id', params.agentId) ); } ``` Sanitize any command before it reaches UI/logs/copy diagnostics: ```ts const SECRET_FLAG_PATTERN = /(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; function sanitizeProcessCommandForDiagnostics(command: string | undefined): string | undefined { const trimmed = command?.trim(); if (!trimmed) return undefined; return trimmed.replace(SECRET_FLAG_PATTERN, '$1[redacted]').slice(0, 500); } ``` Do not use sanitized commands for identity matching. Match on the raw process table row inside main process memory, then only expose sanitized/truncated command text. ## Descendant resolution ```ts function collectDescendants( rows: readonly RuntimeProcessTableRow[], rootPid: number ): RuntimeProcessTableRow[] { const childrenByParent = new Map(); for (const row of rows) { const bucket = childrenByParent.get(row.ppid) ?? []; bucket.push(row); childrenByParent.set(row.ppid, bucket); } const result: RuntimeProcessTableRow[] = []; const queue = [...(childrenByParent.get(rootPid) ?? [])]; while (queue.length > 0) { const next = queue.shift(); if (!next) continue; result.push(next); queue.push(...(childrenByParent.get(next.pid) ?? [])); } return result; } ``` Resolution: ```ts interface ResolvedRuntimeProcess { kind: TeamAgentRuntimeLivenessKind; pid?: number; command?: string; pidSource?: TeamAgentRuntimePidSource; diagnostics: string[]; } function resolveTmuxRuntimeProcess(params: { teamName: string; agentId?: string; pane: TmuxPaneRuntimeInfo; rows: readonly RuntimeProcessTableRow[]; }): ResolvedRuntimeProcess { const descendants = collectDescendants(params.rows, params.pane.panePid); const verified = descendants.find((row) => isVerifiedRuntimeProcess({ row, teamName: params.teamName, agentId: params.agentId, }) ); if (verified) { return { kind: 'runtime_process', pid: verified.pid, command: verified.command, pidSource: 'tmux_child', diagnostics: ['matched tmux descendant by team-name and agent-id'], }; } const candidate = descendants.find((row) => !isShellLikeCommand(row.command)); if (candidate) { return { kind: 'runtime_process_candidate', pid: candidate.pid, command: candidate.command, pidSource: 'tmux_child', diagnostics: ['found non-shell descendant without team/member identity'], }; } if (isShellLikeCommand(params.pane.currentCommand)) { return { kind: 'shell_only', pid: params.pane.panePid, command: params.pane.currentCommand, pidSource: 'tmux_pane', diagnostics: [ `tmux pane is alive, but foreground command is ${params.pane.currentCommand}`, 'no verified runtime descendant process was found', ], }; } return { kind: 'not_found', diagnostics: ['tmux pane exists, but no runtime process could be identified'], }; } ``` ## OpenCode bridge correction Файл: `src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts` Сейчас `pendingRuntimeObserved = createdOrBlocked && runtimeMaterialized`, а `runtimeMaterialized` фактически означает "bridge вернул member". Это не равно live runtime. Надо разделить: - `agentToolAccepted`: bridge принял/создал member. - `runtimeAlive`: есть подтвержденный live runtime signal или confirmed bootstrap. - `bootstrapConfirmed`: `launchState === "confirmed_alive"`. Пример: ```ts function mapBridgeMemberToRuntimeEvidence( memberName: string, launchState: OpenCodeTeamMemberLaunchBridgeState, sessionId: string | undefined, runtimePid: number | undefined, pendingPermissionRequestIds: string[] | undefined, runtimeMaterialized: boolean, diagnostics: string[] ): TeamRuntimeMemberLaunchEvidence { const confirmed = launchState === 'confirmed_alive'; const failed = launchState === 'failed'; const permissionBlocked = launchState === 'permission_blocked'; const validRuntimePid = typeof runtimePid === 'number' && Number.isFinite(runtimePid) && runtimePid > 0; const hasRuntimeSession = typeof sessionId === 'string' && sessionId.trim().length > 0; const runtimeLivenessKind = confirmed ? 'confirmed_bootstrap' : validRuntimePid ? 'runtime_process' : permissionBlocked ? 'permission_blocked' : hasRuntimeSession ? 'runtime_process_candidate' : undefined; return { memberName, providerId: 'opencode', launchState: failed ? 'failed_to_start' : confirmed ? 'confirmed_alive' : permissionBlocked ? 'runtime_pending_permission' : 'runtime_pending_bootstrap', agentToolAccepted: confirmed || runtimeMaterialized, runtimeAlive: confirmed || validRuntimePid, bootstrapConfirmed: confirmed, hardFailure: failed, hardFailureReason: failed ? 'OpenCode bridge reported member launch failure' : undefined, pendingPermissionRequestIds: pendingPermissionRequestIds && pendingPermissionRequestIds.length > 0 ? [...new Set(pendingPermissionRequestIds)] : undefined, sessionId, ...(validRuntimePid ? { runtimePid } : {}), ...(runtimeLivenessKind ? { livenessKind: runtimeLivenessKind } : {}), diagnostics, }; } ``` Важно: `sessionId` без `runtimePid` лучше считать candidate, а не strong live process. Session id полезен для delivery/permission correlation, но сам по себе не доказывает, что процесс сейчас жив. Также `toOpenCodePersistedLaunchMember()` должен сохранять `runtimePid` и `sessionId`, если они есть. Сейчас для primary OpenCode launch evidence это легко потерять. ### OpenCode transaction/readiness invariant `canMarkOpenCodeRunReady()` уже требует `bootstrap_confirmed`, поэтому новая liveness-модель не должна поднимать aggregate state в `clean_success`, если есть только: - bridge `created`; - `sessionId` без bootstrap; - permission request; - stale launch-state member. Regression test: ```ts expect( canMarkOpenCodeRunReady({ members: [{ name: 'bob', launchState: 'runtime_pending_bootstrap' }], // checkpoints exist except bootstrap }).ok ).toBe(false); ``` ### Stale runtime events `assertOpenCodeRuntimeEvidenceAccepted()` already checks tombstones/current run ownership before accepting bootstrap/heartbeat/delivery evidence. This must remain the gate for all strong OpenCode liveness. Rules: - `runtime_bootstrap_checkin` from an old `runId` must not revive a stopped/relaunched member. - `runtime_heartbeat` from an old lane must not refresh `runtimeLastSeenAt`. - Runtime metadata from rejected evidence must not be written to persisted launch state. - UI copy diagnostics should include `runId` and `runtimeSessionId` only after accepted evidence. Regression tests: ```ts await expect( service.recordOpenCodeRuntimeHeartbeat({ teamName, runId: oldRunId, memberName: 'bob', runtimeSessionId: oldSessionId, }) ).rejects.toThrow(); ``` ## `getLiveTeamAgentRuntimeMetadata()` Новая логика: 1. Сначала читать durable status: - `bootstrapConfirmed` - `lastHeartbeatAt` - `runtime_bootstrap_checkin` - transcript success 2. Потом читать verified runtime: - process table match by `--team-name` + `--agent-id` - OpenCode runtimePid/sessionId - tmux descendant with verified identity 3. Потом diagnostic-only: - tmux pane shell - config/meta registration - stale persisted metadata Sketch: ```ts const status = this.findTrackedMemberSpawnStatus(run, memberName); const diagnostics: string[] = []; let livenessKind: TeamAgentRuntimeLivenessKind = 'not_found'; let pid: number | undefined; let pidSource: TeamAgentRuntimePidSource | undefined; let processCommand: string | undefined; if (status?.bootstrapConfirmed === true) { livenessKind = 'confirmed_bootstrap'; diagnostics.push('bootstrap was confirmed by member check-in or heartbeat'); } if (livenessKind !== 'confirmed_bootstrap' && metadata.agentId) { const processPid = processPidByAgentId.get(metadata.agentId); if (processPid) { livenessKind = 'runtime_process'; pid = processPid; pidSource = 'agent_process_table'; diagnostics.push('matched process table by team-name and agent-id'); } } if (livenessKind !== 'runtime_process' && paneInfo) { const resolved = resolveTmuxRuntimeProcess({ teamName, agentId: metadata.agentId, pane: paneInfo, rows: processRows, }); livenessKind = resolved.kind; pid = resolved.pid; pidSource = resolved.pidSource; processCommand = resolved.command; diagnostics.push(...resolved.diagnostics); } if (livenessKind === 'not_found' && metadata.agentId) { livenessKind = 'stale_metadata'; diagnostics.push('persisted agent id exists, but no live process matched it'); } const alive = livenessKind === 'confirmed_bootstrap' || livenessKind === 'runtime_process'; metadataByMember.set(memberName, { ...metadata, alive, livenessKind, ...(pid ? { pid } : {}), ...(pidSource ? { pidSource } : {}), ...(processCommand ? { processCommand } : {}), ...(paneInfo ? { panePid: paneInfo.panePid, paneCurrentCommand: paneInfo.currentCommand, } : {}), diagnostics, }); ``` Fallback policy: - Если enhanced tmux info failed, не возвращать `alive: true` только из старого `panePid`. - Если `ps` failed, показывать diagnostic `process table unavailable`; не self-clear failure. - Если cached metadata есть, сохранять `model/backendType`, но не сохранять stale `alive`. - Если `previousMember.bootstrapConfirmed === true`, persisted launch state может оставаться confirmed для истории, но runtime snapshot должен показывать `alive` отдельно от historical `bootstrapConfirmed`. Иначе UI может считать старого member live после stop/relaunch. ## Persisted launch state Файл: `src/main/services/team/TeamLaunchStateEvaluator.ts` Сейчас `RuntimeMemberSpawnState` и persisted member normalization не знают про новые diagnostic поля. Нужно расширить аккуратно, чтобы старые snapshots читались без migration. Добавить в `PersistedTeamLaunchMemberState`: ```ts runtimeSessionId?: string; livenessKind?: TeamAgentRuntimeLivenessKind; pidSource?: TeamAgentRuntimePidSource; runtimeDiagnostic?: string; runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; ``` Правило: - persisted `livenessKind` можно использовать для UI explanation; - persisted `livenessKind` нельзя использовать как live proof без свежего `lastRuntimeAliveAt` или live runtime check. Normalize: ```ts function normalizeLivenessKind(value: unknown): TeamAgentRuntimeLivenessKind | undefined { return value === 'confirmed_bootstrap' || value === 'runtime_process' || value === 'runtime_process_candidate' || value === 'permission_blocked' || value === 'shell_only' || value === 'registered_only' || value === 'stale_metadata' || value === 'not_found' ? value : undefined; } ``` `updateOpenCodeRuntimeMemberLiveness()` должен сохранять: ```ts livenessKind: 'confirmed_bootstrap', pidSource: 'runtime_bootstrap', runtimeSessionId: input.runtimeSessionId, runtimeDiagnostic: undefined, runtimeDiagnosticSeverity: undefined, ``` `toOpenCodePersistedLaunchMember()` должен сохранять: ```ts runtimePid: evidence?.runtimePid, runtimeSessionId: evidence?.sessionId, livenessKind: evidence?.bootstrapConfirmed ? 'confirmed_bootstrap' : evidence?.runtimeAlive ? 'runtime_process' : evidence?.pendingPermissionRequestIds?.length ? 'permission_blocked' : undefined, ``` Mapping functions that must be updated: - `RuntimeMemberSpawnState` pick list must include `livenessKind`, `runtimeDiagnostic`, `runtimeDiagnosticSeverity`. - `snapshotFromRuntimeMemberStatuses()` must copy those fields into `PersistedTeamLaunchMemberState`. - `snapshotToMemberSpawnStatuses()` must copy them back into `MemberSpawnStatusEntry`. - `normalizePersistedLaunchSnapshot()` must normalize unknown old files without dropping valid new fields. Example: ```ts statuses[memberName] = { status, launchState: entry.launchState, error: entry.hardFailure ? entry.hardFailureReason : undefined, hardFailureReason: entry.hardFailureReason, livenessSource, agentToolAccepted: entry.agentToolAccepted, runtimeAlive: entry.runtimeAlive, bootstrapConfirmed: entry.bootstrapConfirmed, hardFailure: entry.hardFailure, pendingPermissionRequestIds: entry.pendingPermissionRequestIds, firstSpawnAcceptedAt: entry.firstSpawnAcceptedAt, lastHeartbeatAt: entry.lastHeartbeatAt, livenessKind: entry.livenessKind, runtimeDiagnostic: entry.runtimeDiagnostic, runtimeDiagnosticSeverity: entry.runtimeDiagnosticSeverity, updatedAt: entry.lastEvaluatedAt, }; ``` Backward compatibility: - old snapshots without these fields should behave exactly as today; - new optional summary counts should default to `0` at presentation time; - do not bump snapshot `version` unless a required field is introduced. For this plan, keep `version: 2`. ## `attachLiveRuntimeMetadataToStatuses()` Текущий behavior: ```ts if (metadata.alive) { nextEntry.runtimeAlive = true; nextEntry.livenessSource = 'process'; } ``` Новый behavior: ```ts const strongRuntimeAlive = isStrongRuntimeEvidence(metadata); const weakRuntimeEvidence = isWeakRuntimeEvidence(metadata); if ( strongRuntimeAlive && current.hardFailure !== true && current.launchState !== 'failed_to_start' ) { nextEntry.status = 'online'; nextEntry.agentToolAccepted = true; nextEntry.runtimeAlive = true; nextEntry.hardFailure = false; nextEntry.hardFailureReason = undefined; nextEntry.error = undefined; nextEntry.livenessSource = current.bootstrapConfirmed ? current.livenessSource : 'process'; nextEntry.livenessKind = metadata.livenessKind; nextEntry.runtimeDiagnostic = undefined; nextEntry.runtimeDiagnosticSeverity = undefined; nextEntry.launchState = deriveMemberLaunchState(nextEntry); } if (weakRuntimeEvidence && current.bootstrapConfirmed !== true) { nextEntry.runtimeAlive = false; nextEntry.livenessKind = metadata.livenessKind; nextEntry.runtimeDiagnostic = buildRuntimeDiagnostic(metadata); nextEntry.runtimeDiagnosticSeverity = metadata.livenessKind === 'shell_only' ? 'warning' : 'info'; } ``` Self-heal из `failed_to_start` оставить только для strong evidence: ```ts if ( strongRuntimeAlive && current.launchState === 'failed_to_start' && isAutoClearableLaunchFailureReason(failureReason) ) { // clear auto failure } ``` ## Spawn tool result handling Файл: `src/main/services/team/TeamProvisioningService.ts` `handleMemberSpawnToolResult()` раньше содержал shortcut: ```ts if (parsedStatus.reason === 'already_running') { this.setMemberSpawnStatus(run, spawnedMemberName, 'online', undefined, 'process'); } ``` В strict liveness модели это опасно: `already_running` доказывает, что runtime/CLI отказался дублировать spawn, но не доказывает, что нужный teammate сейчас прошел bootstrap или что текущий pane PID является runtime процессом. Итоговая логика: ```ts if (parsedStatus.reason === 'already_running') { this.agentRuntimeSnapshotCache.delete(run.teamName); this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting'); this.appendMemberBootstrapDiagnostic( run, spawnedMemberName, 'already_running requires strong runtime verification' ); void this.reevaluateMemberLaunchStatus(run, spawnedMemberName); } ``` Tests: - `already_running` + shell-only pane -> stays pending/warning, no `runtimeAlive`. - `already_running` + verified process -> can become `runtime_pending_bootstrap`. - `already_running` + confirmed bootstrap -> confirmed alive. ## `reevaluateMemberLaunchStatus()` Текущий early return по `refreshed.runtimeAlive` слишком широкий. Новый sketch: ```ts await this.refreshMemberSpawnStatusesFromLeadInbox(run); await this.maybeAuditMemberSpawnStatuses(run, { force: true }); const refreshed = run.memberSpawnStatuses.get(memberName); if (!refreshed) return; const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(run.teamName); const runtime = findRuntimeMetadataForMember(runtimeByMember, memberName); const strongRuntimeAlive = isStrongRuntimeEvidence(runtime); if (refreshed.launchState === 'failed_to_start' || refreshed.launchState === 'confirmed_alive') { return; } if (strongRuntimeAlive) { this.setMemberRuntimeDiagnostic(run, memberName, { livenessKind: runtime?.livenessKind, message: 'Runtime process is alive, waiting for teammate bootstrap/check-in.', severity: 'warning', }); return; } if (runtime?.livenessKind === 'permission_blocked') { return; } const reason = runtime?.livenessKind === 'shell_only' ? `Teammate did not join within the launch grace window. Tmux pane is alive, but only shell command "${runtime.paneCurrentCommand ?? 'unknown'}" was detected.` : runtime?.livenessKind === 'runtime_process_candidate' ? 'Teammate did not confirm bootstrap. Only an unverified runtime process candidate was found.' : 'Teammate did not join within the launch grace window.'; this.setMemberSpawnStatus(run, memberName, 'error', reason); ``` Для `runtime_process_candidate` лучше использовать 5 минут, не 90 секунд: ```ts const acceptedAtMs = Date.parse(refreshed.firstSpawnAcceptedAt ?? ''); const elapsedMs = Number.isFinite(acceptedAtMs) ? Date.now() - acceptedAtMs : 0; if ( runtime?.livenessKind === 'runtime_process_candidate' && elapsedMs < MEMBER_BOOTSTRAP_STALL_MS ) { return; } ``` ## Runtime snapshot and memory display `getTeamAgentRuntimeSnapshot()` сейчас выбирает `rssPid = liveRuntimeMember?.pid ?? liveRuntimeMember?.metricsPid`. Это нормально для сбора метрики, но UI должен знать источник. Правило: - `pidSource = tmux_pane` + `livenessKind = shell_only` -> memory is shell/pane RSS, не runtime RSS. - `pidSource = tmux_child` или `agent_process_table` -> memory is runtime process RSS. - OpenCode shared host `metricsPid` -> показать как shared host, не как member-owned runtime. - `launchSnapshotAlive` сейчас может сделать `alive: true`, если persisted launch member был `runtimeAlive` или `bootstrapConfirmed`. После изменения это надо разделить: - `historicallyConfirmedBootstrap` - для display/history. - `alive` - только свежий live runtime или свежий heartbeat lease. Добавить в `TeamAgentRuntimeEntry`: ```ts runtimeDiagnostic?: string; pidSource?: TeamAgentRuntimePidSource; paneCurrentCommand?: string; historicalBootstrapConfirmed?: boolean; runtimeLastSeenAt?: string; ``` UI tooltip может объяснить: ```text RSS source: tmux pane shell PID: 26691 Command: zsh Runtime process: not found Bootstrap: no check-in yet ``` ## Restartability semantics Файлы: - `src/main/services/team/TeamProvisioningService.ts` - `src/renderer/components/team/members/MemberDetailDialog.tsx` Важно не смешать `alive` и `restartable`. `shell_only` должен быть `alive: false`, но часто должен оставаться `restartable: true`, если есть `tmuxPaneId`. Иначе пользователь увидит `shell only`, но не сможет нажать Restart. Rules: - `confirmed_bootstrap` / `runtime_process` with member-owned PID -> `alive: true`, `restartable: true`. - `shell_only` with `tmuxPaneId` -> `alive: false`, `restartable: true`, restart kills pane. - `registered_only` without PID/pane -> `alive: false`, `restartable: false`. - OpenCode shared host `metricsPid` -> `restartable: false` unless adapter owns a member lane stop/restart path. - `in-process` -> keep `restartable: false`. `restartMember()` already kills persisted tmux panes via `killTmuxPaneForCurrentPlatformSync(paneId)`, so strict liveness should not remove pane ids from runtime snapshot just because they are weak evidence. Test: ```ts expect(shellOnlyRuntimeEntry).toMatchObject({ alive: false, restartable: true, livenessKind: 'shell_only', pidSource: 'tmux_pane', }); ``` ## IPC/store implications Файлы: - `src/main/ipc/teams.ts` - `src/renderer/store/index.ts` - `src/renderer/store/slices/teamSlice.ts` - `src/renderer/components/team/TeamDetailView.tsx` IPC уже возвращает `TeamAgentRuntimeSnapshot`, значит новый контракт проходит без нового channel. Но store equality обязательно надо обновить: ```ts function areTeamAgentRuntimeEntriesEqual( left: TeamAgentRuntimeEntry | undefined, right: TeamAgentRuntimeEntry | undefined ): boolean { if (left === right) return true; if (!left || !right) return left === right; return ( left.memberName === right.memberName && left.alive === right.alive && left.restartable === right.restartable && left.backendType === right.backendType && left.pid === right.pid && left.runtimeModel === right.runtimeModel && left.rssBytes === right.rssBytes && left.livenessKind === right.livenessKind && left.pidSource === right.pidSource && left.paneCurrentCommand === right.paneCurrentCommand && left.runtimeDiagnostic === right.runtimeDiagnostic && left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity && left.runtimeLastSeenAt === right.runtimeLastSeenAt ); } ``` Если не сделать это, backend может правильно вычислять `shell_only`, а UI продолжит показывать старую карточку из-за suppressed store update. Нужно обновить и spawn equality: ```ts function areMemberSpawnStatusEntriesEqual( left: MemberSpawnStatusEntry | undefined, right: MemberSpawnStatusEntry | undefined ): boolean { if (left === right) return true; if (!left || !right) return left === right; return ( // existing visible fields left.status === right.status && left.launchState === right.launchState && left.error === right.error && left.hardFailureReason === right.hardFailureReason && left.livenessSource === right.livenessSource && left.runtimeAlive === right.runtimeAlive && left.runtimeModel === right.runtimeModel && left.bootstrapConfirmed === right.bootstrapConfirmed && left.hardFailure === right.hardFailure && // new visible diagnostic fields left.livenessKind === right.livenessKind && left.runtimeDiagnostic === right.runtimeDiagnostic && left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity ); } ``` Summary equality: ```ts function areLaunchSummaryCountsEqual( left: PersistedTeamLaunchSummary | undefined, right: PersistedTeamLaunchSummary | undefined ): boolean { if (left === right) return true; if (!left || !right) return left === right; return ( left.confirmedCount === right.confirmedCount && left.pendingCount === right.pendingCount && left.failedCount === right.failedCount && left.runtimeAlivePendingCount === right.runtimeAlivePendingCount && left.shellOnlyPendingCount === right.shellOnlyPendingCount && left.runtimeProcessPendingCount === right.runtimeProcessPendingCount && left.runtimeCandidatePendingCount === right.runtimeCandidatePendingCount && left.noRuntimePendingCount === right.noRuntimePendingCount && left.permissionPendingCount === right.permissionPendingCount ); } ``` Event handling: ```ts if (event.type === 'member-spawn') { if (isStaleRuntimeEvent) return; seedCurrentRunIdIfMissing(); scheduleMemberSpawnStatusesRefresh(event.teamName); scheduleTeamAgentRuntimeRefresh(event.teamName); return; } ``` If `scheduleTeamAgentRuntimeRefresh()` does not exist, add a small debounced variant mirroring `scheduleMemberSpawnStatusesRefresh()`. Polling: - `TeamSpawnStatusWatcher` - 2.5 sec. - `TeamAgentRuntimeWatcher` - 5 sec. - Backend runtime metadata cache TTL is 2 sec. Для launch UI лучше продублировать короткий `livenessKind/runtimeDiagnostic` в `MemberSpawnStatusEntry`, а подробные PID/command детали оставить в runtime snapshot. Тогда badge меняется быстро, tooltip догоняет через runtime snapshot. Cache invalidation checklist: - invalidate `agentRuntimeSnapshotCache` and `liveTeamAgentRuntimeMetadataCache` on runtime check-in; - invalidate on heartbeat; - invalidate on member restart/stop/remove; - invalidate when tmux pane kill succeeds; - invalidate when launch state store writes a new liveness diagnostic. Without this, a member can remain visually `shell only` for up to the polling interval after a valid check-in, which is acceptable, but not after an explicit check-in event. ## API/preload propagation No new IPC channel is needed, but the type propagation still has sharp edges. Files to verify: - `src/shared/types/team.ts` - `src/shared/types/api.ts` - `src/preload/index.ts` - `src/renderer/api/httpClient.ts` - `src/renderer/store/slices/teamSlice.ts` Rules: - New fields on `TeamAgentRuntimeEntry`, `MemberSpawnStatusEntry` and `PersistedTeamLaunchSummary` must be optional at first. - `src/preload/index.ts` can keep the same `invokeIpcWithResult()` calls. - `src/shared/types/api.ts` should not need method signature changes, but typecheck must prove it. - `src/renderer/api/httpClient.ts` browser fallback must still return valid snapshots when new fields are absent. - Renderer helpers must tolerate `undefined` `livenessKind` and map it to current behavior. Recommended type compatibility test: ```ts const snapshot: TeamAgentRuntimeSnapshot = { teamName: 'demo', updatedAt: new Date().toISOString(), runId: null, members: { bob: { memberName: 'bob', alive: false, restartable: true, livenessKind: 'shell_only', pidSource: 'tmux_pane', paneCurrentCommand: 'zsh', updatedAt: new Date().toISOString(), }, }, }; ``` This catches accidental required fields before runtime. ## Progress diagnostics update path `updateProgress()` currently accepts only: ```ts Pick< TeamProvisioningProgress, 'pid' | 'error' | 'warnings' | 'cliLogsTail' | 'configReady' | 'messageSeverity' >; ``` If `launchDiagnostics` is added to `TeamProvisioningProgress`, `updateProgress()` must accept it explicitly: ```ts extras?: Pick< TeamProvisioningProgress, | 'pid' | 'error' | 'warnings' | 'cliLogsTail' | 'configReady' | 'messageSeverity' | 'launchDiagnostics' > ``` And keep it bounded: ```ts launchDiagnostics: boundLaunchDiagnostics( extras?.launchDiagnostics ?? run.progress.launchDiagnostics ), ``` Do not store this as `assistantOutput`. `assistantOutput` is rendered as markdown and is the wrong surface for machine-produced liveness facts. ## Renderer UX ### Member card labels Файлы: - `src/renderer/utils/memberHelpers.ts` - `src/renderer/components/team/members/MemberCard.tsx` - `src/renderer/utils/memberRuntimeSummary.ts` Новые visual states: ```ts export type MemberLaunchVisualState = | 'waiting' | 'spawning' | 'permission_pending' | 'waiting_bootstrap' | 'shell_only' | 'runtime_candidate' | 'registered_only' | 'stale_runtime' | 'error' | null; ``` Mapping: ```ts function resolveLaunchVisualState(params: { spawnStatus?: MemberSpawnStatusEntry; runtimeEntry?: TeamAgentRuntimeEntry; }): MemberLaunchVisualState { const { spawnStatus, runtimeEntry } = params; if (spawnStatus?.launchState === 'failed_to_start') return 'error'; if (spawnStatus?.launchState === 'runtime_pending_permission') return 'permission_pending'; if (runtimeEntry?.livenessKind === 'shell_only') return 'shell_only'; if (runtimeEntry?.livenessKind === 'runtime_process_candidate') return 'runtime_candidate'; if (runtimeEntry?.livenessKind === 'registered_only') return 'registered_only'; if (runtimeEntry?.livenessKind === 'stale_metadata') return 'stale_runtime'; if ( spawnStatus?.launchState === 'runtime_pending_bootstrap' && runtimeEntry?.livenessKind === 'runtime_process' ) { return 'waiting_bootstrap'; } return spawnStatus?.status === 'spawning' ? 'spawning' : 'waiting'; } ``` Labels: ```ts const MEMBER_LAUNCH_LABELS: Record, string> = { waiting: 'starting', spawning: 'starting', permission_pending: 'permission', waiting_bootstrap: 'waiting for bootstrap', shell_only: 'shell only', runtime_candidate: 'process candidate', registered_only: 'registered', stale_runtime: 'stale runtime', error: 'spawn failed', }; ``` Текущий `MemberCard` не принимает `runtimeEntry`, поэтому надо изменить props: ```ts interface MemberCardProps { // existing runtimeEntry?: TeamAgentRuntimeEntry; spawnEntry?: MemberSpawnStatusEntry; } ``` И передавать из `MemberList`: ```tsx ``` Затем `buildMemberLaunchPresentation()` должен принимать `runtimeEntry` или хотя бы `livenessKind`: ```ts const launchPresentation = buildMemberLaunchPresentation({ member, spawnStatus, spawnLaunchState, spawnLivenessSource, spawnRuntimeAlive, runtimeEntry, runtimeAdvisory: member.runtimeAdvisory, isLaunchSettling, isTeamAlive, isTeamProvisioning, leadActivity, }); ``` То же нужно для `MemberDetailHeader` и `MemberHoverCard`, иначе список и detail view будут расходиться по labels. ### Tooltip Tooltip examples: ```text bob Spawn accepted: yes Registered in config: yes Runtime: tmux pane alive, foreground command is zsh Runtime process: not found PID source: tmux pane Bootstrap: no member_briefing/check-in yet ``` ```text alice Spawn accepted: yes Runtime: verified process detected PID source: tmux child Bootstrap: waiting for member_briefing/check-in ``` ```text tom Spawn accepted: yes Runtime: not found after 90s Bootstrap: no check-in Last error: Teammate did not join within the launch grace window. ``` ### Launch banner Файл: `src/renderer/utils/teamProvisioningPresentation.ts` Generic: ```text 4 teammates still joining ``` Заменить на aggregate detail: ```text 4 teammates still joining - 3 shell-only, 1 waiting for bootstrap ``` Helper: ```ts function summarizePendingLaunchDiagnostics(params: { statuses: Record; runtimeEntries: Record | undefined; }): string | null { const counts = { shellOnly: 0, waitingBootstrap: 0, candidate: 0, permission: 0, noRuntime: 0, }; for (const [memberName, status] of Object.entries(params.statuses)) { if (status.launchState === 'confirmed_alive' || status.launchState === 'failed_to_start') { continue; } const runtimeEntry = params.runtimeEntries?.[memberName]; if (status.launchState === 'runtime_pending_permission') counts.permission += 1; else if (runtimeEntry?.livenessKind === 'shell_only') counts.shellOnly += 1; else if (runtimeEntry?.livenessKind === 'runtime_process') counts.waitingBootstrap += 1; else if (runtimeEntry?.livenessKind === 'runtime_process_candidate') counts.candidate += 1; else counts.noRuntime += 1; } const parts = [ counts.shellOnly ? `${counts.shellOnly} shell-only` : '', counts.waitingBootstrap ? `${counts.waitingBootstrap} waiting for bootstrap` : '', counts.candidate ? `${counts.candidate} process candidates` : '', counts.permission ? `${counts.permission} awaiting permission` : '', counts.noRuntime ? `${counts.noRuntime} no runtime found` : '', ].filter(Boolean); return parts.length > 0 ? parts.join(', ') : null; } ``` Сейчас `buildTeamProvisioningPresentation()` принимает только spawn statuses/snapshot, не runtime entries. Есть три варианта: 1. Добавить `runtimeSnapshot?: TeamAgentRuntimeSnapshot` в `buildTeamProvisioningPresentation()`. 🎯 8 🛡️ 8 🧠 5 Примерно 80-130 строк. 2. Дублировать aggregate diagnostic counts в `MemberSpawnStatusesSnapshot.summary`. 🎯 9 🛡️ 9 🧠 6 Примерно 120-190 строк. 3. Использовать только `progress.message`. 🎯 6 🛡️ 5 🧠 3 Примерно 30-60 строк. Рекомендую 2: backend уже лучше знает truth model и может атомарно отдать `shellOnlyCount`, `runtimeProcessPendingCount`, `candidateCount`, `noRuntimeCount`. UI тогда не зависит от race между 2.5 sec spawn polling и 5 sec runtime polling. Расширить summary: ```ts export interface PersistedTeamLaunchSummary { confirmedCount: number; pendingCount: number; failedCount: number; // Compatibility aggregate only. Do not use as process evidence in UI. runtimeAlivePendingCount: number; shellOnlyPendingCount?: number; runtimeProcessPendingCount?: number; runtimeCandidatePendingCount?: number; noRuntimePendingCount?: number; permissionPendingCount?: number; } ``` ### Stepper semantics Файл: `src/renderer/components/team/provisioningSteps.ts` The current stepper uses: - `heartbeatConfirmedCount` - `processOnlyAliveCount` - `pendingSpawnCount` - `failedSpawnCount` After strict liveness, `processOnlyAliveCount` must mean **strong runtime process only**. It must not include: - `shell_only` - `runtime_process_candidate` - `registered_only` - `stale_metadata` - `permission_blocked` Mapping: ```ts if (entry.launchState === 'runtime_pending_bootstrap') { if (entry.runtimeAlive === true && entry.livenessKind === 'runtime_process') { processOnlyAliveCount += 1; } else { pendingSpawnCount += 1; } } ``` Why this matters: the screenshot problem is exactly the UI being stuck on "Members joining". Shell-only should remain in joining until it fails, while verified process can move toward finalizing but still show `waiting for bootstrap`. ### Copy diagnostics Добавить в launch details или member tooltip маленькое действие `Copy diagnostics`. Payload: ```ts interface MemberLaunchDiagnosticsPayload { teamName: string; memberName: string; launchState?: MemberLaunchState; spawnStatus?: MemberSpawnStatus; livenessKind?: TeamAgentRuntimeLivenessKind; livenessSource?: MemberSpawnLivenessSource; pid?: number; pidSource?: TeamAgentRuntimePidSource; paneId?: string; panePid?: number; paneCurrentCommand?: string; processCommand?: string; runtimeDiagnostic?: string; diagnostics?: string[]; updatedAt?: string; } ``` Это поможет быстро понять проблему на скрине друга без доступа к его машине. ## Файлы для изменения Backend/shared: - `src/shared/types/team.ts` - добавить liveness/pid source типы; - расширить `TeamAgentRuntimeEntry`; - добавить компактные diagnostic fields в `MemberSpawnStatusEntry`. - добавить bounded `TeamLaunchDiagnosticItem` и `TeamProvisioningProgress.launchDiagnostics`. - `src/main/services/team/TeamRuntimeLivenessResolver.ts` - вынести pure liveness classification; - принимать tmux/process/OpenCode/persisted facts; - возвращать strong/weak classification и sanitized diagnostics. - `src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts` - добавить `listPaneRuntimeInfo()`; - добавить `listRuntimeProcesses()` или equivalent; - оставить `listPanePids()` совместимым wrapper. - `src/features/tmux-installer/main/composition/runtimeSupport.ts` - экспортировать `listTmuxPaneRuntimeInfoForCurrentPlatform()`; - экспортировать process table helper, если он живет в tmux runtime executor. - `src/main/services/team/TeamProvisioningService.ts` - расширить `LiveTeamAgentRuntimeMetadata`; - parse sanitized runtime tool `metadata`; - добавить strict evidence helpers; - использовать `TeamRuntimeLivenessResolver`; - обновить `updateProgress()` extras для `launchDiagnostics`; - переписать tmux/process resolution; - убрать strong `online/process` shortcut из `already_running`; - исправить `attachLiveRuntimeMetadataToStatuses()`; - исправить `reevaluateMemberLaunchStatus()`; - invalidate runtime caches на check-in/heartbeat/restart/stop; - прокинуть diagnostics в `getTeamAgentRuntimeSnapshot()`. - `src/main/services/team/TeamLaunchStateEvaluator.ts` - нормализовать persisted liveness diagnostic fields; - считать optional diagnostic counts в summary; - не превращать stale persisted `runtimeAlive` в live proof. - `src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts` - не считать `created` bridge member strong alive без `runtimePid`; - сохранить `runtimePid` и `sessionId` в persisted launch state. - `src/main/services/team/runtime/TeamRuntimeAdapter.ts` - расширить `TeamRuntimeMemberLaunchEvidence` полями `livenessKind`, `pidSource`, `runtimeDiagnostic`; - сохранить backward compatibility для существующих adapters. - `src/main/services/team/progressPayload.ts` - добавить `boundLaunchDiagnostics()` и не расширять raw log tails. - `src/shared/types/api.ts` - проверить, что existing `getMemberSpawnStatuses()` и `getTeamAgentRuntime()` contracts не требуют нового channel. - `src/preload/index.ts` - оставить существующие IPC methods, убедиться typecheck проходит с optional fields. - `src/renderer/api/httpClient.ts` - browser fallback должен оставаться valid при отсутствующих diagnostic fields. - `src/renderer/store/slices/teamSlice.ts` - обновить `areTeamAgentRuntimeEntriesEqual()`; - обновить `areMemberSpawnStatusEntriesEqual()`; - обновить `areLaunchSummaryCountsEqual()`; - убедиться, что runtime diagnostic changes не suppress-ятся. - `src/renderer/store/index.ts` - на `member-spawn` event обновлять и spawn statuses, и runtime snapshot. Renderer: - `src/renderer/utils/memberHelpers.ts` - добавить visual states и labels. - `src/renderer/utils/memberRuntimeSummary.ts` - memory summary должен учитывать `pidSource`. - `src/renderer/components/team/members/MemberList.tsx` - передать `runtimeEntry` и `spawnEntry` в presentation/member card layer. - `src/renderer/components/team/members/MemberCard.tsx` - badge + tooltip + copy diagnostics. - `src/renderer/components/team/members/MemberDetailHeader.tsx` - использовать тот же launch presentation contract, что и card. - `src/renderer/components/team/members/MemberHoverCard.tsx` - не отставать от list/card labels. - `src/renderer/utils/teamProvisioningPresentation.ts` - aggregate launch diagnostics. - `src/renderer/components/team/provisioningSteps.ts` - `processOnlyAliveCount` считать только для strong runtime process. - `src/renderer/components/team/ProvisioningProgressBlock.tsx` - добавить компактный Diagnostics disclosure для `launchDiagnostics`. ## Tests Backend: - `TeamRuntimeLivenessResolver.test.ts` - tmux foreground shell + no child -> `shell_only`; - verified process row by `--team-name` + `--agent-id` -> `runtime_process`; - non-shell descendant without identity -> `runtime_process_candidate`; - persisted PID without current process identity -> `stale_metadata`; - process command secrets are redacted in diagnostics; - provider failure diagnostic does not produce strong alive. - `TeamProvisioningService.test.ts` - tmux shell-only pane не ставит `runtimeAlive`; - shell-only после 90 секунд становится `failed_to_start`; - stale persisted `tmuxPaneId` не self-clear-ит failure; - verified process by `--team-name` + `--agent-id` ставит `runtimeAlive`; - runtime process candidate не считается strong alive; - OpenCode `created` без `runtimePid` не ставит `runtimeAlive`; - OpenCode `created` с `runtimePid` ставит `runtimeAlive`; - OpenCode `sessionId` без `runtimePid` становится `runtime_process_candidate`, а не strong alive; - `runtime_bootstrap_checkin` сохраняет `runtimeSessionId`, `livenessKind: "confirmed_bootstrap"`; - stale runtime heartbeat от old `runId` rejected и не меняет launch state; - runtime metadata PID без process identity не становится strong alive; - `already_running` + shell-only не ставит `runtimeAlive`; - permission blocked остается pending permission, не hard fail. - `TmuxPlatformCommandExecutor.test.ts` - `listPaneRuntimeInfo()` парсит `pane_current_command`; - `listPanePids()` остается совместимым pane-existence helper; - process table parser поддерживает `pid`, `ppid`, `command`; - WSL branch не использует host process table. Renderer: - `memberHelpers.test.ts` - `shell_only` -> `shell only`; - `runtime_process` + pending bootstrap -> `waiting for bootstrap`; - `runtime_process_candidate` -> `process candidate`; - permission state не затирается runtime diagnostics. - `memberRuntimeSummary.test.ts` - `2 MB` с `pidSource=tmux_pane` получает tooltip/source `tmux pane shell`; - runtime child показывает обычный runtime memory. - `teamSlice.test.ts` - изменение `livenessKind` обновляет `teamAgentRuntimeByTeam`; - изменение `runtimeDiagnostic` обновляет `teamAgentRuntimeByTeam`. - изменение spawn `livenessKind/runtimeDiagnostic` обновляет `memberSpawnStatusesByTeam`; - изменение optional summary diagnostic counts обновляет presentation. - `member-spawn` event schedules both spawn status refresh and runtime snapshot refresh. - `httpClient.test.ts` - browser fallback `getTeamAgentRuntime()` remains valid without diagnostic fields; - browser fallback `getMemberSpawnStatuses()` remains valid without summary diagnostic counts. - `teamProvisioningPresentation.test.ts` - banner показывает `3 shell-only, 1 waiting for bootstrap`; - pending permission получает отдельный count. - `provisioningSteps.test.ts` - `shell_only` не увеличивает `processOnlyAliveCount`; - `runtime_process_candidate` не увеличивает `processOnlyAliveCount`; - `runtime_process` увеличивает `processOnlyAliveCount`. - `ProvisioningProgressBlock.test.tsx` - renders bounded `launchDiagnostics`; - does not require opening CLI logs to see `shell only`; - long process command is truncated/sanitized. ## Phases ### Phase 0 - Diagnostics without behavior change 🎯 10 🛡️ 10 🧠 4 Примерно 180-260 строк. Добавить новые optional fields и заполнить `livenessKind`, `pidSource`, `paneCurrentCommand`, `diagnostics`, но пока не менять timeout behavior. Цель: увидеть на реальном launch, что именно определяется у друга: shell-only, process candidate, stale metadata или OpenCode bridge claim. Add: - `TeamRuntimeLivenessResolver` pure tests; - process table/tmux providers; - strict-only runtime evidence flow without a runtime-mode switch. Verification: ```bash pnpm typecheck pnpm exec vitest run test/main/features/tmux-installer test/main/services/team/TeamProvisioningService.test.ts ``` ### Phase 1 - Strict strong evidence 🎯 9 🛡️ 9 🧠 7 Примерно 220-320 строк. Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence only. Shell/pane/candidate больше не выставляют `runtimeAlive`. Verification: ```bash pnpm exec vitest run test/main/services/team/TeamProvisioningService.test.ts ``` ### Phase 2 - Timeout and self-heal hardening 🎯 9 🛡️ 9 🧠 6 Примерно 120-180 строк. Исправить `reevaluateMemberLaunchStatus()`: - shell-only/no-runtime/stale -> fail after 90s; - permission -> stay pending permission; - candidate -> warning, fail after 5 min; - verified runtime -> warning, no false hard fail at 90s; - auto-clear failure только по strong evidence. ### Phase 3 - UI visibility 🎯 9 🛡️ 8 🧠 6 Примерно 220-320 строк. Добавить: - labels `shell only`, `waiting for bootstrap`, `process candidate`; - tooltip; - aggregate banner detail; - copy diagnostics. ### Phase 4 - Real launch validation 🎯 8 🛡️ 9 🧠 6 Примерно 100-180 строк тестовых fixtures/scripts. Manual checks: ```bash tmux list-panes -a -F '#{pane_id} #{pane_pid} #{pane_current_command}' ps -ax -o pid=,ppid=,command= | rg '||claude|codex|opencode' ``` Scenarios: 1. Успешный Anthropic tmux launch. 2. Shell-only pane. 3. Missing MCP/member_briefing. 4. Permission pending. 5. OpenCode bridge member without `runtimePid`. 6. OpenCode bridge member with `runtimePid`. 7. Restart member while old pane exists. ## Acceptance criteria 1. Tmux pane жив, foreground command `zsh/bash/sh`, runtime child не найден: - `TeamAgentRuntimeEntry.alive === false` - `livenessKind === "shell_only"` - `pidSource === "tmux_pane"` - UI показывает `shell only` - после 90 секунд member становится `failed_to_start` 2. Найден process с `--team-name --agent-id `: - `TeamAgentRuntimeEntry.alive === true` - `livenessKind === "runtime_process"` - `MemberSpawnStatusEntry.runtimeAlive === true` - UI показывает `waiting for bootstrap`, если bootstrap еще не пришел 3. Member сделал check-in: - `bootstrapConfirmed === true` - `livenessKind === "confirmed_bootstrap"` - `launchState === "confirmed_alive"` - UI показывает `ready` 4. Persisted metadata есть, process не найден: - не self-clear failure; - не `runtimeAlive`; - UI показывает `stale runtime` или `registered`. 5. OpenCode bridge вернул member без `runtimePid`: - `agentToolAccepted === true`; - `runtimeAlive === false`; - UI показывает pending/bridge diagnostics, не `online`. 6. `2.0 MB` больше не выглядит как полноценный runtime: - tooltip объясняет `RSS source: tmux pane shell`; - launch badge показывает `shell only`. 7. Launch details объясняет stuck state без открытия logs: - `launchDiagnostics` содержит bounded rows; - UI показывает хотя бы `shell only`, `waiting for bootstrap`, `no runtime found`; - `cliLogsTail` и `assistantOutput` остаются bounded. 8. Store suppression не скрывает диагностику: - изменение `livenessKind` меняет renderer state; - изменение summary diagnostic counts меняет presentation; - `member-spawn` event refreshes runtime snapshot. 9. Rollout безопасен: - strict behavior включен по умолчанию; - diagnostics UI остается доступным без отдельного mode flag; - rollback требует явного code revert или отдельного follow-up setting. 10. Provider failures не создают ложный ready: - process table failure дает `process_table_unavailable`; - tmux/process provider failure не self-clear-ит failure; - command diagnostics sanitized and truncated. ## Main risks ### False negative для реального runtime Если реальный teammate не содержит `--team-name`/`--agent-id` в command, strict model может понизить его до candidate. Mitigation: - Phase 0 сначала собирает diagnostics без behavior change. - Candidate не fail-ится за 90 секунд. - Allowlist runtime command markers добавлять только после реальных данных. ### Windows/WSL process tree Host-side process table не увидит Linux tmux descendants. Mitigation: - process table должен жить рядом с tmux executor; - Windows branch должен запускать `ps` внутри WSL distro. ### OpenCode shared host Один OpenCode host PID может обслуживать несколько members. Mitigation: - `runtimePid` хранить как `metricsPid`, если это shared host; - `restartable=false`, если PID не member-owned; - UI label `shared OpenCode host`, не "member runtime". ### UI overload Слишком много деталей в карточке сделают интерфейс шумным. Mitigation: - короткий badge в карточке; - детали в tooltip; - aggregate counts в banner; - полный JSON только через copy diagnostics. ### Process command privacy `ps` command can include cwd, file paths, API keys or tokens. Mitigation: - identity matching uses raw command only inside main process memory; - UI/logs/copy diagnostics receive sanitized command only; - redact common secret flags; - truncate command strings to 500 chars; - do not include raw runtime tool metadata. ### Process table overhead Reading `ps` per member would be wasteful and flaky on large systems. Mitigation: - read process table once per runtime snapshot; - keep existing 2 sec backend cache TTL; - do not call `pidusage` for weak shell-only rows unless UI needs memory display; - cap diagnostics to 20 progress rows. ## Minimal safe patch order 1. Добавить типы и optional fields. 2. Добавить sanitized runtime tool metadata parser. 3. Добавить tmux `listPaneRuntimeInfo()` и сохранить wrapper `listPanePids()`. 4. Добавить process table provider/parser с `ppid`. 5. Вынести `TeamRuntimeLivenessResolver`. 6. Заполнить `livenessKind`. 7. Написать backend tests на shell-only, verified runtime, stale event, metadata PID. 8. Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence. 9. Исправить `already_running` shortcut. 10. Переключить timeout/self-heal logic на strong evidence. 11. Исправить OpenCode bridge mapping. 12. Обновить persisted summary diagnostics и store equality. 13. Добавить `launchDiagnostics` в progress payload и UI disclosure. 14. Добавить renderer labels/tooltips/banner. 15. Добавить copy diagnostics. 16. Manual validation: создать команду, проверить pending names, runtime diagnostics и отсутствие false-ready shell-only процесса. ## Expected UX Before: ```text bob starting 2.0 MB jack starting 2.0 MB tom starting 2.0 MB ``` After: ```text bob shell only Anthropic · Opus 4.7 · 2.0 MB jack waiting for bootstrap Anthropic · Opus 4.7 · 418 MB tom spawn failed no runtime process after 90s ``` Launch banner: ```text 4 teammates still joining - 3 shell-only, 1 waiting for bootstrap ``` Tooltip for shell-only: ```text Spawn accepted: yes Registered in config: yes Runtime process: not found Tmux pane: alive Foreground command: zsh PID source: tmux pane Bootstrap: no member_briefing/check-in yet ```