@sooink/ai-session-tidy 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,8 @@ import { execSync } from 'child_process';
6
6
 
7
7
  const SERVICE_LABEL = 'sooink.ai-session-tidy.watcher';
8
8
  const PLIST_FILENAME = `${SERVICE_LABEL}.plist`;
9
+ const BIN_NAME = 'ai-session-tidy';
10
+ const BUNDLE_IDENTIFIER = 'io.github.sooink.ai-session-tidy';
9
11
 
10
12
  export type ServiceStatus = 'running' | 'stopped' | 'not_installed';
11
13
 
@@ -20,13 +22,26 @@ function getPlistPath(): string {
20
22
  return join(homedir(), 'Library', 'LaunchAgents', PLIST_FILENAME);
21
23
  }
22
24
 
25
+ function getBinPath(): string | null {
26
+ try {
27
+ const binPath = execSync(`which ${BIN_NAME}`, {
28
+ encoding: 'utf-8',
29
+ stdio: ['pipe', 'pipe', 'pipe'],
30
+ }).trim();
31
+ if (binPath && existsSync(binPath)) {
32
+ return binPath;
33
+ }
34
+ } catch {
35
+ // not found in PATH
36
+ }
37
+ return null;
38
+ }
39
+
23
40
  function getNodePath(): string {
24
- // Get absolute path to node executable
25
41
  return process.execPath;
26
42
  }
27
43
 
28
44
  function getScriptPath(): string {
29
- // Get absolute path to the CLI script
30
45
  const scriptPath = process.argv[1];
31
46
  if (scriptPath && existsSync(scriptPath)) {
32
47
  return scriptPath;
@@ -34,27 +49,57 @@ function getScriptPath(): string {
34
49
  throw new Error('Could not determine script path');
35
50
  }
36
51
 
52
+ /**
53
+ * Build ProgramArguments for the LaunchAgent plist.
54
+ *
55
+ * Prefers the bin command path (e.g. /usr/local/bin/ai-session-tidy) so that
56
+ * macOS attributes the background activity to "ai-session-tidy" instead of
57
+ * "Node.js Foundation". Falls back to [node, script] if the bin is not found.
58
+ *
59
+ * @see https://developer.apple.com/forums/thread/735065
60
+ */
61
+ function getProgramArgs(args: string[]): string[] {
62
+ const binPath = getBinPath();
63
+ if (binPath) {
64
+ return [binPath, ...args];
65
+ }
66
+ return [getNodePath(), getScriptPath(), ...args];
67
+ }
68
+
37
69
  function generatePlist(options: {
38
70
  label: string;
39
- nodePath: string;
40
- scriptPath: string;
41
- args: string[];
71
+ programArgs: string[];
42
72
  }): string {
43
- const allArgs = [options.nodePath, options.scriptPath, ...options.args];
44
- const argsXml = allArgs.map((arg) => ` <string>${arg}</string>`).join('\n');
73
+ const argsXml = options.programArgs
74
+ .map((arg) => ` <string>${arg}</string>`)
75
+ .join('\n');
45
76
 
46
77
  const home = homedir();
47
78
 
79
+ // Include PATH so that shebang (#!/usr/bin/env node) can locate the node binary.
80
+ // This is necessary for nvm/fnm users where node is not in the default system PATH.
81
+ const nodeBinDir = dirname(process.execPath);
82
+ const systemPath = process.env['PATH'] ?? '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin';
83
+ const envPath = systemPath.includes(nodeBinDir)
84
+ ? systemPath
85
+ : `${nodeBinDir}:${systemPath}`;
86
+
48
87
  return `<?xml version="1.0" encoding="UTF-8"?>
49
88
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
50
89
  <plist version="1.0">
51
90
  <dict>
52
91
  <key>Label</key>
53
92
  <string>${options.label}</string>
93
+ <key>AssociatedBundleIdentifiers</key>
94
+ <array>
95
+ <string>${BUNDLE_IDENTIFIER}</string>
96
+ </array>
54
97
  <key>EnvironmentVariables</key>
55
98
  <dict>
56
99
  <key>HOME</key>
57
100
  <string>${home}</string>
101
+ <key>PATH</key>
102
+ <string>${envPath}</string>
58
103
  </dict>
59
104
  <key>ProgramArguments</key>
60
105
  <array>
@@ -108,9 +153,7 @@ export class ServiceManager {
108
153
 
109
154
  const plistContent = generatePlist({
110
155
  label: SERVICE_LABEL,
111
- nodePath: getNodePath(),
112
- scriptPath: getScriptPath(),
113
- args: ['watch', 'run'],
156
+ programArgs: getProgramArgs(['watch', 'run']),
114
157
  });
115
158
 
116
159
  await writeFile(this.plistPath, plistContent, 'utf-8');
@@ -131,6 +174,17 @@ export class ServiceManager {
131
174
  throw new Error('Service not installed. Run "watch start" to install and start.');
132
175
  }
133
176
 
177
+ // Clear old log files before starting
178
+ const logDir = join(homedir(), '.ai-session-tidy');
179
+ const stdoutPath = join(logDir, 'watcher.log');
180
+ const stderrPath = join(logDir, 'watcher.error.log');
181
+ if (existsSync(stdoutPath)) {
182
+ await writeFile(stdoutPath, '', 'utf-8');
183
+ }
184
+ if (existsSync(stderrPath)) {
185
+ await writeFile(stderrPath, '', 'utf-8');
186
+ }
187
+
134
188
  try {
135
189
  execSync(`launchctl load "${this.plistPath}"`, { stdio: 'pipe' });
136
190
  } catch (error) {
@@ -11,6 +11,7 @@ import {
11
11
  getClaudeSessionEnvDir,
12
12
  getClaudeTodosDir,
13
13
  getClaudeFileHistoryDir,
14
+ getClaudeTasksDir,
14
15
  } from '../utils/paths.js';
15
16
  import { getDirectorySize } from '../utils/size.js';
16
17
 
@@ -36,6 +37,7 @@ export interface ClaudeCodeScannerOptions {
36
37
  sessionEnvDir?: string | null; // null to disable session-env scanning
37
38
  todosDir?: string | null; // null to disable todos scanning
38
39
  fileHistoryDir?: string | null; // null to disable file-history scanning
40
+ tasksDir?: string | null; // null to disable tasks scanning
39
41
  }
40
42
 
41
43
  export class ClaudeCodeScanner implements Scanner {
@@ -45,6 +47,7 @@ export class ClaudeCodeScanner implements Scanner {
45
47
  private readonly sessionEnvDir: string | null;
46
48
  private readonly todosDir: string | null;
47
49
  private readonly fileHistoryDir: string | null;
50
+ private readonly tasksDir: string | null;
48
51
 
49
52
  constructor(projectsDirOrOptions?: string | ClaudeCodeScannerOptions) {
50
53
  if (typeof projectsDirOrOptions === 'string') {
@@ -54,6 +57,7 @@ export class ClaudeCodeScanner implements Scanner {
54
57
  this.sessionEnvDir = null;
55
58
  this.todosDir = null;
56
59
  this.fileHistoryDir = null;
60
+ this.tasksDir = null;
57
61
  } else if (projectsDirOrOptions) {
58
62
  this.projectsDir = projectsDirOrOptions.projectsDir ?? getClaudeProjectsDir();
59
63
  this.configPath = projectsDirOrOptions.configPath === undefined
@@ -68,12 +72,16 @@ export class ClaudeCodeScanner implements Scanner {
68
72
  this.fileHistoryDir = projectsDirOrOptions.fileHistoryDir === undefined
69
73
  ? getClaudeFileHistoryDir()
70
74
  : projectsDirOrOptions.fileHistoryDir;
75
+ this.tasksDir = projectsDirOrOptions.tasksDir === undefined
76
+ ? getClaudeTasksDir()
77
+ : projectsDirOrOptions.tasksDir;
71
78
  } else {
72
79
  this.projectsDir = getClaudeProjectsDir();
73
80
  this.configPath = getClaudeConfigPath();
74
81
  this.sessionEnvDir = getClaudeSessionEnvDir();
75
82
  this.todosDir = getClaudeTodosDir();
76
83
  this.fileHistoryDir = getClaudeFileHistoryDir();
84
+ this.tasksDir = getClaudeTasksDir();
77
85
  }
78
86
  }
79
87
 
@@ -148,6 +156,10 @@ export class ClaudeCodeScanner implements Scanner {
148
156
  const fileHistorySessions = await this.scanFileHistoryDir(validSessionIds);
149
157
  sessions.push(...fileHistorySessions);
150
158
 
159
+ // 7. Scan ~/.claude/tasks for orphaned folders
160
+ const tasksSessions = await this.scanTasksDir(validSessionIds);
161
+ sessions.push(...tasksSessions);
162
+
151
163
  const totalSize = sessions.reduce((sum, s) => sum + s.size, 0);
152
164
 
153
165
  return {
@@ -360,6 +372,49 @@ export class ClaudeCodeScanner implements Scanner {
360
372
  return orphanedHistories;
361
373
  }
362
374
 
375
+ /**
376
+ * Detect orphaned folders from ~/.claude/tasks
377
+ * Folder name is the session UUID, contains .lock file
378
+ */
379
+ private async scanTasksDir(validSessionIds: Set<string>): Promise<OrphanedSession[]> {
380
+ if (!this.tasksDir) {
381
+ return [];
382
+ }
383
+
384
+ const orphanedTasks: OrphanedSession[] = [];
385
+
386
+ try {
387
+ await access(this.tasksDir);
388
+ const entries = await readdir(this.tasksDir, { withFileTypes: true });
389
+
390
+ for (const entry of entries) {
391
+ if (!entry.isDirectory()) continue;
392
+
393
+ const sessionId = entry.name;
394
+
395
+ // Orphan if not in valid session IDs
396
+ if (!validSessionIds.has(sessionId)) {
397
+ const taskPath = join(this.tasksDir, entry.name);
398
+ const size = await getDirectorySize(taskPath);
399
+ const taskStat = await stat(taskPath);
400
+
401
+ orphanedTasks.push({
402
+ toolName: this.name,
403
+ sessionPath: taskPath,
404
+ projectPath: sessionId, // Session UUID
405
+ size,
406
+ lastModified: taskStat.mtime,
407
+ type: 'tasks',
408
+ });
409
+ }
410
+ }
411
+ } catch {
412
+ // Ignore if directory doesn't exist or access fails
413
+ }
414
+
415
+ return orphanedTasks;
416
+ }
417
+
363
418
  /**
364
419
  * Extract project path (cwd) from JSONL file
365
420
  */
@@ -1,6 +1,6 @@
1
1
  export type ToolName = 'claude-code' | 'cursor';
2
2
 
3
- export type SessionType = 'session' | 'config' | 'session-env' | 'todos' | 'file-history';
3
+ export type SessionType = 'session' | 'config' | 'session-env' | 'todos' | 'file-history' | 'tasks';
4
4
 
5
5
  export interface ConfigStats {
6
6
  lastCost?: number;
@@ -1,3 +1,4 @@
1
+ import { existsSync } from 'fs';
1
2
  import { homedir } from 'os';
2
3
  import { join } from 'path';
3
4
 
@@ -14,13 +15,45 @@ export function encodePath(path: string): string {
14
15
 
15
16
  /**
16
17
  * Decode Claude Code encoded path to Unix path
17
- * -home-user-project /home/user/project
18
+ * Handles hyphenated folder names by checking filesystem
19
+ *
20
+ * -Users-kory-my-project → /Users/kory/my-project (if exists)
21
+ * -Users-kory-my-project → /Users/kory/my/project (fallback)
18
22
  */
19
23
  export function decodePath(encoded: string): string {
20
24
  if (encoded === '') return '';
21
- // Treat as Unix encoding if it starts with a dash
22
25
  if (!encoded.startsWith('-')) return encoded;
23
- return encoded.replace(/-/g, '/');
26
+
27
+ // Split by dash (remove leading dash first)
28
+ const parts = encoded.slice(1).split('-');
29
+
30
+ // Try to reconstruct path by checking filesystem
31
+ return reconstructPath(parts, '');
32
+ }
33
+
34
+ /**
35
+ * Recursively reconstruct path by trying different combinations
36
+ */
37
+ function reconstructPath(parts: string[], currentPath: string): string {
38
+ if (parts.length === 0) return currentPath;
39
+
40
+ // Try combining segments (longest first to prefer hyphenated names)
41
+ for (let len = parts.length; len >= 1; len--) {
42
+ const segment = parts.slice(0, len).join('-');
43
+ const testPath = currentPath + '/' + segment;
44
+
45
+ if (existsSync(testPath)) {
46
+ // Found existing path, continue with remaining parts
47
+ const result = reconstructPath(parts.slice(len), testPath);
48
+ // Verify the final path exists (or is the best we can do)
49
+ if (existsSync(result) || parts.slice(len).length === 0) {
50
+ return result;
51
+ }
52
+ }
53
+ }
54
+
55
+ // No existing path found, use simple decode for remaining parts
56
+ return currentPath + '/' + parts.join('/');
24
57
  }
25
58
 
26
59
  /**
@@ -79,6 +112,13 @@ export function getClaudeFileHistoryDir(): string {
79
112
  return join(homedir(), '.claude', 'file-history');
80
113
  }
81
114
 
115
+ /**
116
+ * Claude Code tasks directory path (~/.claude/tasks)
117
+ */
118
+ export function getClaudeTasksDir(): string {
119
+ return join(homedir(), '.claude', 'tasks');
120
+ }
121
+
82
122
  /**
83
123
  * Replace home directory with ~ for display
84
124
  * /Users/user/.ai-session-tidy → ~/.ai-session-tidy
package/README.ko.md DELETED
@@ -1,176 +0,0 @@
1
- <div align="center">
2
-
3
- # AI Session Tidy
4
-
5
- **AI 코딩 도구의 방치된 세션을 정리합니다**
6
-
7
- [![npm version](https://img.shields.io/npm/v/@sooink/ai-session-tidy.svg?style=flat-square)](https://www.npmjs.com/package/@sooink/ai-session-tidy)
8
- [![node](https://img.shields.io/badge/node-%3E%3D24-brightgreen?style=flat-square)](https://nodejs.org/)
9
- [![platform](https://img.shields.io/badge/platform-macOS-blue?style=flat-square)](https://github.com/sooink/ai-session-tidy)
10
- [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT)
11
-
12
- [English](README.md) · [한국어](README.ko.md)
13
-
14
- </div>
15
-
16
- ---
17
-
18
- ## 문제점
19
-
20
- **Claude Code**나 **Cursor** 같은 AI 코딩 도구는 세션 데이터를 로컬에 저장합니다—대화 기록, 파일 스냅샷, Todo 등.
21
-
22
- 프로젝트를 삭제, 이동, 이름 변경하면 세션 데이터가 방치됩니다:
23
-
24
- ```
25
- ~/.claude/
26
- ├── projects/
27
- │ ├── -Users-you-deleted-project/ # 👈 지난주 삭제한 프로젝트
28
- │ ├── -Users-you-temp-worktree/ # 👈 제거한 worktree
29
- │ └── -Users-you-renamed-project/ # 👈 이름 바꾼 프로젝트
30
- ├── todos/ # 방치된 Todo 파일
31
- └── file-history/ # 방치된 Rewind 스냅샷
32
- ```
33
-
34
- Claude Code는 30일 후 오래된 세션을 삭제하지만, **Cursor는 자동 정리 기능이 없습니다.** Claude Code도 30일간은 방치된 데이터가 남아있습니다.
35
-
36
- **이 도구는 방치된 세션을 즉시 찾아서 정리합니다.**
37
-
38
- ## 빠른 시작
39
-
40
- ```bash
41
- npm install -g @sooink/ai-session-tidy
42
-
43
- ai-session-tidy # 방치된 세션 스캔
44
- ai-session-tidy clean # 휴지통으로 이동
45
- ai-session-tidy watch start # 자동 정리 데몬
46
- ```
47
-
48
- ![Demo](assets/demo.gif)
49
-
50
- ## 활용 사례
51
-
52
- ### Git Worktree 워크플로우
53
-
54
- [Git worktree](https://git-scm.com/docs/git-worktree)로 여러 브랜치에서 동시에 작업할 수 있습니다. 하지만 worktree를 제거해도 세션 데이터는 남습니다.
55
-
56
- ```bash
57
- git worktree add ../feature-branch feature
58
- cd ../feature-branch && claude # 세션 데이터 생성
59
-
60
- git worktree remove ../feature-branch
61
- # ~/.claude/projects/-...-feature-branch/ 가 그대로 남음
62
- ```
63
-
64
- **watch 모드를 사용하면** 자동으로 정리됩니다:
65
-
66
- ```bash
67
- ai-session-tidy watch start # 한 번 실행, 로그인 시 자동 시작
68
-
69
- git worktree remove ../feature # watch가 감지 → 5분 후 정리
70
- ```
71
-
72
- ### 멀티 에이전트 오케스트레이션
73
-
74
- [최신 AI 워크플로우](https://www.anthropic.com/engineering/multi-agent-research-system)는 여러 에이전트를 병렬로 실행하며, 각각 격리된 worktree에서 작업합니다.
75
-
76
- 이로 인해 세션 데이터 축적이 배가됩니다. watch 모드가 시스템을 자동으로 깔끔하게 유지합니다.
77
-
78
- ## 지원 도구
79
-
80
- | 도구 | 상태 |
81
- |-----|------|
82
- | Claude Code | ✅ 지원 |
83
- | Cursor | ✅ 지원 |
84
-
85
- ## 명령어
86
-
87
- ### `scan` (기본)
88
-
89
- 삭제 없이 방치된 세션을 찾습니다.
90
-
91
- ```bash
92
- ai-session-tidy # 기본 스캔
93
- ai-session-tidy -v # 상세 출력
94
- ai-session-tidy --json # JSON 출력
95
- ```
96
-
97
- ### `clean`
98
-
99
- 방치된 세션을 삭제합니다.
100
-
101
- ```bash
102
- ai-session-tidy clean # 휴지통으로 이동 (확인 필요)
103
- ai-session-tidy clean -i # 대화형 선택
104
- ai-session-tidy clean -f # 확인 생략
105
- ai-session-tidy clean -n # 드라이런 (삭제 대상만 표시)
106
- ```
107
-
108
- ![Interactive Clean](assets/demo-interactive.gif)
109
-
110
- ### `watch`
111
-
112
- 감시하고 자동으로 정리합니다.
113
-
114
- ```bash
115
- ai-session-tidy watch # 포그라운드 모드
116
- ai-session-tidy watch start # 백그라운드 데몬 (로그인 시 자동 시작)
117
- ai-session-tidy watch stop # 데몬 중지
118
- ai-session-tidy watch status # 상태 확인
119
- ai-session-tidy watch status -l # 최근 로그 표시
120
- ```
121
-
122
- ### `config`
123
-
124
- 설정을 관리합니다.
125
-
126
- ```bash
127
- ai-session-tidy config show # 전체 설정 보기
128
- ai-session-tidy config path add ~/projects # 감시 경로 추가
129
- ai-session-tidy config path list # 감시 경로 목록
130
- ai-session-tidy config ignore add ~/backup # 제외 경로 추가
131
- ai-session-tidy config ignore list # 제외 경로 목록
132
- ai-session-tidy config delay 1 # 정리 딜레이 설정 (분)
133
- ai-session-tidy config depth 5 # 감시 깊이 설정
134
- ai-session-tidy config reset # 기본값으로 초기화
135
- ```
136
-
137
- > [!TIP]
138
- > 숨김 폴더 (`.git`, `.cache` 등)와 macOS 시스템 폴더 (`Library`, `Music` 등)는 자동으로 제외됩니다.
139
-
140
- ## 정리 대상
141
-
142
- ### Claude Code
143
-
144
- | 위치 | 설명 | 조건 |
145
- |-----|------|-----|
146
- | `~/.claude/projects/{path}/` | 세션 폴더 | 프로젝트 삭제됨 |
147
- | `~/.claude.json` | Config 항목 | 프로젝트 삭제됨 |
148
- | `~/.claude/session-env/{uuid}/` | 세션 환경 | 빈 폴더 |
149
- | `~/.claude/todos/{uuid}-*.json` | Todo 파일 | 세션 없음 |
150
- | `~/.claude/file-history/{uuid}/` | Rewind 스냅샷 | 세션 없음 |
151
-
152
- ### Cursor
153
-
154
- | 위치 | 설명 | 조건 |
155
- |-----|------|-----|
156
- | `~/Library/.../workspaceStorage/{hash}/` | 워크스페이스 데이터 | 프로젝트 삭제됨 |
157
-
158
- ## 안전장치
159
-
160
- > [!NOTE]
161
- > 모든 작업은 기본적으로 안전합니다—명시적 조치 없이는 영구 삭제되지 않습니다.
162
-
163
- - **스캔은 읽기 전용** — `scan`은 아무것도 삭제하지 않음
164
- - **휴지통 우선** — `clean`은 휴지통으로 이동 (복구 가능)
165
- - **확인 필요** — `-f` 없이는 삭제 전 확인
166
- - **5분 딜레이** — watch 모드는 정리 전 대기 (설정 가능)
167
-
168
- ## 개발
169
-
170
- ```bash
171
- git clone https://github.com/sooink/ai-session-tidy.git
172
- cd ai-session-tidy
173
- pnpm install
174
- pnpm build
175
- pnpm test
176
- ```