diff --git a/crates/executors/src/executors/claude/native.rs b/crates/executors/src/executors/claude/native.rs index 5a3270a4f9..72a6cd1c57 100644 --- a/crates/executors/src/executors/claude/native.rs +++ b/crates/executors/src/executors/claude/native.rs @@ -31,6 +31,7 @@ pub struct NativeClaudeEnvelopeMetadata { pub timestamp: Option, pub version: Option, pub git_branch: Option, + pub entrypoint: Option, pub kind: String, pub leaf_uuid: Option, pub is_sidechain: bool, @@ -111,6 +112,7 @@ struct NativeClaudeWireEnvelope { timestamp: Option, version: Option, git_branch: Option, + entrypoint: Option, #[serde(default)] is_sidechain: bool, leaf_uuid: Option, @@ -143,6 +145,7 @@ pub fn adapt_native_claude_line( timestamp: wire.timestamp, version: wire.version, git_branch: wire.git_branch, + entrypoint: wire.entrypoint, kind: kind.clone(), leaf_uuid: wire.leaf_uuid, is_sidechain: wire.is_sidechain, @@ -276,6 +279,23 @@ mod tests { assert_eq!(line.plain_user_text().as_deref(), Some("hello")); } + #[test] + fn parses_optional_entrypoint_on_bookkeeping_records() { + let with_entrypoint = adapt_native_claude_line( + r#"{"type":"attachment","entrypoint":"sdk-py"}"#, + "file-session", + ) + .unwrap(); + assert_eq!( + with_entrypoint.metadata().entrypoint.as_deref(), + Some("sdk-py") + ); + + let without_entrypoint = + adapt_native_claude_line(r#"{"type":"attachment"}"#, "file-session").unwrap(); + assert_eq!(without_entrypoint.metadata().entrypoint, None); + } + #[test] fn native_strategy_emits_plain_user_as_user_message() { let line = adapt_native_claude_line(&user_line(r#""hello""#), "sid").unwrap(); diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index b6dbc46cf2..3cef48528f 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -126,6 +126,7 @@ fn generate_types_content() -> String { services::services::claude_transcript_ingest::NativeFileImportHealth::decl(), services::services::claude_transcript_ingest::NativeIngestHealth::decl(), services::services::claude_transcript_ingest::NativeFeedSnapshot::decl(), + services::services::claude_transcript_ingest::CliSessionKind::decl(), services::services::claude_transcript_ingest::UnassignedCliSession::decl(), server::routes::native_transcripts::AssignNativeCliSessionRequest::decl(), relay_types::StartSpake2EnrollmentRequest::decl(), diff --git a/crates/services/src/services/claude_transcript_ingest.rs b/crates/services/src/services/claude_transcript_ingest.rs index 50156059df..b869b59b68 100644 --- a/crates/services/src/services/claude_transcript_ingest.rs +++ b/crates/services/src/services/claude_transcript_ingest.rs @@ -104,6 +104,35 @@ pub enum ClaudeTranscriptIngestError { NotQuarantined(String), } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +pub enum CliSessionKind { + /// A real, interactive human CLI session (entrypoint "cli", or anything + /// we cannot positively identify as a background subagent). + Main, + /// A programmatically spawned background agent (entrypoint "sdk-cli" / "sdk-py"). + Subagent, +} + +impl CliSessionKind { + /// Classify a session from its transcript `entrypoint`. + /// + /// FAIL OPEN TO MAIN: we hide a session from the default view ONLY when it + /// is positively identified as a background subagent. Every other case — + /// an unrecognized entrypoint, a value added by a future Claude version, a + /// missing field, or a transcript we failed to read — classifies as Main so + /// a real conversation is never hidden behind the agents toggle. Do NOT + /// rewrite this as a closed match on the known values; the default arm is + /// load-bearing. + pub fn from_entrypoint(entrypoint: Option<&str>) -> Self { + match entrypoint { + Some("sdk-cli") | Some("sdk-py") => Self::Subagent, + // Anything else (including None / unknown) -> visible Main. + _ => Self::Main, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct UnassignedCliSession { pub claude_session_id: String, @@ -112,6 +141,7 @@ pub struct UnassignedCliSession { pub file_name: String, pub mtime_ms: Option, pub first_prompt_snippet: Option, + pub kind: CliSessionKind, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -348,13 +378,15 @@ impl ClaudeTranscriptIngest { .into_iter() .map(|file| { let path = Path::new(&file.dir_path).join(&file.file_name); + let preview = read_session_preview(&path, &file.claude_session_id); UnassignedCliSession { claude_session_id: file.claude_session_id.clone(), cwd: cwd.to_string_lossy().into_owned(), dir_path: file.dir_path, file_name: file.file_name, mtime_ms: file.observed_mtime_ms, - first_prompt_snippet: first_prompt_snippet(&path, &file.claude_session_id), + first_prompt_snippet: preview.first_prompt_snippet, + kind: preview.kind, } }) .collect()) @@ -1147,24 +1179,45 @@ fn claude_project_slug(cwd: &Path) -> String { .collect() } -fn first_prompt_snippet(path: &Path, file_session_id: &str) -> Option { - let file = File::open(path).ok()?; - for line in BufReader::new(file).lines().take(50) { - let Ok(line) = line else { - continue; - }; - let Ok(adapted) = adapt_native_claude_line(&line, file_session_id) else { - continue; - }; - if let Some(prompt) = adapted.plain_user_text() { - let mut snippet = prompt.chars().take(160).collect::(); - if prompt.chars().count() > 160 { - snippet.push('…'); +struct SessionPreview { + first_prompt_snippet: Option, + kind: CliSessionKind, +} + +fn read_session_preview(path: &Path, file_session_id: &str) -> SessionPreview { + let mut snippet: Option = None; + let mut entrypoint: Option = None; + if let Ok(file) = File::open(path) { + for line in BufReader::new(file).lines().take(50) { + let Ok(line) = line else { + continue; + }; + let Ok(adapted) = adapt_native_claude_line(&line, file_session_id) else { + continue; + }; + if entrypoint.is_none() + && let Some(ep) = adapted.metadata().entrypoint.as_deref() + { + entrypoint = Some(ep.to_string()); + } + if snippet.is_none() + && let Some(prompt) = adapted.plain_user_text() + { + let mut s = prompt.chars().take(160).collect::(); + if prompt.chars().count() > 160 { + s.push('…'); + } + snippet = Some(s); + } + if snippet.is_some() && entrypoint.is_some() { + break; } - return Some(snippet); } } - None + SessionPreview { + first_prompt_snippet: snippet, + kind: CliSessionKind::from_entrypoint(entrypoint.as_deref()), + } } fn verify_last_line_hash( diff --git a/crates/services/src/services/claude_transcript_ingest/tests.rs b/crates/services/src/services/claude_transcript_ingest/tests.rs index 3ff0341c3d..cd586bb0d4 100644 --- a/crates/services/src/services/claude_transcript_ingest/tests.rs +++ b/crates/services/src/services/claude_transcript_ingest/tests.rs @@ -42,8 +42,8 @@ use tokio_util::sync::CancellationToken; use uuid::Uuid; use super::{ - ClaudeTranscriptIngest, ClaudeTranscriptIngestError, DirectoryContext, NativeFeedOrigin, - NativeFeedUpdate, claude_project_slug, first_prompt_snippet, + ClaudeTranscriptIngest, ClaudeTranscriptIngestError, CliSessionKind, DirectoryContext, + NativeFeedOrigin, NativeFeedUpdate, claude_project_slug, read_session_preview, }; use crate::services::cli_collab::{CliWriterProbe, ProbeReport, SidEvidence}; @@ -832,7 +832,28 @@ async fn paste_ack_matcher_excludes_executor_linked_native_records() { } #[test] -fn prompt_preview_skips_malformed_lines_within_its_scan_bound() { +fn cli_session_kind_classification_fails_open_to_main() { + assert_eq!( + CliSessionKind::from_entrypoint(Some("cli")), + CliSessionKind::Main + ); + assert_eq!(CliSessionKind::from_entrypoint(None), CliSessionKind::Main); + assert_eq!( + CliSessionKind::from_entrypoint(Some("totally-new-thing")), + CliSessionKind::Main + ); + assert_eq!( + CliSessionKind::from_entrypoint(Some("sdk-cli")), + CliSessionKind::Subagent + ); + assert_eq!( + CliSessionKind::from_entrypoint(Some("sdk-py")), + CliSessionKind::Subagent + ); +} + +#[test] +fn session_preview_skips_malformed_lines_within_its_scan_bound() { let temp = TempDir::new().unwrap(); let sid = "91919191-9191-4919-8919-919191919191"; let path = temp.path().join(format!("{sid}.jsonl")); @@ -850,8 +871,42 @@ fn prompt_preview_skips_malformed_lines_within_its_scan_bound() { ) .unwrap(); + let preview = read_session_preview(&path, sid); + assert_eq!( + preview.first_prompt_snippet.as_deref(), + Some("usable preview") + ); + assert_eq!(preview.kind, CliSessionKind::Main); +} + +#[test] +fn session_preview_reads_entrypoint_from_bookkeeping_before_prompt() { + let temp = TempDir::new().unwrap(); + let sid = "92929292-9292-4929-8929-929292929292"; + let path = temp.path().join(format!("{sid}.jsonl")); + let attachment = serde_json::json!({ + "type": "attachment", + "sessionId": sid, + "entrypoint": "sdk-py" + }); + fs::write( + &path, + format!( + "{attachment}\n{}", + native_user_record( + sid, + "preview-user", + "usable preview", + "2026-07-20T20:00:00Z" + ) + ), + ) + .unwrap(); + + let preview = read_session_preview(&path, sid); + assert_eq!(preview.kind, CliSessionKind::Subagent); assert_eq!( - first_prompt_snippet(&path, sid).as_deref(), + preview.first_prompt_snippet.as_deref(), Some("usable preview") ); } diff --git a/packages/web-core/src/features/workspace-chat/model/partitionCliSessionsByKind.test.ts b/packages/web-core/src/features/workspace-chat/model/partitionCliSessionsByKind.test.ts new file mode 100644 index 0000000000..fe7c823891 --- /dev/null +++ b/packages/web-core/src/features/workspace-chat/model/partitionCliSessionsByKind.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import type { CliSessionKind, UnassignedCliSession } from 'shared/types'; + +import { partitionCliSessionsByKind } from './partitionCliSessionsByKind'; + +function session( + claudeSessionId: string, + kind: CliSessionKind +): UnassignedCliSession { + return { + claude_session_id: claudeSessionId, + cwd: '/workspace', + dir_path: '/transcripts', + file_name: `${claudeSessionId}.jsonl`, + mtime_ms: null, + first_prompt_snippet: null, + kind, + }; +} + +describe('partitionCliSessionsByKind', () => { + it('splits mixed sessions while preserving order', () => { + const mainOne = session('main-one', 'main'); + const agentOne = session('agent-one', 'subagent'); + const mainTwo = session('main-two', 'main'); + const agentTwo = session('agent-two', 'subagent'); + + expect( + partitionCliSessionsByKind([mainOne, agentOne, mainTwo, agentTwo]) + ).toEqual({ + main: [mainOne, mainTwo], + agents: [agentOne, agentTwo], + }); + }); + + it('keeps all main sessions visible', () => { + const sessions = [session('main-one', 'main'), session('main-two', 'main')]; + + expect(partitionCliSessionsByKind(sessions)).toEqual({ + main: sessions, + agents: [], + }); + }); + + it('puts all positively identified subagents in agents', () => { + const sessions = [ + session('agent-one', 'subagent'), + session('agent-two', 'subagent'), + ]; + + expect(partitionCliSessionsByKind(sessions)).toEqual({ + main: [], + agents: sessions, + }); + }); + + it('returns empty partitions for an empty list', () => { + expect(partitionCliSessionsByKind([])).toEqual({ + main: [], + agents: [], + }); + }); + + it('fails open to main for unexpected or absent kinds', () => { + const unexpected = { + ...session('unexpected', 'main'), + kind: 'future-kind', + } as unknown as UnassignedCliSession; + const absent = { + ...session('absent', 'main'), + kind: undefined, + } as unknown as UnassignedCliSession; + + expect(partitionCliSessionsByKind([unexpected, absent])).toEqual({ + main: [unexpected, absent], + agents: [], + }); + }); +}); diff --git a/packages/web-core/src/features/workspace-chat/model/partitionCliSessionsByKind.ts b/packages/web-core/src/features/workspace-chat/model/partitionCliSessionsByKind.ts new file mode 100644 index 0000000000..08e9d397d8 --- /dev/null +++ b/packages/web-core/src/features/workspace-chat/model/partitionCliSessionsByKind.ts @@ -0,0 +1,28 @@ +import type { UnassignedCliSession } from 'shared/types'; + +export interface PartitionedCliSessions { + main: UnassignedCliSession[]; + agents: UnassignedCliSession[]; +} + +/** + * Split unassigned CLI sessions into visible "main" conversations and hidden + * background "agents". FAIL OPEN TO MAIN: a session is treated as a background + * agent ONLY when it is positively kind === 'subagent'. Anything else — an + * unrecognized/absent kind from an older payload — stays a visible main + * conversation. This mirrors the backend invariant; never hide a real chat. + */ +export function partitionCliSessionsByKind( + sessions: UnassignedCliSession[] +): PartitionedCliSessions { + const main: UnassignedCliSession[] = []; + const agents: UnassignedCliSession[] = []; + for (const session of sessions) { + if (session.kind === 'subagent') { + agents.push(session); + } else { + main.push(session); + } + } + return { main, agents }; +} diff --git a/packages/web-core/src/features/workspace-chat/ui/UnassignedCliSessions.tsx b/packages/web-core/src/features/workspace-chat/ui/UnassignedCliSessions.tsx index 129df2e0f3..6d4739de16 100644 --- a/packages/web-core/src/features/workspace-chat/ui/UnassignedCliSessions.tsx +++ b/packages/web-core/src/features/workspace-chat/ui/UnassignedCliSessions.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react'; -import { TerminalWindowIcon } from '@phosphor-icons/react'; +import { CaretDownIcon, TerminalWindowIcon } from '@phosphor-icons/react'; import { useTranslation } from 'react-i18next'; import type { UnassignedCliSession } from 'shared/types'; +import { cn } from '@/shared/lib/utils'; import { Alert, AlertDescription } from '@vibe/ui/components/Alert'; import { Badge } from '@vibe/ui/components/Badge'; import { Button } from '@vibe/ui/components/Button'; @@ -13,6 +14,7 @@ import { DialogHeader, DialogTitle, } from '@vibe/ui/components/Dialog'; +import { partitionCliSessionsByKind } from '../model/partitionCliSessionsByKind'; interface UnassignedCliSessionsProps { sessions: UnassignedCliSession[]; @@ -29,13 +31,71 @@ export function UnassignedCliSessions({ }: UnassignedCliSessionsProps) { const { t } = useTranslation('common'); const [open, setOpen] = useState(false); + const [showAgents, setShowAgents] = useState(false); + const { main, agents } = partitionCliSessionsByKind(sessions); useEffect(() => { if (sessions.length === 0) setOpen(false); }, [sessions.length]); + useEffect(() => { + if (!open) setShowAgents(false); + }, [open]); + if (sessions.length === 0) return null; + const renderSession = (session: UnassignedCliSession) => { + const isAssigning = assigningSessionId === session.claude_session_id; + return ( +
+
+
+ + {t( + session.kind === 'subagent' + ? 'conversation.quarantine.agentBadge' + : 'conversation.quarantine.mainBadge' + )} + +

+ {session.first_prompt_snippet ?? + t('conversation.quarantine.noPreview')} +

+

+ {session.cwd} +

+ + {session.claude_session_id} + +
+ +
+
+ ); + }; + return ( - - - ); - })} + diff --git a/packages/web-core/src/i18n/locales/en/common.json b/packages/web-core/src/i18n/locales/en/common.json index efabc6f4a5..f1198c3daa 100644 --- a/packages/web-core/src/i18n/locales/en/common.json +++ b/packages/web-core/src/i18n/locales/en/common.json @@ -89,7 +89,13 @@ "noPreview": "No prompt preview available", "assign": "Assign here", "assigning": "Assigning…", - "error": "This conversation could not be assigned. Try again." + "error": "This conversation could not be assigned. Try again.", + "mainBadge": "Main", + "agentBadge": "Agent", + "showBackgroundAgents": "Show {{count}} background agent", + "showBackgroundAgents_other": "Show {{count}} background agents", + "hideBackgroundAgents": "Hide {{count}} background agent", + "hideBackgroundAgents_other": "Hide {{count}} background agents" }, "toolSummary": { "read": "Read {{path}}", diff --git a/packages/web-core/src/i18n/locales/es/common.json b/packages/web-core/src/i18n/locales/es/common.json index 9ce69d3c9a..f04f74c00f 100644 --- a/packages/web-core/src/i18n/locales/es/common.json +++ b/packages/web-core/src/i18n/locales/es/common.json @@ -97,7 +97,13 @@ "noPreview": "No hay vista previa del mensaje", "assign": "Asignar aquí", "assigning": "Asignando…", - "error": "No se pudo asignar esta conversación. Inténtalo de nuevo." + "error": "No se pudo asignar esta conversación. Inténtalo de nuevo.", + "mainBadge": "Principal", + "agentBadge": "Agente", + "showBackgroundAgents": "Mostrar {{count}} agente en segundo plano", + "showBackgroundAgents_other": "Mostrar {{count}} agentes en segundo plano", + "hideBackgroundAgents": "Ocultar {{count}} agente en segundo plano", + "hideBackgroundAgents_other": "Ocultar {{count}} agentes en segundo plano" }, "toolSummary": { "read": "Leyó {{path}}", diff --git a/packages/web-core/src/i18n/locales/fr/common.json b/packages/web-core/src/i18n/locales/fr/common.json index 872d64d810..5d0edf3aa0 100644 --- a/packages/web-core/src/i18n/locales/fr/common.json +++ b/packages/web-core/src/i18n/locales/fr/common.json @@ -84,7 +84,13 @@ "noPreview": "Aucun aperçu du message disponible", "assign": "Attribuer ici", "assigning": "Attribution…", - "error": "Cette conversation n'a pas pu être attribuée. Réessayez." + "error": "Cette conversation n'a pas pu être attribuée. Réessayez.", + "mainBadge": "Principale", + "agentBadge": "Agent", + "showBackgroundAgents": "Afficher {{count}} agent en arrière-plan", + "showBackgroundAgents_other": "Afficher {{count}} agents en arrière-plan", + "hideBackgroundAgents": "Masquer {{count}} agent en arrière-plan", + "hideBackgroundAgents_other": "Masquer {{count}} agents en arrière-plan" }, "toolSummary": { "read": "Lu {{path}}", diff --git a/packages/web-core/src/i18n/locales/ja/common.json b/packages/web-core/src/i18n/locales/ja/common.json index a6d19d0a56..9fb1313b62 100644 --- a/packages/web-core/src/i18n/locales/ja/common.json +++ b/packages/web-core/src/i18n/locales/ja/common.json @@ -97,7 +97,13 @@ "noPreview": "プロンプトのプレビューはありません", "assign": "ここに割り当て", "assigning": "割り当て中…", - "error": "この会話を割り当てられませんでした。もう一度お試しください。" + "error": "この会話を割り当てられませんでした。もう一度お試しください。", + "mainBadge": "メイン", + "agentBadge": "エージェント", + "showBackgroundAgents": "バックグラウンドエージェント {{count}} 件を表示", + "showBackgroundAgents_other": "バックグラウンドエージェント {{count}} 件を表示", + "hideBackgroundAgents": "バックグラウンドエージェント {{count}} 件を非表示", + "hideBackgroundAgents_other": "バックグラウンドエージェント {{count}} 件を非表示" }, "toolSummary": { "read": "{{path}} を読み込み", diff --git a/packages/web-core/src/i18n/locales/ko/common.json b/packages/web-core/src/i18n/locales/ko/common.json index 1452eb2319..a33e31fbb9 100644 --- a/packages/web-core/src/i18n/locales/ko/common.json +++ b/packages/web-core/src/i18n/locales/ko/common.json @@ -97,7 +97,13 @@ "noPreview": "프롬프트 미리보기가 없습니다", "assign": "여기에 할당", "assigning": "할당 중…", - "error": "이 대화를 할당할 수 없습니다. 다시 시도하세요." + "error": "이 대화를 할당할 수 없습니다. 다시 시도하세요.", + "mainBadge": "메인", + "agentBadge": "에이전트", + "showBackgroundAgents": "백그라운드 에이전트 {{count}}개 표시", + "showBackgroundAgents_other": "백그라운드 에이전트 {{count}}개 표시", + "hideBackgroundAgents": "백그라운드 에이전트 {{count}}개 숨기기", + "hideBackgroundAgents_other": "백그라운드 에이전트 {{count}}개 숨기기" }, "toolSummary": { "read": "{{path}} 읽기", diff --git a/packages/web-core/src/i18n/locales/zh-Hans/common.json b/packages/web-core/src/i18n/locales/zh-Hans/common.json index 5fc3ef1384..d12e4454d1 100644 --- a/packages/web-core/src/i18n/locales/zh-Hans/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hans/common.json @@ -84,7 +84,13 @@ "noPreview": "没有可用的提示预览", "assign": "分配到此处", "assigning": "正在分配…", - "error": "无法分配此对话,请重试。" + "error": "无法分配此对话,请重试。", + "mainBadge": "主", + "agentBadge": "代理", + "showBackgroundAgents": "显示 {{count}} 个后台代理", + "showBackgroundAgents_other": "显示 {{count}} 个后台代理", + "hideBackgroundAgents": "隐藏 {{count}} 个后台代理", + "hideBackgroundAgents_other": "隐藏 {{count}} 个后台代理" }, "toolSummary": { "read": "读取 {{path}}", diff --git a/packages/web-core/src/i18n/locales/zh-Hant/common.json b/packages/web-core/src/i18n/locales/zh-Hant/common.json index 933f4a8449..e699141bde 100644 --- a/packages/web-core/src/i18n/locales/zh-Hant/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hant/common.json @@ -84,7 +84,13 @@ "noPreview": "沒有可用的提示預覽", "assign": "分配到此處", "assigning": "正在分配…", - "error": "無法分配此對話,請再試一次。" + "error": "無法分配此對話,請再試一次。", + "mainBadge": "主", + "agentBadge": "代理", + "showBackgroundAgents": "顯示 {{count}} 個背景代理", + "showBackgroundAgents_other": "顯示 {{count}} 個背景代理", + "hideBackgroundAgents": "隱藏 {{count}} 個背景代理", + "hideBackgroundAgents_other": "隱藏 {{count}} 個背景代理" }, "toolSummary": { "read": "讀取 {{path}}", diff --git a/shared/types.ts b/shared/types.ts index a9b32e2820..18708b7222 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -382,7 +382,9 @@ export type NativeIngestHealth = { unknown_kinds: bigint, rescans: bigint, quara export type NativeFeedSnapshot = { revision: bigint, seq: bigint, entries: Array, forks: Array, health: NativeIngestHealth, }; -export type UnassignedCliSession = { claude_session_id: string, cwd: string, dir_path: string, file_name: string, mtime_ms: bigint | null, first_prompt_snippet: string | null, }; +export type CliSessionKind = "main" | "subagent"; + +export type UnassignedCliSession = { claude_session_id: string, cwd: string, dir_path: string, file_name: string, mtime_ms: bigint | null, first_prompt_snippet: string | null, kind: CliSessionKind, }; export type AssignNativeCliSessionRequest = { claude_session_id: string, session_id: string, };