@sooink/ai-session-tidy 0.1.3 → 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.
- package/.github/workflows/release.yml +57 -0
- package/CHANGELOG.md +32 -0
- package/README.md +10 -8
- package/assets/demo-interactive.gif +0 -0
- package/assets/demo.gif +0 -0
- package/dist/index.js +136 -12
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/commands/clean.ts +29 -3
- package/src/commands/scan.ts +23 -0
- package/src/commands/watch.ts +7 -3
- package/src/core/cleaner.ts +5 -0
- package/src/core/service.ts +53 -10
- package/src/scanners/claude-code.ts +55 -0
- package/src/scanners/types.ts +1 -1
- package/src/utils/paths.ts +7 -0
- package/README.ko.md +0 -213
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sooink/ai-session-tidy",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "CLI tool that detects and cleans orphaned session data from AI coding tools",
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "CLI tool that automatically detects and cleans orphaned session data from AI coding tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"ai-session-tidy": "./dist/index.js"
|
package/src/commands/clean.ts
CHANGED
|
@@ -37,7 +37,7 @@ function formatSessionChoice(session: OrphanedSession): string {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
interface GroupChoice {
|
|
40
|
-
type: 'session-env' | 'todos' | 'file-history';
|
|
40
|
+
type: 'session-env' | 'todos' | 'file-history' | 'tasks';
|
|
41
41
|
sessions: OrphanedSession[];
|
|
42
42
|
totalSize: number;
|
|
43
43
|
}
|
|
@@ -47,11 +47,13 @@ function formatGroupChoice(group: GroupChoice): string {
|
|
|
47
47
|
'session-env': 'empty session-env folder',
|
|
48
48
|
'todos': 'orphaned todos file',
|
|
49
49
|
'file-history': 'orphaned file-history folder',
|
|
50
|
+
'tasks': 'orphaned tasks folder',
|
|
50
51
|
};
|
|
51
52
|
const colors: Record<string, (s: string) => string> = {
|
|
52
53
|
'session-env': chalk.green,
|
|
53
54
|
'todos': chalk.magenta,
|
|
54
55
|
'file-history': chalk.blue,
|
|
56
|
+
'tasks': chalk.cyan,
|
|
55
57
|
};
|
|
56
58
|
const label = labels[group.type] || group.type;
|
|
57
59
|
const count = group.sessions.length;
|
|
@@ -103,16 +105,18 @@ export const cleanCommand = new Command('clean')
|
|
|
103
105
|
const fileHistoryEntries = allSessions.filter(
|
|
104
106
|
(s) => s.type === 'file-history'
|
|
105
107
|
);
|
|
108
|
+
const tasksEntries = allSessions.filter((s) => s.type === 'tasks');
|
|
106
109
|
|
|
107
110
|
// Individual selection targets (session folders and config entries)
|
|
108
111
|
const individualSessions = allSessions.filter(
|
|
109
112
|
(s) =>
|
|
110
113
|
s.type !== 'session-env' &&
|
|
111
114
|
s.type !== 'todos' &&
|
|
112
|
-
s.type !== 'file-history'
|
|
115
|
+
s.type !== 'file-history' &&
|
|
116
|
+
s.type !== 'tasks'
|
|
113
117
|
);
|
|
114
118
|
|
|
115
|
-
// Group selection targets (session-env, todos, file-history)
|
|
119
|
+
// Group selection targets (session-env, todos, file-history, tasks)
|
|
116
120
|
const groupChoices: GroupChoice[] = [];
|
|
117
121
|
if (sessionEnvEntries.length > 0) {
|
|
118
122
|
groupChoices.push({
|
|
@@ -135,6 +139,13 @@ export const cleanCommand = new Command('clean')
|
|
|
135
139
|
totalSize: fileHistoryEntries.reduce((sum, s) => sum + s.size, 0),
|
|
136
140
|
});
|
|
137
141
|
}
|
|
142
|
+
if (tasksEntries.length > 0) {
|
|
143
|
+
groupChoices.push({
|
|
144
|
+
type: 'tasks',
|
|
145
|
+
sessions: tasksEntries,
|
|
146
|
+
totalSize: tasksEntries.reduce((sum, s) => sum + s.size, 0),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
138
149
|
|
|
139
150
|
// Output summary
|
|
140
151
|
console.log();
|
|
@@ -154,6 +165,9 @@ export const cleanCommand = new Command('clean')
|
|
|
154
165
|
if (fileHistoryEntries.length > 0) {
|
|
155
166
|
parts.push(`${fileHistoryEntries.length} file-history folder(s)`);
|
|
156
167
|
}
|
|
168
|
+
if (tasksEntries.length > 0) {
|
|
169
|
+
parts.push(`${tasksEntries.length} tasks folder(s)`);
|
|
170
|
+
}
|
|
157
171
|
logger.warn(`Found ${parts.join(' + ')} (${formatSize(totalSize)})`);
|
|
158
172
|
|
|
159
173
|
if (options.verbose && !options.interactive) {
|
|
@@ -283,6 +297,9 @@ export const cleanCommand = new Command('clean')
|
|
|
283
297
|
const dryRunHistories = sessionsToClean.filter(
|
|
284
298
|
(s) => s.type === 'file-history'
|
|
285
299
|
);
|
|
300
|
+
const dryRunTasks = sessionsToClean.filter(
|
|
301
|
+
(s) => s.type === 'tasks'
|
|
302
|
+
);
|
|
286
303
|
|
|
287
304
|
for (const session of dryRunFolders) {
|
|
288
305
|
console.log(
|
|
@@ -319,6 +336,12 @@ export const cleanCommand = new Command('clean')
|
|
|
319
336
|
` ${chalk.blue('Would delete:')} ${dryRunHistories.length} file-history folder(s) (${formatSize(dryRunHistories.reduce((sum, s) => sum + s.size, 0))})`
|
|
320
337
|
);
|
|
321
338
|
}
|
|
339
|
+
if (dryRunTasks.length > 0) {
|
|
340
|
+
console.log();
|
|
341
|
+
console.log(
|
|
342
|
+
` ${chalk.cyan('Would delete:')} ${dryRunTasks.length} tasks folder(s) (${formatSize(dryRunTasks.reduce((sum, s) => sum + s.size, 0))})`
|
|
343
|
+
);
|
|
344
|
+
}
|
|
322
345
|
return;
|
|
323
346
|
}
|
|
324
347
|
|
|
@@ -375,6 +398,9 @@ export const cleanCommand = new Command('clean')
|
|
|
375
398
|
if (deletedByType.fileHistory > 0) {
|
|
376
399
|
parts.push(`${deletedByType.fileHistory} file-history`);
|
|
377
400
|
}
|
|
401
|
+
if (deletedByType.tasks > 0) {
|
|
402
|
+
parts.push(`${deletedByType.tasks} tasks`);
|
|
403
|
+
}
|
|
378
404
|
|
|
379
405
|
const summary =
|
|
380
406
|
parts.length > 0
|
package/src/commands/scan.ts
CHANGED
|
@@ -85,6 +85,7 @@ function outputTable(results: ScanResult[], verbose?: boolean): void {
|
|
|
85
85
|
const sessionEnvEntries = allSessions.filter((s) => s.type === 'session-env');
|
|
86
86
|
const todosEntries = allSessions.filter((s) => s.type === 'todos');
|
|
87
87
|
const fileHistoryEntries = allSessions.filter((s) => s.type === 'file-history');
|
|
88
|
+
const tasksEntries = allSessions.filter((s) => s.type === 'tasks');
|
|
88
89
|
const totalSize = results.reduce((sum, r) => sum + r.totalSize, 0);
|
|
89
90
|
|
|
90
91
|
if (allSessions.length === 0) {
|
|
@@ -111,6 +112,9 @@ function outputTable(results: ScanResult[], verbose?: boolean): void {
|
|
|
111
112
|
if (fileHistoryEntries.length > 0) {
|
|
112
113
|
parts.push(`${fileHistoryEntries.length} file-history folder(s)`);
|
|
113
114
|
}
|
|
115
|
+
if (tasksEntries.length > 0) {
|
|
116
|
+
parts.push(`${tasksEntries.length} tasks folder(s)`);
|
|
117
|
+
}
|
|
114
118
|
logger.warn(`Found ${parts.join(' + ')} (${formatSize(totalSize)})`);
|
|
115
119
|
console.log();
|
|
116
120
|
|
|
@@ -123,6 +127,7 @@ function outputTable(results: ScanResult[], verbose?: boolean): void {
|
|
|
123
127
|
chalk.cyan('Env'),
|
|
124
128
|
chalk.cyan('Todos'),
|
|
125
129
|
chalk.cyan('History'),
|
|
130
|
+
chalk.cyan('Tasks'),
|
|
126
131
|
chalk.cyan('Size'),
|
|
127
132
|
chalk.cyan('Scan Time'),
|
|
128
133
|
],
|
|
@@ -136,6 +141,7 @@ function outputTable(results: ScanResult[], verbose?: boolean): void {
|
|
|
136
141
|
const envs = result.sessions.filter((s) => s.type === 'session-env').length;
|
|
137
142
|
const todos = result.sessions.filter((s) => s.type === 'todos').length;
|
|
138
143
|
const histories = result.sessions.filter((s) => s.type === 'file-history').length;
|
|
144
|
+
const tasks = result.sessions.filter((s) => s.type === 'tasks').length;
|
|
139
145
|
summaryTable.push([
|
|
140
146
|
result.toolName,
|
|
141
147
|
folders > 0 ? String(folders) : '-',
|
|
@@ -143,6 +149,7 @@ function outputTable(results: ScanResult[], verbose?: boolean): void {
|
|
|
143
149
|
envs > 0 ? String(envs) : '-',
|
|
144
150
|
todos > 0 ? String(todos) : '-',
|
|
145
151
|
histories > 0 ? String(histories) : '-',
|
|
152
|
+
tasks > 0 ? String(tasks) : '-',
|
|
146
153
|
formatSize(result.totalSize),
|
|
147
154
|
`${result.scanDuration.toFixed(0)}ms`,
|
|
148
155
|
]);
|
|
@@ -240,6 +247,22 @@ function outputTable(results: ScanResult[], verbose?: boolean): void {
|
|
|
240
247
|
}
|
|
241
248
|
}
|
|
242
249
|
|
|
250
|
+
// Tasks
|
|
251
|
+
if (tasksEntries.length > 0) {
|
|
252
|
+
console.log();
|
|
253
|
+
console.log(chalk.bold('Orphaned Tasks:'));
|
|
254
|
+
console.log();
|
|
255
|
+
|
|
256
|
+
for (const entry of tasksEntries) {
|
|
257
|
+
const folderName = entry.sessionPath.split('/').pop() || entry.sessionPath;
|
|
258
|
+
console.log(
|
|
259
|
+
` ${chalk.cyan('[tasks]')} ${chalk.white(folderName)} ${chalk.dim(`(${formatSize(entry.size)})`)}`
|
|
260
|
+
);
|
|
261
|
+
console.log(` ${chalk.dim('→')} ${tildify(entry.sessionPath)}`);
|
|
262
|
+
console.log();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
243
266
|
}
|
|
244
267
|
|
|
245
268
|
console.log();
|
package/src/commands/watch.ts
CHANGED
|
@@ -265,11 +265,12 @@ async function runWatcher(options: RunOptions): Promise<void> {
|
|
|
265
265
|
watchPaths: validPaths,
|
|
266
266
|
delayMs,
|
|
267
267
|
depth,
|
|
268
|
-
ignorePaths: getIgnorePaths(),
|
|
268
|
+
ignorePaths: getIgnorePaths() ?? [],
|
|
269
269
|
onDelete: async (events) => {
|
|
270
270
|
// Log batch events
|
|
271
|
-
|
|
272
|
-
|
|
271
|
+
const firstEvent = events[0];
|
|
272
|
+
if (events.length === 1 && firstEvent) {
|
|
273
|
+
logger.info(`Detected deletion: ${tildify(firstEvent.path)}`);
|
|
273
274
|
} else {
|
|
274
275
|
logger.info(`Detected ${events.length} deletions (debounced)`);
|
|
275
276
|
if (options.verbose) {
|
|
@@ -313,6 +314,9 @@ async function runWatcher(options: RunOptions): Promise<void> {
|
|
|
313
314
|
if (deletedByType.fileHistory > 0) {
|
|
314
315
|
parts.push(`${deletedByType.fileHistory} file-history`);
|
|
315
316
|
}
|
|
317
|
+
if (deletedByType.tasks > 0) {
|
|
318
|
+
parts.push(`${deletedByType.tasks} tasks`);
|
|
319
|
+
}
|
|
316
320
|
|
|
317
321
|
const summary = parts.length > 0 ? parts.join(' + ') : `${cleanResult.deletedCount} item(s)`;
|
|
318
322
|
logger.success(
|
package/src/core/cleaner.ts
CHANGED
|
@@ -21,6 +21,7 @@ export interface CleanCountByType {
|
|
|
21
21
|
sessionEnv: number;
|
|
22
22
|
todos: number;
|
|
23
23
|
fileHistory: number;
|
|
24
|
+
tasks: number;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export interface CleanResult {
|
|
@@ -55,6 +56,7 @@ export class Cleaner {
|
|
|
55
56
|
sessionEnv: 0,
|
|
56
57
|
todos: 0,
|
|
57
58
|
fileHistory: 0,
|
|
59
|
+
tasks: 0,
|
|
58
60
|
},
|
|
59
61
|
skippedCount: 0,
|
|
60
62
|
alreadyGoneCount: 0,
|
|
@@ -96,6 +98,9 @@ export class Cleaner {
|
|
|
96
98
|
case 'file-history':
|
|
97
99
|
result.deletedByType.fileHistory++;
|
|
98
100
|
break;
|
|
101
|
+
case 'tasks':
|
|
102
|
+
result.deletedByType.tasks++;
|
|
103
|
+
break;
|
|
99
104
|
default:
|
|
100
105
|
result.deletedByType.session++;
|
|
101
106
|
}
|
package/src/core/service.ts
CHANGED
|
@@ -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
|
-
|
|
40
|
-
scriptPath: string;
|
|
41
|
-
args: string[];
|
|
71
|
+
programArgs: string[];
|
|
42
72
|
}): string {
|
|
43
|
-
const
|
|
44
|
-
|
|
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
|
-
|
|
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');
|
|
@@ -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
|
*/
|
package/src/scanners/types.ts
CHANGED
|
@@ -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;
|
package/src/utils/paths.ts
CHANGED
|
@@ -112,6 +112,13 @@ export function getClaudeFileHistoryDir(): string {
|
|
|
112
112
|
return join(homedir(), '.claude', 'file-history');
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Claude Code tasks directory path (~/.claude/tasks)
|
|
117
|
+
*/
|
|
118
|
+
export function getClaudeTasksDir(): string {
|
|
119
|
+
return join(homedir(), '.claude', 'tasks');
|
|
120
|
+
}
|
|
121
|
+
|
|
115
122
|
/**
|
|
116
123
|
* Replace home directory with ~ for display
|
|
117
124
|
* /Users/user/.ai-session-tidy → ~/.ai-session-tidy
|
package/README.ko.md
DELETED
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
<div align="center">
|
|
2
|
-
|
|
3
|
-
# AI Session Tidy
|
|
4
|
-
|
|
5
|
-
**AI 코딩 도구의 방치된 세션을 정리합니다**
|
|
6
|
-
|
|
7
|
-
[](https://www.npmjs.com/package/@sooink/ai-session-tidy)
|
|
8
|
-
[](https://nodejs.org/)
|
|
9
|
-
[](https://github.com/sooink/ai-session-tidy)
|
|
10
|
-
[](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
|
-
특히 **git worktree 워크플로우**에서 이 문제가 심각해집니다. 브랜치가 자주 생성되고 삭제되면서 세션 데이터가 빠르게 쌓이기 때문입니다.
|
|
35
|
-
|
|
36
|
-
Claude Code는 30일 후 오래된 세션을 삭제하지만, **Cursor는 자동 정리 기능이 없습니다.** Claude Code도 30일간은 방치된 데이터가 남아있습니다. worktree를 삭제할 때마다 수동으로 정리하는 건 번거롭습니다.
|
|
37
|
-
|
|
38
|
-
**이 도구는 방치된 세션을 자동으로 감지하고 정리합니다.**
|
|
39
|
-
`watch start`를 한 번만 실행하면 백그라운드에서 프로젝트 삭제를 감시하고 자동으로 정리합니다.
|
|
40
|
-
|
|
41
|
-
## 빠른 시작
|
|
42
|
-
|
|
43
|
-
```bash
|
|
44
|
-
npm install -g @sooink/ai-session-tidy
|
|
45
|
-
|
|
46
|
-
# 자동 정리 (권장)
|
|
47
|
-
ai-session-tidy watch start
|
|
48
|
-
|
|
49
|
-
# 수동 정리
|
|
50
|
-
ai-session-tidy # 방치된 세션 스캔
|
|
51
|
-
ai-session-tidy clean # 휴지통으로 이동
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-

|
|
55
|
-
|
|
56
|
-
## 활용 사례
|
|
57
|
-
|
|
58
|
-
### Git Worktree 워크플로우
|
|
59
|
-
|
|
60
|
-
[Git worktree](https://git-scm.com/docs/git-worktree)로 여러 브랜치에서 동시에 작업할 수 있습니다. 하지만 worktree를 제거해도 세션 데이터는 남습니다.
|
|
61
|
-
|
|
62
|
-
```bash
|
|
63
|
-
git worktree add ../feature-branch feature
|
|
64
|
-
cd ../feature-branch && claude # 세션 데이터 생성
|
|
65
|
-
|
|
66
|
-
git worktree remove ../feature-branch
|
|
67
|
-
# ~/.claude/projects/-...-feature-branch/ 가 그대로 남음
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
**watch 모드를 사용하면** 자동으로 정리됩니다:
|
|
71
|
-
|
|
72
|
-
```bash
|
|
73
|
-
ai-session-tidy watch start # 한 번 실행, 로그인 시 자동 시작
|
|
74
|
-
|
|
75
|
-
git worktree remove ../feature # watch가 감지 → 5분 후 정리
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
### 멀티 에이전트 오케스트레이션
|
|
79
|
-
|
|
80
|
-
[최신 AI 워크플로우](https://www.anthropic.com/engineering/multi-agent-research-system)는 여러 에이전트를 병렬로 실행하며, 각각 격리된 worktree에서 작업합니다.
|
|
81
|
-
|
|
82
|
-
이로 인해 세션 데이터 축적이 배가됩니다. watch 모드가 시스템을 자동으로 깔끔하게 유지합니다.
|
|
83
|
-
|
|
84
|
-
## 지원 도구
|
|
85
|
-
|
|
86
|
-
| 도구 | 상태 |
|
|
87
|
-
|-----|------|
|
|
88
|
-
| Claude Code | ✅ 지원 |
|
|
89
|
-
| Cursor | ✅ 지원 |
|
|
90
|
-
|
|
91
|
-
## 명령어
|
|
92
|
-
|
|
93
|
-
### `scan` (기본)
|
|
94
|
-
|
|
95
|
-
삭제 없이 방치된 세션을 찾습니다. `ai-session-tidy`만 실행하면 `ai-session-tidy scan`과 동일합니다.
|
|
96
|
-
|
|
97
|
-
```bash
|
|
98
|
-
ai-session-tidy # 기본 스캔
|
|
99
|
-
ai-session-tidy -v # 상세 출력
|
|
100
|
-
ai-session-tidy --json # JSON 출력
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
<details>
|
|
104
|
-
<summary><code>-v</code> 출력 예시</summary>
|
|
105
|
-
|
|
106
|
-
```
|
|
107
|
-
⚠ Found 2 session folder(s) + 1 config entry(ies) + 3 session-env folder(s) (156.2 MB)
|
|
108
|
-
|
|
109
|
-
┌─────────────┬──────────┬────────┬─────┬───────┬─────────┬──────────┬───────────┐
|
|
110
|
-
│ Tool │ Sessions │ Config │ Env │ Todos │ History │ Size │ Scan Time │
|
|
111
|
-
├─────────────┼──────────┼────────┼─────┼───────┼─────────┼──────────┼───────────┤
|
|
112
|
-
│ claude-code │ 2 │ 1 │ 3 │ - │ - │ 156.2 MB │ 45ms │
|
|
113
|
-
└─────────────┴──────────┴────────┴─────┴───────┴─────────┴──────────┴───────────┘
|
|
114
|
-
|
|
115
|
-
Session Folders:
|
|
116
|
-
|
|
117
|
-
[claude-code] deleted-project (128.5 MB)
|
|
118
|
-
→ /Users/you/deleted-project
|
|
119
|
-
Modified: 1/15/2025
|
|
120
|
-
|
|
121
|
-
[claude-code] old-worktree (27.7 MB)
|
|
122
|
-
→ /Users/you/old-worktree
|
|
123
|
-
Modified: 1/10/2025
|
|
124
|
-
|
|
125
|
-
Config Entries (~/.claude.json):
|
|
126
|
-
|
|
127
|
-
[config] deleted-project
|
|
128
|
-
→ /Users/you/deleted-project
|
|
129
|
-
Cost: $1.25 | Tokens: 150K in / 12K out
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
</details>
|
|
133
|
-
|
|
134
|
-
### `clean`
|
|
135
|
-
|
|
136
|
-
방치된 세션을 삭제합니다.
|
|
137
|
-
|
|
138
|
-
```bash
|
|
139
|
-
ai-session-tidy clean # 휴지통으로 이동 (확인 필요)
|
|
140
|
-
ai-session-tidy clean -i # 대화형 선택 (TTY 필요)
|
|
141
|
-
ai-session-tidy clean -f # 확인 생략 (스크립트/CI용)
|
|
142
|
-
ai-session-tidy clean -n # 드라이런 (삭제 대상만 표시)
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-

|
|
146
|
-
|
|
147
|
-
### `watch`
|
|
148
|
-
|
|
149
|
-
감시하고 자동으로 정리합니다.
|
|
150
|
-
|
|
151
|
-
```bash
|
|
152
|
-
ai-session-tidy watch # 포그라운드 모드
|
|
153
|
-
ai-session-tidy watch start # 백그라운드 데몬 (로그인 시 자동 시작)
|
|
154
|
-
ai-session-tidy watch stop # 데몬 중지
|
|
155
|
-
ai-session-tidy watch status # 상태 확인
|
|
156
|
-
ai-session-tidy watch status -l # 최근 로그 표시
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
### `config`
|
|
160
|
-
|
|
161
|
-
설정을 관리합니다.
|
|
162
|
-
|
|
163
|
-
```bash
|
|
164
|
-
ai-session-tidy config show # 전체 설정 보기
|
|
165
|
-
ai-session-tidy config path add ~/projects # 감시 경로 추가
|
|
166
|
-
ai-session-tidy config path list # 감시 경로 목록
|
|
167
|
-
ai-session-tidy config ignore add ~/backup # 제외 경로 추가
|
|
168
|
-
ai-session-tidy config ignore list # 제외 경로 목록
|
|
169
|
-
ai-session-tidy config delay 1 # 정리 딜레이 설정 (분)
|
|
170
|
-
ai-session-tidy config depth 5 # 감시 깊이 설정
|
|
171
|
-
ai-session-tidy config reset # 기본값으로 초기화
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
> [!TIP]
|
|
175
|
-
> 숨김 폴더 (`.git`, `.cache` 등)와 macOS 시스템 폴더 (`Library`, `Music` 등)는 자동으로 제외됩니다.
|
|
176
|
-
|
|
177
|
-
## 정리 대상
|
|
178
|
-
|
|
179
|
-
### Claude Code
|
|
180
|
-
|
|
181
|
-
| 위치 | 설명 | 조건 |
|
|
182
|
-
|-----|------|-----|
|
|
183
|
-
| `~/.claude/projects/{path}/` | 세션 폴더 | 프로젝트 삭제됨 |
|
|
184
|
-
| `~/.claude.json` | Config 항목 | 프로젝트 삭제됨 |
|
|
185
|
-
| `~/.claude/session-env/{uuid}/` | 세션 환경 | 빈 폴더 |
|
|
186
|
-
| `~/.claude/todos/{uuid}-*.json` | Todo 파일 | 세션 없음 |
|
|
187
|
-
| `~/.claude/file-history/{uuid}/` | Rewind 스냅샷 | 세션 없음 |
|
|
188
|
-
|
|
189
|
-
### Cursor
|
|
190
|
-
|
|
191
|
-
| 위치 | 설명 | 조건 |
|
|
192
|
-
|-----|------|-----|
|
|
193
|
-
| `~/Library/.../workspaceStorage/{hash}/` | 워크스페이스 데이터 | 프로젝트 삭제됨 |
|
|
194
|
-
|
|
195
|
-
## 안전장치
|
|
196
|
-
|
|
197
|
-
> [!NOTE]
|
|
198
|
-
> 모든 작업은 기본적으로 안전합니다—명시적 조치 없이는 영구 삭제되지 않습니다.
|
|
199
|
-
|
|
200
|
-
- **스캔은 읽기 전용** — `scan`은 아무것도 삭제하지 않음
|
|
201
|
-
- **휴지통 우선** — `clean`은 휴지통으로 이동 (복구 가능)
|
|
202
|
-
- **확인 필요** — `-f` 없이는 삭제 전 확인
|
|
203
|
-
- **5분 딜레이** — watch 모드는 정리 전 대기 (설정 가능)
|
|
204
|
-
|
|
205
|
-
## 개발
|
|
206
|
-
|
|
207
|
-
```bash
|
|
208
|
-
git clone https://github.com/sooink/ai-session-tidy.git
|
|
209
|
-
cd ai-session-tidy
|
|
210
|
-
pnpm install
|
|
211
|
-
pnpm build
|
|
212
|
-
pnpm test
|
|
213
|
-
```
|