Skip to content
Merged
20 changes: 20 additions & 0 deletions crates/executors/src/executors/claude/native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ pub struct NativeClaudeEnvelopeMetadata {
pub timestamp: Option<String>,
pub version: Option<String>,
pub git_branch: Option<String>,
pub entrypoint: Option<String>,
pub kind: String,
pub leaf_uuid: Option<String>,
pub is_sidechain: bool,
Expand Down Expand Up @@ -111,6 +112,7 @@ struct NativeClaudeWireEnvelope {
timestamp: Option<String>,
version: Option<String>,
git_branch: Option<String>,
entrypoint: Option<String>,
#[serde(default)]
is_sidechain: bool,
leaf_uuid: Option<String>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions crates/server/src/bin/generate_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
85 changes: 69 additions & 16 deletions crates/services/src/services/claude_transcript_ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -112,6 +141,7 @@ pub struct UnassignedCliSession {
pub file_name: String,
pub mtime_ms: Option<i64>,
pub first_prompt_snippet: Option<String>,
pub kind: CliSessionKind,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -1147,24 +1179,45 @@ fn claude_project_slug(cwd: &Path) -> String {
.collect()
}

fn first_prompt_snippet(path: &Path, file_session_id: &str) -> Option<String> {
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::<String>();
if prompt.chars().count() > 160 {
snippet.push('…');
struct SessionPreview {
first_prompt_snippet: Option<String>,
kind: CliSessionKind,
}

fn read_session_preview(path: &Path, file_session_id: &str) -> SessionPreview {
let mut snippet: Option<String> = None;
let mut entrypoint: Option<String> = 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::<String>();
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(
Expand Down
63 changes: 59 additions & 4 deletions crates/services/src/services/claude_transcript_ingest/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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"));
Expand All @@ -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")
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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: [],
});
});
});
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading
Loading