@@ -451,6 +451,192 @@ function runTests() {
451451 } ) ) passed ++ ;
452452 else failed ++ ;
453453
454+ // ── Counter file cleanup (#2156) ──
455+ // claude-tool-count-<sessionId> files were never removed. The hook now
456+ // sweeps stale counters older than COMPACT_STATE_TTL_DAYS (default 14)
457+ // before opening the active counter. These tests pin the contract.
458+ console . log ( '\nCounter file cleanup (#2156):' ) ;
459+
460+ /**
461+ * Set a file's mtime/atime to N days ago.
462+ */
463+ function setMtimeDaysAgo ( filePath , daysAgo ) {
464+ const seconds = Math . floor ( Date . now ( ) / 1000 ) - daysAgo * 24 * 60 * 60 ;
465+ fs . utimesSync ( filePath , seconds , seconds ) ;
466+ }
467+
468+ if ( test ( 'removes counter files older than retention window' , ( ) => {
469+ const { sessionId, cleanup } = createCounterContext ( ) ;
470+ const stale = getCounterFilePath ( `stale-${ Date . now ( ) } ` ) ;
471+ fs . writeFileSync ( stale , '1' ) ;
472+ setMtimeDaysAgo ( stale , 30 ) ;
473+ try {
474+ const result = runCompact ( { CLAUDE_SESSION_ID : sessionId } ) ;
475+ assert . strictEqual ( result . code , 0 , 'Should exit 0' ) ;
476+ assert . ok ( ! fs . existsSync ( stale ) ,
477+ `Stale counter file should have been swept. Path: ${ stale } ` ) ;
478+ } finally {
479+ try { fs . unlinkSync ( stale ) ; } catch ( _err ) { /* ignore */ }
480+ cleanup ( ) ;
481+ }
482+ } ) ) passed ++ ;
483+ else failed ++ ;
484+
485+ if ( test ( 'preserves counter files within retention window' , ( ) => {
486+ const { sessionId, cleanup } = createCounterContext ( ) ;
487+ const fresh = getCounterFilePath ( `fresh-${ Date . now ( ) } ` ) ;
488+ fs . writeFileSync ( fresh , '1' ) ;
489+ setMtimeDaysAgo ( fresh , 5 ) ;
490+ try {
491+ runCompact ( { CLAUDE_SESSION_ID : sessionId } ) ;
492+ assert . ok ( fs . existsSync ( fresh ) ,
493+ `Fresh counter file should be preserved. Path: ${ fresh } ` ) ;
494+ } finally {
495+ try { fs . unlinkSync ( fresh ) ; } catch ( _err ) { /* ignore */ }
496+ cleanup ( ) ;
497+ }
498+ } ) ) passed ++ ;
499+ else failed ++ ;
500+
501+ if ( test ( 'preserves the active session\'s counter file even if old' , ( ) => {
502+ const { sessionId, counterFile, cleanup } = createCounterContext ( ) ;
503+ cleanup ( ) ;
504+ fs . writeFileSync ( counterFile , '7' ) ;
505+ setMtimeDaysAgo ( counterFile , 30 ) ;
506+ try {
507+ const result = runCompact ( { CLAUDE_SESSION_ID : sessionId } ) ;
508+ assert . strictEqual ( result . code , 0 , 'Should exit 0' ) ;
509+ // Active counter survives the sweep AND is incremented by the hook (7 -> 8).
510+ assert . ok ( fs . existsSync ( counterFile ) ,
511+ 'Active session counter must survive the sweep' ) ;
512+ const count = parseInt ( fs . readFileSync ( counterFile , 'utf8' ) . trim ( ) , 10 ) ;
513+ assert . strictEqual ( count , 8 ,
514+ `Active counter should be incremented by the hook. Got ${ count } ` ) ;
515+ } finally {
516+ cleanup ( ) ;
517+ }
518+ } ) ) passed ++ ;
519+ else failed ++ ;
520+
521+ if ( test ( 'honours COMPACT_STATE_TTL_DAYS env var' , ( ) => {
522+ const { sessionId, cleanup } = createCounterContext ( ) ;
523+ const target = getCounterFilePath ( `ttl-${ Date . now ( ) } ` ) ;
524+ fs . writeFileSync ( target , '1' ) ;
525+ setMtimeDaysAgo ( target , 5 ) ; // Within default 14d window, but outside TTL=3
526+ try {
527+ const result = runCompact ( {
528+ CLAUDE_SESSION_ID : sessionId ,
529+ COMPACT_STATE_TTL_DAYS : '3'
530+ } ) ;
531+ assert . strictEqual ( result . code , 0 ) ;
532+ assert . ok ( ! fs . existsSync ( target ) ,
533+ `TTL=3 should sweep a 5-day-old file. Path: ${ target } ` ) ;
534+ } finally {
535+ try { fs . unlinkSync ( target ) ; } catch ( _err ) { /* ignore */ }
536+ cleanup ( ) ;
537+ }
538+ } ) ) passed ++ ;
539+ else failed ++ ;
540+
541+ if ( test ( 'falls back to default for invalid COMPACT_STATE_TTL_DAYS' , ( ) => {
542+ const { sessionId, cleanup } = createCounterContext ( ) ;
543+ const target = getCounterFilePath ( `fallback-${ Date . now ( ) } ` ) ;
544+ fs . writeFileSync ( target , '1' ) ;
545+ setMtimeDaysAgo ( target , 5 ) ; // Within default 14d window, would survive a fallback
546+ try {
547+ // Each invalid form: zero, negative, non-numeric — should fall back to 14d default.
548+ for ( const bad of [ '0' , '-5' , 'abc' ] ) {
549+ // Reset mtime each iteration so the file remains 5 days old.
550+ setMtimeDaysAgo ( target , 5 ) ;
551+ const result = runCompact ( {
552+ CLAUDE_SESSION_ID : sessionId ,
553+ COMPACT_STATE_TTL_DAYS : bad
554+ } ) ;
555+ assert . strictEqual ( result . code , 0 ) ;
556+ assert . ok ( fs . existsSync ( target ) ,
557+ `Invalid TTL '${ bad } ' should fall back to default (14d) and preserve a 5-day-old file` ) ;
558+ }
559+ } finally {
560+ try { fs . unlinkSync ( target ) ; } catch ( _err ) { /* ignore */ }
561+ cleanup ( ) ;
562+ }
563+ } ) ) passed ++ ;
564+ else failed ++ ;
565+
566+ if ( test ( 'does not touch unrelated temp files' , ( ) => {
567+ const { sessionId, cleanup } = createCounterContext ( ) ;
568+ const unrelated = path . join ( os . tmpdir ( ) , `unrelated-${ Date . now ( ) } .tmp` ) ;
569+ fs . writeFileSync ( unrelated , 'do not touch' ) ;
570+ setMtimeDaysAgo ( unrelated , 60 ) ;
571+ try {
572+ runCompact ( { CLAUDE_SESSION_ID : sessionId } ) ;
573+ assert . ok ( fs . existsSync ( unrelated ) ,
574+ `Unrelated temp file should not be swept. Path: ${ unrelated } ` ) ;
575+ } finally {
576+ try { fs . unlinkSync ( unrelated ) ; } catch ( _err ) { /* ignore */ }
577+ cleanup ( ) ;
578+ }
579+ } ) ) passed ++ ;
580+ else failed ++ ;
581+
582+ if ( test ( 'preserves files whose mtime sits at or after the TTL cutoff' , ( ) => {
583+ // Contract: docstring says files "older than" retentionDays are removed.
584+ // A file at the exact boundary (age == retentionDays) is NOT older than
585+ // retentionDays, so it must survive the sweep. Pins the >= comparison
586+ // in cleanupOldCounters: anything with mtimeMs >= cutoffMs is skipped.
587+ //
588+ // We can't pin the boundary by clock — the sweep computes its own
589+ // Date.now() after this test runs, so `setMtimeDaysAgo(file, 14)` is
590+ // effectively "14d + handful of ms", placing the file just past the
591+ // cutoff. To exercise the boundary deterministically, set the file's
592+ // mtime two seconds *newer* than the projected cutoff: with `>` the
593+ // file would be deleted (mtimeMs > cutoffMs is false at the cutoff
594+ // edge); with `>=` it survives.
595+ const { sessionId, cleanup } = createCounterContext ( ) ;
596+ const boundary = getCounterFilePath ( `boundary-${ Date . now ( ) } ` ) ;
597+ fs . writeFileSync ( boundary , '1' ) ;
598+ const retentionDays = 14 ;
599+ const boundaryMs = Date . now ( ) - retentionDays * 24 * 60 * 60 * 1000 + 2000 ;
600+ const sec = Math . floor ( boundaryMs / 1000 ) ;
601+ fs . utimesSync ( boundary , sec , sec ) ;
602+ try {
603+ const result = runCompact ( { CLAUDE_SESSION_ID : sessionId } ) ;
604+ assert . strictEqual ( result . code , 0 ) ;
605+ assert . ok ( fs . existsSync ( boundary ) ,
606+ `Boundary-aged counter file should be preserved. Path: ${ boundary } ` ) ;
607+ } finally {
608+ try { fs . unlinkSync ( boundary ) ; } catch ( _err ) { /* ignore */ }
609+ cleanup ( ) ;
610+ }
611+ } ) ) passed ++ ;
612+ else failed ++ ;
613+
614+ if ( test ( 'exit 0 holds when sweep encounters a populated temp dir' , ( ) => {
615+ // Functional smoke: with a mix of stale, fresh, and unrelated files
616+ // present, the hook must still exit 0 — the always-exit-0 contract
617+ // takes precedence over sweep failures.
618+ const { sessionId, cleanup } = createCounterContext ( ) ;
619+ const stale = getCounterFilePath ( `mix-stale-${ Date . now ( ) } ` ) ;
620+ const fresh = getCounterFilePath ( `mix-fresh-${ Date . now ( ) } ` ) ;
621+ const unrelated = path . join ( os . tmpdir ( ) , `mix-unrelated-${ Date . now ( ) } .tmp` ) ;
622+ fs . writeFileSync ( stale , '1' ) ;
623+ fs . writeFileSync ( fresh , '1' ) ;
624+ fs . writeFileSync ( unrelated , '1' ) ;
625+ setMtimeDaysAgo ( stale , 30 ) ;
626+ setMtimeDaysAgo ( fresh , 1 ) ;
627+ setMtimeDaysAgo ( unrelated , 30 ) ;
628+ try {
629+ const result = runCompact ( { CLAUDE_SESSION_ID : sessionId } ) ;
630+ assert . strictEqual ( result . code , 0 , 'Hook must exit 0 even with files in temp dir' ) ;
631+ } finally {
632+ for ( const p of [ stale , fresh , unrelated ] ) {
633+ try { fs . unlinkSync ( p ) ; } catch ( _err ) { /* ignore */ }
634+ }
635+ cleanup ( ) ;
636+ }
637+ } ) ) passed ++ ;
638+ else failed ++ ;
639+
454640 // Summary
455641 console . log ( `
456642Results: Passed: ${ passed } , Failed: ${ failed } ` ) ;
0 commit comments