agent-session-kill 0.1.0
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/LICENSE +21 -0
- package/README.md +163 -0
- package/package.json +48 -0
- package/src/age.js +23 -0
- package/src/apply.js +115 -0
- package/src/cli.js +213 -0
- package/src/delegates.js +31 -0
- package/src/format.js +114 -0
- package/src/interactive.js +215 -0
- package/src/protection.js +141 -0
- package/src/scanner.js +217 -0
- package/src/tui-state.js +114 -0
package/src/format.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*m/g;
|
|
4
|
+
|
|
5
|
+
function normalizeResults(input) {
|
|
6
|
+
if (Array.isArray(input)) {
|
|
7
|
+
return { entries: input, errors: [] };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
entries: Array.isArray(input?.entries) ? input.entries : [],
|
|
12
|
+
errors: Array.isArray(input?.errors) ? input.errors : [],
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function decorateEntry(entry) {
|
|
17
|
+
const protectedRow = Boolean(entry.protected);
|
|
18
|
+
const cacheRisk = entry.category === "cache";
|
|
19
|
+
const delegateRisk = entry.action === "delegate";
|
|
20
|
+
const risk = protectedRow ? "protected" : delegateRisk ? "delegate" : cacheRisk ? "cache" : "none";
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
...entry,
|
|
24
|
+
selectable: !protectedRow && (entry.action === "trash" || entry.action === "delete"),
|
|
25
|
+
risk,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function decorateResults(input) {
|
|
30
|
+
const results = normalizeResults(input);
|
|
31
|
+
return { ...results, entries: results.entries.map(decorateEntry) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function colorAction(value, color) {
|
|
35
|
+
if (!color) return value;
|
|
36
|
+
if (value === "trash") return chalk.yellow(value);
|
|
37
|
+
if (value === "delete") return chalk.red(value);
|
|
38
|
+
if (value === "keep") return chalk.dim(value);
|
|
39
|
+
if (value === "delegate") return chalk.cyan(value);
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function colorRisk(value, color) {
|
|
44
|
+
if (!color) return value;
|
|
45
|
+
if (value === "protected") return chalk.red(value);
|
|
46
|
+
if (value === "delegate") return chalk.cyan(value);
|
|
47
|
+
if (value === "cache") return chalk.yellow(value);
|
|
48
|
+
if (value === "none") return chalk.dim(value);
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function valueFor(entry, column, color) {
|
|
53
|
+
if (column.key === "modified") {
|
|
54
|
+
return Number.isFinite(entry.modifiedMs) ? new Date(entry.modifiedMs).toISOString() : "";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (column.key === "action") {
|
|
58
|
+
return colorAction(String(entry.action ?? ""), color);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (column.key === "risk") {
|
|
62
|
+
return colorRisk(String(entry.risk ?? ""), color);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const value = entry[column.key];
|
|
66
|
+
return value === undefined || value === null ? "" : String(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function visibleLength(value) {
|
|
70
|
+
return value.replace(ANSI_ESCAPE_PATTERN, "").length;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function formatTable(input, { color } = {}) {
|
|
74
|
+
const results = decorateResults(input);
|
|
75
|
+
const useColor = color !== false;
|
|
76
|
+
const columns = [
|
|
77
|
+
{ key: "tool", label: "Tool" },
|
|
78
|
+
{ key: "category", label: "Category" },
|
|
79
|
+
{ key: "action", label: "Action" },
|
|
80
|
+
{ key: "risk", label: "Risk" },
|
|
81
|
+
{ key: "sizeBytes", label: "Bytes" },
|
|
82
|
+
{ key: "modified", label: "Modified" },
|
|
83
|
+
{ key: "path", label: "Path" },
|
|
84
|
+
{ key: "reason", label: "Reason" },
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const rows = results.entries.map((entry) => columns.map((column) => valueFor(entry, column, useColor)));
|
|
88
|
+
const widths = columns.map((column, index) =>
|
|
89
|
+
Math.max(column.label.length, ...rows.map((row) => visibleLength(row[index]))),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const renderRow = (cells) =>
|
|
93
|
+
cells
|
|
94
|
+
.map((cell, index) => `${cell}${" ".repeat(Math.max(0, widths[index] - visibleLength(cell)))}`)
|
|
95
|
+
.join(" ")
|
|
96
|
+
.trimEnd();
|
|
97
|
+
|
|
98
|
+
const lines = [
|
|
99
|
+
renderRow(columns.map((column) => column.label)),
|
|
100
|
+
renderRow(widths.map((width) => "-".repeat(width))),
|
|
101
|
+
...rows.map(renderRow),
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
if (results.errors.length > 0) {
|
|
105
|
+
lines.push("", "Errors:", ...results.errors.map((error) => `- ${error}`));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return lines.join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function formatJson(entries) {
|
|
112
|
+
return `${JSON.stringify(normalizeResults(entries), null, 2)}\n`;
|
|
113
|
+
}
|
|
114
|
+
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { scanRemnants } from "./scanner.js";
|
|
5
|
+
import { applyManifest } from "./apply.js";
|
|
6
|
+
import { createTuiState, handleTuiKey, selectedRows, visibleRows } from "./tui-state.js";
|
|
7
|
+
|
|
8
|
+
function formatBytes(value) {
|
|
9
|
+
const size = Number.isFinite(value) ? Math.max(0, value) : 0;
|
|
10
|
+
if (size < 1024) return `${size} B`;
|
|
11
|
+
if (size < 1024 ** 2) return `${(size / 1024).toFixed(1)} KB`;
|
|
12
|
+
if (size < 1024 ** 3) return `${(size / (1024 ** 2)).toFixed(1)} MB`;
|
|
13
|
+
return `${(size / (1024 ** 3)).toFixed(1)} GB`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function colorAction(action) {
|
|
17
|
+
if (action === "delete") return chalk.red(action);
|
|
18
|
+
if (action === "trash") return chalk.yellow(action);
|
|
19
|
+
if (action === "delegate") return chalk.cyan(action);
|
|
20
|
+
return chalk.dim(action);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function colorRisk(risk) {
|
|
24
|
+
if (risk === "protected") return chalk.red(risk);
|
|
25
|
+
if (risk === "cache") return chalk.yellow(risk);
|
|
26
|
+
if (risk === "delegate") return chalk.cyan(risk);
|
|
27
|
+
return chalk.dim(risk);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function renderRow(row, active, selected) {
|
|
31
|
+
const pointer = active ? chalk.cyan("›") : " ";
|
|
32
|
+
const marker = selected ? chalk.green("●") : row.selectable ? "○" : "·";
|
|
33
|
+
const tool = String(row.tool ?? "").padEnd(6);
|
|
34
|
+
const category = String(row.category ?? "").padEnd(16);
|
|
35
|
+
const actionText = colorAction(String(row.action ?? "")).padEnd(8);
|
|
36
|
+
const size = formatBytes(row.sizeBytes).padStart(9);
|
|
37
|
+
const risk = colorRisk(String(row.risk ?? "none")).padEnd(9);
|
|
38
|
+
const line = `${pointer} ${marker} ${tool} ${category} ${actionText} ${size} ${risk} ${row.path ?? ""}`;
|
|
39
|
+
|
|
40
|
+
return row.selectable ? line : chalk.dim(line);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function render(state, io) {
|
|
44
|
+
const rows = visibleRows(state);
|
|
45
|
+
const windowSize = 20;
|
|
46
|
+
const start = Math.max(0, Math.min(state.cursor - Math.floor(windowSize / 2), Math.max(0, rows.length - windowSize)));
|
|
47
|
+
const end = Math.min(rows.length, start + windowSize);
|
|
48
|
+
|
|
49
|
+
io.stdout.write("\x1b[2J\x1b[H");
|
|
50
|
+
io.stdout.write(`${chalk.bold("Agent Session Kill")}\n`);
|
|
51
|
+
io.stdout.write(`${chalk.dim("↑/↓ j/k move · space toggle · a all · d/delete apply selected · r rescan · q/esc quit")}\n\n`);
|
|
52
|
+
|
|
53
|
+
if (rows.length === 0) {
|
|
54
|
+
io.stdout.write(`${chalk.dim("No rows match the current filter.")}\n`);
|
|
55
|
+
} else {
|
|
56
|
+
for (let index = start; index < end; index += 1) {
|
|
57
|
+
const row = rows[index];
|
|
58
|
+
io.stdout.write(`${renderRow(row, index === state.cursor, state.selected.has(row.path))}\n`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
io.stdout.write(`\nSelected: ${selectedRows(state).length} / ${rows.filter((row) => row.selectable).length}\n`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function confirmDelete(rows, io) {
|
|
66
|
+
io.stdout.write("\nSelected paths:\n");
|
|
67
|
+
for (const row of rows) {
|
|
68
|
+
io.stdout.write(`- ${row.path}\n`);
|
|
69
|
+
}
|
|
70
|
+
io.stdout.write("Type delete to confirm: ");
|
|
71
|
+
|
|
72
|
+
return await new Promise((resolve) => {
|
|
73
|
+
const rl = readline.createInterface({
|
|
74
|
+
input: io.stdin,
|
|
75
|
+
output: io.stdout,
|
|
76
|
+
terminal: Boolean(io.stdin?.isTTY && io.stdout?.isTTY),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
rl.question("", (answer) => {
|
|
80
|
+
rl.close();
|
|
81
|
+
resolve(answer.trim().toLowerCase() === "delete");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function asScanOptions(options) {
|
|
87
|
+
return {
|
|
88
|
+
homeDir: options.homeDir,
|
|
89
|
+
tempDir: options.tempDir,
|
|
90
|
+
nowMs: Date.now(),
|
|
91
|
+
olderThanMs: options.olderThanMs,
|
|
92
|
+
includeCache: options.includeCache,
|
|
93
|
+
apply: false,
|
|
94
|
+
permanent: options.permanent,
|
|
95
|
+
tools: options.tools,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function maybeSetRawMode(input, value) {
|
|
100
|
+
if (input?.isTTY && typeof input.setRawMode === "function") {
|
|
101
|
+
input.setRawMode(value);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function runInteractive(
|
|
109
|
+
options,
|
|
110
|
+
io = { stdin: process.stdin, stdout: process.stdout, stderr: process.stderr },
|
|
111
|
+
) {
|
|
112
|
+
const streams = {
|
|
113
|
+
stdin: io.stdin ?? process.stdin,
|
|
114
|
+
stdout: io.stdout ?? process.stdout,
|
|
115
|
+
stderr: io.stderr ?? process.stderr,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
let scan = await scanRemnants(asScanOptions(options));
|
|
119
|
+
let state = createTuiState(scan.entries);
|
|
120
|
+
|
|
121
|
+
let rawModeEnabled = false;
|
|
122
|
+
let busy = false;
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
readline.emitKeypressEvents(streams.stdin);
|
|
126
|
+
if (typeof streams.stdin.resume === "function") {
|
|
127
|
+
streams.stdin.resume();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
rawModeEnabled = maybeSetRawMode(streams.stdin, true);
|
|
131
|
+
render(state, streams);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (rawModeEnabled) {
|
|
134
|
+
maybeSetRawMode(streams.stdin, false);
|
|
135
|
+
}
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return await new Promise((resolve) => {
|
|
140
|
+
const finish = (code) => {
|
|
141
|
+
if (rawModeEnabled) {
|
|
142
|
+
maybeSetRawMode(streams.stdin, false);
|
|
143
|
+
}
|
|
144
|
+
streams.stdin.off("keypress", onKeypress);
|
|
145
|
+
resolve(code);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const onKeypress = async (_chunk, key = {}) => {
|
|
149
|
+
if (busy) return;
|
|
150
|
+
busy = true;
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
state = handleTuiKey(state, key);
|
|
154
|
+
|
|
155
|
+
if (state.intent === "quit") {
|
|
156
|
+
finish(0);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (state.intent === "rescan") {
|
|
161
|
+
scan = await scanRemnants(asScanOptions(options));
|
|
162
|
+
state = createTuiState(scan.entries);
|
|
163
|
+
render(state, streams);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (state.intent === "delete-selected") {
|
|
168
|
+
const rows = selectedRows(state);
|
|
169
|
+
if (rows.length === 0) {
|
|
170
|
+
render(state, streams);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (rawModeEnabled) {
|
|
175
|
+
maybeSetRawMode(streams.stdin, false);
|
|
176
|
+
rawModeEnabled = false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const confirmed = await confirmDelete(rows, streams);
|
|
180
|
+
if (!confirmed) {
|
|
181
|
+
rawModeEnabled = maybeSetRawMode(streams.stdin, true);
|
|
182
|
+
state = { ...state, intent: "none" };
|
|
183
|
+
render(state, streams);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const applyResult = await applyManifest({ entries: rows, errors: [] }, {
|
|
188
|
+
homeDir: options.homeDir,
|
|
189
|
+
permanent: options.permanent,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if (applyResult.errors.length > 0) {
|
|
193
|
+
for (const error of applyResult.errors) {
|
|
194
|
+
streams.stderr.write(`${error}\n`);
|
|
195
|
+
}
|
|
196
|
+
finish(1);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
finish(0);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
render(state, streams);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
streams.stderr.write(`${error.message}\n`);
|
|
207
|
+
finish(1);
|
|
208
|
+
} finally {
|
|
209
|
+
busy = false;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
streams.stdin.on("keypress", onKeypress);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
const PROTECTED_ROOT_NAMES = new Set([
|
|
4
|
+
"plugins",
|
|
5
|
+
"skills",
|
|
6
|
+
"agents",
|
|
7
|
+
"commands",
|
|
8
|
+
"managed-skills",
|
|
9
|
+
"memories",
|
|
10
|
+
"model",
|
|
11
|
+
"models",
|
|
12
|
+
"mcp",
|
|
13
|
+
"npm",
|
|
14
|
+
"projects",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const PROTECTED_FILE_PATTERNS = [
|
|
18
|
+
/^auth\b/i,
|
|
19
|
+
/^settings\b/i,
|
|
20
|
+
/^config\b/i,
|
|
21
|
+
/^model(s)?\b/i,
|
|
22
|
+
/^mcp\b/i,
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function absolute(input) {
|
|
26
|
+
return path.resolve(input);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isInside(candidate, root) {
|
|
30
|
+
if (!root) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const relative = path.relative(absolute(root), absolute(candidate));
|
|
35
|
+
return relative === "" || (relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function firstSegment(candidate, root) {
|
|
39
|
+
if (!isInside(candidate, root)) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const relative = path.relative(absolute(root), absolute(candidate));
|
|
44
|
+
return relative.split(path.sep)[0];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function basename(candidate) {
|
|
48
|
+
return path.basename(absolute(candidate));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function matchesProtectedFile(candidate) {
|
|
52
|
+
const name = basename(candidate);
|
|
53
|
+
return PROTECTED_FILE_PATTERNS.some((pattern) => pattern.test(name));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isProtectedUnderRoot(candidate, root) {
|
|
57
|
+
const segment = firstSegment(candidate, root);
|
|
58
|
+
if (!segment) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return PROTECTED_ROOT_NAMES.has(segment) || matchesProtectedFile(candidate);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function classifyRemnant(candidate, roots) {
|
|
66
|
+
const remnantRoots = [
|
|
67
|
+
[roots.claudeDir, "session-env", "session-env"],
|
|
68
|
+
[roots.claudeDir, "tasks", "tasks"],
|
|
69
|
+
[roots.claudeDir, "todos", "todos"],
|
|
70
|
+
[roots.piAgentDir, "sessions", "sessions"],
|
|
71
|
+
[roots.ompAgentDir, "sessions", "sessions"],
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
for (const [root, segment, category] of remnantRoots) {
|
|
75
|
+
if (isInside(candidate, path.join(root, segment))) {
|
|
76
|
+
return category;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (isPiSubagentsAsync(candidate, roots.tempDir)) {
|
|
81
|
+
return "pi-subagents-async";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isPiSubagentsAsync(candidate, tempDir) {
|
|
88
|
+
if (!isInside(candidate, tempDir)) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const relative = path.relative(absolute(tempDir), absolute(candidate));
|
|
93
|
+
const parts = relative.split(path.sep);
|
|
94
|
+
return parts[0]?.startsWith("pi-subagents-") && parts[1] === "async-subagent-runs";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isCachePath(candidate, roots) {
|
|
98
|
+
return [
|
|
99
|
+
path.join(roots.claudeDir, "cache"),
|
|
100
|
+
path.join(roots.piAgentDir, "cache"),
|
|
101
|
+
path.join(roots.ompDir, "cache"),
|
|
102
|
+
path.join(roots.ompAgentDir, "cache"),
|
|
103
|
+
].some((root) => isInside(candidate, root));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function isProtectedPath(candidate, roots) {
|
|
107
|
+
if (absolute(candidate) === path.join(absolute(roots.homeDir), ".claude.json")) {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (classifyRemnant(candidate, roots)) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return [roots.claudeDir, roots.piAgentDir, roots.ompAgentDir].some((root) => isProtectedUnderRoot(candidate, root));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function classifyPath(candidate, roots, includeCache) {
|
|
119
|
+
const remnantCategory = classifyRemnant(candidate, roots);
|
|
120
|
+
if (remnantCategory) {
|
|
121
|
+
return {
|
|
122
|
+
category: remnantCategory,
|
|
123
|
+
cache: false,
|
|
124
|
+
protected: false,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (isCachePath(candidate, roots)) {
|
|
129
|
+
return {
|
|
130
|
+
category: "cache",
|
|
131
|
+
cache: true,
|
|
132
|
+
protected: !includeCache,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
category: "unknown",
|
|
138
|
+
cache: false,
|
|
139
|
+
protected: isProtectedPath(candidate, roots),
|
|
140
|
+
};
|
|
141
|
+
}
|
package/src/scanner.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { lstat, readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isOlderThan } from "./age.js";
|
|
4
|
+
import { classifyPath, isProtectedPath } from "./protection.js";
|
|
5
|
+
|
|
6
|
+
function buildRoots(options) {
|
|
7
|
+
const homeDir = path.resolve(options.homeDir);
|
|
8
|
+
const tempDir = path.resolve(options.tempDir);
|
|
9
|
+
const claudeDir = path.join(homeDir, ".claude");
|
|
10
|
+
const piAgentDir = path.join(homeDir, ".pi", "agent");
|
|
11
|
+
const ompDir = path.join(homeDir, ".omp");
|
|
12
|
+
const ompAgentDir = path.join(ompDir, "agent");
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
homeDir,
|
|
16
|
+
tempDir,
|
|
17
|
+
claudeDir,
|
|
18
|
+
piAgentDir,
|
|
19
|
+
ompDir,
|
|
20
|
+
ompAgentDir,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function selected(options, tool) {
|
|
25
|
+
return !options.tools || options.tools.has(tool);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function staticCandidates(options, roots) {
|
|
29
|
+
const candidates = [];
|
|
30
|
+
|
|
31
|
+
if (selected(options, "claude")) {
|
|
32
|
+
for (const category of ["session-env", "tasks", "todos", "plans", "debug", "cache", "paste-cache", "shell-snapshots", "backups"]) {
|
|
33
|
+
candidates.push({ tool: "claude", category, root: path.join(roots.claudeDir, category) });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (selected(options, "pi")) {
|
|
38
|
+
candidates.push(
|
|
39
|
+
{ tool: "pi", category: "sessions", root: path.join(roots.piAgentDir, "sessions") },
|
|
40
|
+
{ tool: "pi", category: "tmp", root: path.join(roots.piAgentDir, "tmp") },
|
|
41
|
+
{ tool: "pi", category: "session-search-index", root: path.join(roots.homeDir, ".pi", "session-search", "index") },
|
|
42
|
+
{ tool: "pi", category: "cache", root: path.join(roots.piAgentDir, "cache") },
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (selected(options, "omp")) {
|
|
47
|
+
candidates.push(
|
|
48
|
+
{ tool: "omp", category: "sessions", root: path.join(roots.ompAgentDir, "sessions") },
|
|
49
|
+
{ tool: "omp", category: "terminal-sessions", root: path.join(roots.ompAgentDir, "terminal-sessions") },
|
|
50
|
+
{ tool: "omp", category: "logs", root: path.join(roots.ompDir, "logs") },
|
|
51
|
+
{ tool: "omp", category: "blobs", root: path.join(roots.ompAgentDir, "blobs") },
|
|
52
|
+
{ tool: "omp", category: "cache", root: path.join(roots.ompDir, "cache") },
|
|
53
|
+
{ tool: "omp", category: "cache", root: path.join(roots.ompAgentDir, "cache") },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return candidates;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function tempCandidates(options, roots, errors) {
|
|
61
|
+
if (!selected(options, "temp")) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let dirents;
|
|
66
|
+
try {
|
|
67
|
+
dirents = await readdir(roots.tempDir, { withFileTypes: true });
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error?.code !== "ENOENT") {
|
|
70
|
+
errors.push(`Failed to read ${roots.tempDir}: ${error.message}`);
|
|
71
|
+
}
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const candidates = [];
|
|
76
|
+
for (const dirent of dirents) {
|
|
77
|
+
if (!dirent.isDirectory() || dirent.isSymbolicLink() || !dirent.name.startsWith("pi-subagents-")) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const root = path.join(roots.tempDir, dirent.name);
|
|
82
|
+
candidates.push(
|
|
83
|
+
{ tool: "temp", category: "pi-subagents-chain", root: path.join(root, "chain-runs") },
|
|
84
|
+
{ tool: "temp", category: "pi-subagents-async", root: path.join(root, "async-subagent-runs") },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return candidates;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function reasonFor({ cacheOptOut, protectedPath, oldEnough }) {
|
|
92
|
+
if (cacheOptOut) {
|
|
93
|
+
return "cache cleanup opt-out";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (protectedPath) {
|
|
97
|
+
return "protected path";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!oldEnough) {
|
|
101
|
+
return "younger than threshold";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return "older than threshold";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function entryFor(filePath, stats, candidate, roots, options) {
|
|
108
|
+
const classification = classifyPath(filePath, roots, options.includeCache);
|
|
109
|
+
const cacheOptOut = classification.cache && !options.includeCache;
|
|
110
|
+
const protectedPath = classification.protected || isProtectedPath(filePath, roots);
|
|
111
|
+
const oldEnough = isOlderThan(stats.mtimeMs, options.nowMs, options.olderThanMs);
|
|
112
|
+
const action = protectedPath || cacheOptOut || !oldEnough ? "keep" : options.permanent ? "delete" : "trash";
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
tool: candidate.tool,
|
|
116
|
+
category: classification.category === "unknown" ? candidate.category : classification.category,
|
|
117
|
+
path: filePath,
|
|
118
|
+
type: "file",
|
|
119
|
+
sizeBytes: stats.size,
|
|
120
|
+
modifiedMs: stats.mtimeMs,
|
|
121
|
+
action,
|
|
122
|
+
reason: reasonFor({ cacheOptOut, protectedPath, oldEnough }),
|
|
123
|
+
protected: protectedPath,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function walkCandidate(candidate, roots, options, entries, errors) {
|
|
128
|
+
let stats;
|
|
129
|
+
try {
|
|
130
|
+
stats = await lstat(candidate.root);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error?.code !== "ENOENT") {
|
|
133
|
+
errors.push(`Failed to stat ${candidate.root}: ${error.message}`);
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (stats.isSymbolicLink()) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (stats.isFile()) {
|
|
143
|
+
entries.push(entryFor(candidate.root, stats, candidate, roots, options));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!stats.isDirectory()) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
await walkDirectory(candidate.root, candidate, roots, options, entries, errors);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function walkDirectory(directory, candidate, roots, options, entries, errors) {
|
|
155
|
+
let dirents;
|
|
156
|
+
try {
|
|
157
|
+
dirents = await readdir(directory, { withFileTypes: true });
|
|
158
|
+
} catch (error) {
|
|
159
|
+
errors.push(`Failed to read ${directory}: ${error.message}`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
dirents.sort((a, b) => a.name.localeCompare(b.name));
|
|
164
|
+
|
|
165
|
+
for (const dirent of dirents) {
|
|
166
|
+
const childPath = path.join(directory, dirent.name);
|
|
167
|
+
|
|
168
|
+
if (dirent.isSymbolicLink()) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (dirent.isDirectory()) {
|
|
173
|
+
await walkDirectory(childPath, candidate, roots, options, entries, errors);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!dirent.isFile()) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let stats;
|
|
182
|
+
try {
|
|
183
|
+
stats = await lstat(childPath);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
errors.push(`Failed to stat ${childPath}: ${error.message}`);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!stats.isSymbolicLink() && stats.isFile()) {
|
|
190
|
+
entries.push(entryFor(childPath, stats, candidate, roots, options));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function scanRemnants(options) {
|
|
196
|
+
const roots = buildRoots(options);
|
|
197
|
+
const errors = [];
|
|
198
|
+
const entries = [];
|
|
199
|
+
const candidates = [
|
|
200
|
+
...staticCandidates(options, roots),
|
|
201
|
+
...(await tempCandidates(options, roots, errors)),
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
for (const candidate of candidates) {
|
|
205
|
+
await walkCandidate(candidate, roots, options, entries, errors);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
209
|
+
|
|
210
|
+
const result = { entries, errors };
|
|
211
|
+
Object.defineProperty(result, "find", {
|
|
212
|
+
value: entries.find.bind(entries),
|
|
213
|
+
enumerable: false,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return result;
|
|
217
|
+
}
|