@@ -903,6 +903,41 @@ impl Store {
903903 Ok ( ( ) )
904904 }
905905
906+ /// Decide whether a stale extension row (one absent from the latest scan)
907+ /// should be pruned from the store.
908+ ///
909+ /// Kept (returns false):
910+ /// - disabled rows — intentionally absent from scan results;
911+ /// - CLI extensions with install_meta — their binary can transiently fail
912+ /// detection on startup, so one missing scan isn't proof of removal;
913+ /// - file-backed install_meta rows whose `source_path` still exists on disk
914+ /// (or is unknown) — a momentary scan gap, not a real uninstall.
915+ ///
916+ /// Pruned (returns true): everything else that is enabled and gone,
917+ /// including skill and plugin rows with install_meta whose files the user
918+ /// deleted (e.g. `rm -rf ~/.claude`) — otherwise they linger forever as
919+ /// ghost rows. Scanned MCP/hook entries carry no install_meta, so they take
920+ /// the normal no-meta prune path rather than this `has_install_meta` branch.
921+ fn stale_row_should_prune (
922+ enabled : bool ,
923+ has_install_meta : bool ,
924+ kind : & str ,
925+ source_path : Option < & str > ,
926+ ) -> bool {
927+ if !enabled {
928+ return false ;
929+ }
930+ if has_install_meta {
931+ if kind == ExtensionKind :: Cli . as_str ( ) {
932+ return false ;
933+ }
934+ if source_path. is_none_or ( |p| Path :: new ( p) . exists ( ) ) {
935+ return false ;
936+ }
937+ }
938+ true
939+ }
940+
906941 /// Sync all scanned extensions in a single transaction.
907942 /// Upserts every extension and removes stale entries that no longer exist on disk.
908943 /// Much faster than individual insert_extension calls (one fsync instead of N).
@@ -953,24 +988,38 @@ impl Store {
953988 [ ] ,
954989 ) ?;
955990
956- // Remove stale extensions no longer on disk — but keep:
957- // - Disabled ones (intentionally absent from scan results)
958- // - Extensions with install_meta (user explicitly installed, e.g. CLI via install_cli)
959- // These may temporarily disappear from scan if binary detection fails on startup.
991+ // Remove stale extensions no longer on disk. The keep/prune decision
992+ // lives in `stale_row_should_prune`: disabled rows and CLI binaries with
993+ // install_meta are always kept; file-backed install_meta rows are kept
994+ // only while their source_path still exists, so a manual delete (e.g.
995+ // `rm -rf ~/.claude`) no longer leaves ghost rows behind.
960996 let scanned_ids: std:: collections:: HashSet < & str > =
961997 extensions. iter ( ) . map ( |e| e. id . as_str ( ) ) . collect ( ) ;
962- let stale_ids: Vec < ( String , bool , bool ) > = {
998+ let stale_ids: Vec < ( String , bool , bool , String , Option < String > ) > = {
963999 let mut stmt = tx. prepare (
964- "SELECT id, enabled, (install_type IS NOT NULL) as has_meta FROM extensions"
1000+ "SELECT id, enabled, (install_type IS NOT NULL) as has_meta, kind, source_path FROM extensions"
9651001 ) ?;
9661002 stmt. query_map ( [ ] , |row| {
967- Ok ( ( row. get :: < _ , String > ( 0 ) ?, row. get :: < _ , bool > ( 1 ) ?, row. get :: < _ , bool > ( 2 ) ?) )
1003+ Ok ( (
1004+ row. get :: < _ , String > ( 0 ) ?,
1005+ row. get :: < _ , bool > ( 1 ) ?,
1006+ row. get :: < _ , bool > ( 2 ) ?,
1007+ row. get :: < _ , String > ( 3 ) ?,
1008+ row. get :: < _ , Option < String > > ( 4 ) ?,
1009+ ) )
9681010 } ) ?
9691011 . filter_map ( |r| r. map_err ( |e| eprintln ! ( "[hk] row error: {e}" ) ) . ok ( ) )
9701012 . collect ( )
9711013 } ;
972- for ( id, enabled, has_install_meta) in & stale_ids {
973- if !scanned_ids. contains ( id. as_str ( ) ) && * enabled && !has_install_meta {
1014+ for ( id, enabled, has_install_meta, kind, source_path) in & stale_ids {
1015+ if !scanned_ids. contains ( id. as_str ( ) )
1016+ && Self :: stale_row_should_prune (
1017+ * enabled,
1018+ * has_install_meta,
1019+ kind,
1020+ source_path. as_deref ( ) ,
1021+ )
1022+ {
9741023 tx. execute ( "DELETE FROM extensions WHERE id = ?1" , params ! [ id] ) ?;
9751024 }
9761025 }
@@ -1044,25 +1093,41 @@ impl Store {
10441093 Self :: sync_extension_agents ( & tx, & ext. id , & ext. agents ) ?;
10451094 }
10461095
1047- // Remove stale extensions for THIS agent only — keep disabled ones
1048- // and extensions with install_meta (user-installed, may temporarily not scan)
1096+ // Remove stale extensions for THIS agent only, using the same keep/prune
1097+ // rule as sync_extensions (see `stale_row_should_prune`): disabled rows
1098+ // and CLI binaries with install_meta stay; file-backed install_meta rows
1099+ // stay only while their source_path still exists on disk.
10491100 let scanned_ids: std:: collections:: HashSet < & str > =
10501101 extensions. iter ( ) . map ( |e| e. id . as_str ( ) ) . collect ( ) ;
1051- let stale_ids: Vec < ( String , bool , bool ) > = {
1102+ let stale_ids: Vec < ( String , bool , bool , String , Option < String > ) > = {
10521103 let mut stmt = tx. prepare (
1053- "SELECT DISTINCT e.id, e.enabled, (e.install_type IS NOT NULL) as has_meta
1104+ "SELECT DISTINCT e.id, e.enabled, (e.install_type IS NOT NULL) as has_meta,
1105+ e.kind, e.source_path
10541106 FROM extensions e
10551107 INNER JOIN extension_agents ea ON e.id = ea.extension_id
10561108 WHERE ea.agent_name = ?1"
10571109 ) ?;
10581110 stmt. query_map ( params ! [ agent] , |row| {
1059- Ok ( ( row. get :: < _ , String > ( 0 ) ?, row. get :: < _ , bool > ( 1 ) ?, row. get :: < _ , bool > ( 2 ) ?) )
1111+ Ok ( (
1112+ row. get :: < _ , String > ( 0 ) ?,
1113+ row. get :: < _ , bool > ( 1 ) ?,
1114+ row. get :: < _ , bool > ( 2 ) ?,
1115+ row. get :: < _ , String > ( 3 ) ?,
1116+ row. get :: < _ , Option < String > > ( 4 ) ?,
1117+ ) )
10601118 } ) ?
10611119 . filter_map ( |r| r. ok ( ) )
10621120 . collect ( )
10631121 } ;
1064- for ( id, enabled, has_install_meta) in & stale_ids {
1065- if !scanned_ids. contains ( id. as_str ( ) ) && * enabled && !has_install_meta {
1122+ for ( id, enabled, has_install_meta, kind, source_path) in & stale_ids {
1123+ if !scanned_ids. contains ( id. as_str ( ) )
1124+ && Self :: stale_row_should_prune (
1125+ * enabled,
1126+ * has_install_meta,
1127+ kind,
1128+ source_path. as_deref ( ) ,
1129+ )
1130+ {
10661131 tx. execute ( "DELETE FROM extensions WHERE id = ?1" , params ! [ id] ) ?;
10671132 }
10681133 }
@@ -2424,6 +2489,99 @@ mod tests {
24242489 assert_eq ! ( im. remote_revision. as_deref( ) , Some ( "def456" ) ) ;
24252490 }
24262491
2492+ #[ test]
2493+ fn test_stale_row_should_prune_decision ( ) {
2494+ let cli = ExtensionKind :: Cli . as_str ( ) ;
2495+ let skill = ExtensionKind :: Skill . as_str ( ) ;
2496+ let exists = env ! ( "CARGO_MANIFEST_DIR" ) ; // guaranteed to exist
2497+ let missing = "/nonexistent/harnesskit/ghost/SKILL.md" ;
2498+
2499+ // Disabled rows are intentionally absent from scans — always kept.
2500+ assert ! ( !Store :: stale_row_should_prune( false , true , skill, Some ( missing) ) ) ;
2501+ // Sourceless rows (no install_meta) are pruned when gone — prior behavior.
2502+ assert ! ( Store :: stale_row_should_prune( true , false , skill, None ) ) ;
2503+ // CLI with install_meta is kept even when absent (flaky binary detection).
2504+ assert ! ( !Store :: stale_row_should_prune( true , true , cli, Some ( missing) ) ) ;
2505+ // File-backed install_meta row whose file is gone → pruned (the ghost fix).
2506+ assert ! ( Store :: stale_row_should_prune( true , true , skill, Some ( missing) ) ) ;
2507+ // File-backed install_meta row whose file still exists → kept (scan gap).
2508+ assert ! ( !Store :: stale_row_should_prune( true , true , skill, Some ( exists) ) ) ;
2509+ // Unknown source_path → kept (can't prove removal).
2510+ assert ! ( !Store :: stale_row_should_prune( true , true , skill, None ) ) ;
2511+ }
2512+
2513+ #[ test]
2514+ fn test_sync_prunes_ghost_skill_with_install_meta_when_files_deleted ( ) {
2515+ // Regression: a marketplace/git-installed skill whose files the user
2516+ // deleted (e.g. `rm -rf ~/.claude`) used to linger forever because any
2517+ // install_meta row was exempt from stale cleanup. It should now be
2518+ // pruned once its source_path is gone, while a CLI with install_meta and
2519+ // a skill whose file still exists are both kept.
2520+ let ( store, dir) = test_store ( ) ;
2521+ let meta = || {
2522+ Some ( InstallMeta {
2523+ install_type : "marketplace" . into ( ) ,
2524+ url : Some ( "https://github.com/tw93/waza" . into ( ) ) ,
2525+ url_resolved : None ,
2526+ branch : None ,
2527+ subpath : None ,
2528+ revision : None ,
2529+ remote_revision : None ,
2530+ checked_at : None ,
2531+ check_error : None ,
2532+ } )
2533+ } ;
2534+
2535+ // Skill installed from marketplace, but its file no longer exists.
2536+ let mut ghost = sample_extension ( ) ;
2537+ ghost. id = "ghost-skill" . into ( ) ;
2538+ ghost. name = "ghost-skill" . into ( ) ;
2539+ ghost. source_path = Some ( "/nonexistent/harnesskit/ghost/SKILL.md" . into ( ) ) ;
2540+ ghost. install_meta = meta ( ) ;
2541+ store. insert_extension ( & ghost) . unwrap ( ) ;
2542+
2543+ // Skill whose file still exists on disk (simulate a transient scan gap).
2544+ let live_path = dir. path ( ) . join ( "live-SKILL.md" ) ;
2545+ std:: fs:: write ( & live_path, "x" ) . unwrap ( ) ;
2546+ let mut live = sample_extension ( ) ;
2547+ live. id = "live-skill" . into ( ) ;
2548+ live. name = "live-skill" . into ( ) ;
2549+ live. source_path = Some ( live_path. to_string_lossy ( ) . into_owned ( ) ) ;
2550+ live. install_meta = meta ( ) ;
2551+ store. insert_extension ( & live) . unwrap ( ) ;
2552+
2553+ // CLI with install_meta — binary detection is flaky, must stay.
2554+ let mut cli = sample_extension ( ) ;
2555+ cli. id = "cli-tool" . into ( ) ;
2556+ cli. name = "cli-tool" . into ( ) ;
2557+ cli. kind = ExtensionKind :: Cli ;
2558+ cli. source_path = Some ( "/nonexistent/harnesskit/cli-bin" . into ( ) ) ;
2559+ cli. install_meta = meta ( ) ;
2560+ store. insert_extension ( & cli) . unwrap ( ) ;
2561+
2562+ // Empty scan = nothing found on disk this round.
2563+ store. sync_extensions ( & [ ] ) . unwrap ( ) ;
2564+
2565+ let ids: Vec < String > = store
2566+ . list_extensions ( None , None )
2567+ . unwrap ( )
2568+ . into_iter ( )
2569+ . map ( |e| e. id )
2570+ . collect ( ) ;
2571+ assert ! (
2572+ !ids. contains( & "ghost-skill" . to_string( ) ) ,
2573+ "ghost skill with deleted files should be pruned"
2574+ ) ;
2575+ assert ! (
2576+ ids. contains( & "live-skill" . to_string( ) ) ,
2577+ "skill whose file still exists should be kept"
2578+ ) ;
2579+ assert ! (
2580+ ids. contains( & "cli-tool" . to_string( ) ) ,
2581+ "CLI with install_meta should be kept"
2582+ ) ;
2583+ }
2584+
24272585 #[ test]
24282586 fn test_sync_backfills_install_meta_from_git_source ( ) {
24292587 let ( store, _dir) = test_store ( ) ;
@@ -2758,6 +2916,60 @@ mod tests {
27582916 assert_eq ! ( cursor. len( ) , 1 ) ;
27592917 }
27602918
2919+ #[ test]
2920+ fn test_sync_for_agent_prunes_ghost_skill_with_install_meta ( ) {
2921+ // Same ghost-pruning rule as sync_extensions, exercised through the
2922+ // per-agent path (which has its own JOIN query) to guard the column
2923+ // mapping there.
2924+ let ( store, dir) = test_store ( ) ;
2925+ let meta = || {
2926+ Some ( InstallMeta {
2927+ install_type : "marketplace" . into ( ) ,
2928+ url : Some ( "https://github.com/tw93/waza" . into ( ) ) ,
2929+ url_resolved : None ,
2930+ branch : None ,
2931+ subpath : None ,
2932+ revision : None ,
2933+ remote_revision : None ,
2934+ checked_at : None ,
2935+ check_error : None ,
2936+ } )
2937+ } ;
2938+
2939+ let mut ghost = sample_extension ( ) ;
2940+ ghost. id = "agent-ghost" . into ( ) ;
2941+ ghost. agents = vec ! [ "claude" . into( ) ] ;
2942+ ghost. source_path = Some ( "/nonexistent/harnesskit/ghost/SKILL.md" . into ( ) ) ;
2943+ ghost. install_meta = meta ( ) ;
2944+ store. insert_extension ( & ghost) . unwrap ( ) ;
2945+
2946+ let live_path = dir. path ( ) . join ( "agent-live-SKILL.md" ) ;
2947+ std:: fs:: write ( & live_path, "x" ) . unwrap ( ) ;
2948+ let mut live = sample_extension ( ) ;
2949+ live. id = "agent-live" . into ( ) ;
2950+ live. agents = vec ! [ "claude" . into( ) ] ;
2951+ live. source_path = Some ( live_path. to_string_lossy ( ) . into_owned ( ) ) ;
2952+ live. install_meta = meta ( ) ;
2953+ store. insert_extension ( & live) . unwrap ( ) ;
2954+
2955+ store. sync_extensions_for_agent ( "claude" , & [ ] ) . unwrap ( ) ;
2956+
2957+ let ids: Vec < String > = store
2958+ . list_extensions ( None , Some ( "claude" ) )
2959+ . unwrap ( )
2960+ . into_iter ( )
2961+ . map ( |e| e. id )
2962+ . collect ( ) ;
2963+ assert ! (
2964+ !ids. contains( & "agent-ghost" . to_string( ) ) ,
2965+ "ghost skill should be pruned via the per-agent path"
2966+ ) ;
2967+ assert ! (
2968+ ids. contains( & "agent-live" . to_string( ) ) ,
2969+ "skill whose file still exists should be kept"
2970+ ) ;
2971+ }
2972+
27612973 #[ test]
27622974 fn test_count_latest_findings_by_severity ( ) {
27632975 let ( store, _dir) = test_store ( ) ;
0 commit comments