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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kyle Brodeur
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,163 @@
1
+ # Agent Session Kill
2
+
3
+ NPKILL-style cleanup for AI coding-agent session remnants.
4
+
5
+ `agent-session-kill` scans local Claude Code, Pi/pi-mono, Oh My Pi (OMP), and subagent temp storage, then lets you review and delete stale session artifacts from an interactive terminal UI.
6
+
7
+ ## Why
8
+
9
+ Agent harnesses save transcripts, tool outputs, task state, shell snapshots, logs, temp subagent runs, and per-project session files. Those files are useful while work is active, but they pile up.
10
+
11
+ Agent Session Kill gives you a single cleanup surface with conservative defaults:
12
+
13
+ - interactive picker by default, inspired by `npkill`
14
+ - dry-run and JSON modes for scripts
15
+ - protected paths for auth, settings, plugins, skills, memory, and model config
16
+ - cache cleanup is opt-in
17
+ - trash-first deletion by default
18
+ - explicit confirmation before interactive deletion
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ npm install -g agent-session-kill
24
+ ```
25
+
26
+ Or run without installing:
27
+
28
+ ```bash
29
+ npx agent-session-kill
30
+ ```
31
+
32
+ ## Commands
33
+
34
+ ```bash
35
+ agent-session-kill # interactive picker
36
+ agentkill # short alias
37
+ agent-session-kill interactive # explicit picker
38
+ agent-session-kill scan --older-than 14d # non-interactive inventory
39
+ agent-session-kill scan --json # machine-readable inventory
40
+ agent-session-kill clean --dry-run # non-interactive dry run
41
+ agent-session-kill clean --apply # apply non-interactive manifest
42
+ ```
43
+
44
+ ## Interactive controls
45
+
46
+ | Key | Action |
47
+ | --- | --- |
48
+ | `↑` / `k` | Move up |
49
+ | `↓` / `j` | Move down |
50
+ | `PgUp` / `Ctrl+u` | Page up |
51
+ | `PgDn` / `Ctrl+d` | Page down |
52
+ | `Home` / `End` | Jump to first/last row |
53
+ | `Space` | Toggle current selectable row |
54
+ | `a` | Toggle all selectable visible rows |
55
+ | `d` / `Del` | Delete selected rows after confirmation |
56
+ | `r` | Rescan |
57
+ | `q` / `Esc` | Quit without changes |
58
+
59
+ Rows that are protected or kept are visible but not selectable.
60
+
61
+ ## Options
62
+
63
+ | Option | Description | Default |
64
+ | --- | --- | --- |
65
+ | `--older-than <duration>` | Minimum age for stale artifacts. Supports `m`, `h`, `d`. | `14d` |
66
+ | `--tool <name>` | Limit to `claude`, `pi`, `omp`, or `temp`. Repeat or comma-separate. | all |
67
+ | `--include-cache` | Include cache roots as cleanup candidates. | off |
68
+ | `--json` | Print JSON. Always non-interactive. | off |
69
+ | `--delegates` | Run delegated dry-runs (`claude project purge`, `omp worktree clear`). | off |
70
+ | `--home <path>` | Home directory to scan. | current user home |
71
+ | `--temp <path>` | Temp directory to scan. | OS temp dir |
72
+ | `--dry-run` | Force dry-run mode. | off |
73
+ | `--apply` | Apply cleanup in `clean` mode. | off |
74
+ | `--permanent` | Permanently delete instead of trashing. | off |
75
+
76
+ ## What it scans
77
+
78
+ ### Claude Code
79
+
80
+ - `~/.claude/session-env/`
81
+ - `~/.claude/tasks/`
82
+ - `~/.claude/plans/`
83
+ - `~/.claude/debug/`
84
+ - `~/.claude/paste-cache/`
85
+ - `~/.claude/shell-snapshots/`
86
+ - `~/.claude/backups/`
87
+ - `~/.claude/cache/` only when `--include-cache` is set
88
+
89
+ Claude project transcripts under `~/.claude/projects/` are protected from direct deletion. Use `--delegates` to preview the official `claude project purge` path.
90
+
91
+ ### Pi / pi-mono
92
+
93
+ - `~/.pi/agent/sessions/`
94
+ - `~/.pi/agent/tmp/`
95
+ - `~/.pi/session-search/index/`
96
+ - `~/.pi/agent/cache/` only when `--include-cache` is set
97
+
98
+ ### OMP / Oh My Pi
99
+
100
+ - `~/.omp/agent/sessions/`
101
+ - `~/.omp/agent/terminal-sessions/`
102
+ - `~/.omp/logs/`
103
+ - `~/.omp/agent/blobs/`
104
+ - `~/.omp/agent/cache/` only when `--include-cache` is set
105
+ - `~/.omp/cache/` only when `--include-cache` is set
106
+
107
+ OMP worktrees are handled through `omp worktree clear --dry-run` when delegates are enabled.
108
+
109
+ ### Subagent temp runs
110
+
111
+ - `<temp>/pi-subagents-*/chain-runs/`
112
+ - `<temp>/pi-subagents-*/async-subagent-runs/`
113
+
114
+ ## Protected paths
115
+
116
+ Agent Session Kill never deletes known auth, settings, plugin, skill, memory, model, or MCP configuration paths, even when broad options are used.
117
+
118
+ Examples:
119
+
120
+ - `~/.claude.json`
121
+ - `~/.claude/settings.json`
122
+ - `~/.claude/plugins/`
123
+ - `~/.claude/skills/`
124
+ - `~/.claude/agents/`
125
+ - `~/.claude/commands/`
126
+ - `~/.claude/projects/`
127
+ - `~/.pi/agent/auth.json`
128
+ - `~/.pi/agent/settings.json`
129
+ - `~/.pi/agent/models.json`
130
+ - `~/.pi/agent/mcp*.json`
131
+ - `~/.pi/agent/npm/`
132
+ - `~/.omp/agent/config.yml`
133
+ - `~/.omp/agent/managed-skills/`
134
+ - `~/.omp/agent/memories/`
135
+
136
+ ## Safety model
137
+
138
+ - Interactive mode requires a TTY.
139
+ - Script output uses `scan`, `clean --dry-run`, or `--json`.
140
+ - `clean` is dry-run unless `--apply` is present.
141
+ - `--dry-run` overrides `--apply` if both are supplied.
142
+ - Cache cleanup requires `--include-cache`.
143
+ - Permanent deletion requires `--permanent`.
144
+ - Interactive deletion requires typing `delete` after selected paths are printed.
145
+
146
+ ## Development
147
+
148
+ ```bash
149
+ npm install
150
+ npm test
151
+ ```
152
+
153
+ Run local smoke checks:
154
+
155
+ ```bash
156
+ node src/cli.js scan --tool claude
157
+ node src/cli.js clean --dry-run --tool claude
158
+ node src/cli.js scan --json --tool claude
159
+ ```
160
+
161
+ ## License
162
+
163
+ MIT
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "agent-session-kill",
3
+ "version": "0.1.0",
4
+ "description": "NPKILL-style cleanup for Claude, Pi, and OMP agent session remnants.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Kyle Brodeur",
8
+ "homepage": "https://github.com/kylebrodeur/agent-session-kill#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/kylebrodeur/agent-session-kill.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/kylebrodeur/agent-session-kill/issues"
15
+ },
16
+ "keywords": [
17
+ "claude-code",
18
+ "oh-my-pi",
19
+ "pi",
20
+ "sessions",
21
+ "cleanup",
22
+ "agent",
23
+ "tui",
24
+ "npkill"
25
+ ],
26
+ "bin": {
27
+ "agent-session-kill": "src/cli.js",
28
+ "agentkill": "src/cli.js"
29
+ },
30
+ "files": [
31
+ "src/*.js",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "engines": {
36
+ "node": ">=22"
37
+ },
38
+ "dependencies": {
39
+ "chalk": "^5.6.2",
40
+ "commander": "^14.0.2"
41
+ },
42
+ "scripts": {
43
+ "test": "node --test",
44
+ "scan": "node src/cli.js scan",
45
+ "interactive": "node src/cli.js",
46
+ "clean:dry-run": "node src/cli.js clean --dry-run"
47
+ }
48
+ }
package/src/age.js ADDED
@@ -0,0 +1,23 @@
1
+ const UNIT_MS = {
2
+ m: 60 * 1000,
3
+ h: 60 * 60 * 1000,
4
+ d: 24 * 60 * 60 * 1000,
5
+ };
6
+
7
+ export function parseDurationMs(input) {
8
+ const match = /^(\d+)([mhd])$/.exec(input);
9
+ if (!match) {
10
+ throw new Error(`Invalid duration: ${input}`);
11
+ }
12
+
13
+ const value = Number(match[1]);
14
+ if (!Number.isSafeInteger(value) || value <= 0) {
15
+ throw new Error(`Invalid duration: ${input}`);
16
+ }
17
+
18
+ return value * UNIT_MS[match[2]];
19
+ }
20
+
21
+ export function isOlderThan(mtimeMs, nowMs, olderThanMs) {
22
+ return nowMs - mtimeMs >= olderThanMs;
23
+ }
package/src/apply.js ADDED
@@ -0,0 +1,115 @@
1
+ import { access, cp, mkdir, rename, rm } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ function normalizeManifest(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
+ function defaultTrashDir(options) {
17
+ const homeDir = options?.homeDir ? path.resolve(options.homeDir) : os.homedir();
18
+ return path.join(homeDir, ".Trash", "agent-session-kill");
19
+ }
20
+
21
+ function trashPathFor(entry, trashDir) {
22
+ const absolutePath = path.resolve(entry.path);
23
+ const parsed = path.parse(absolutePath);
24
+ const rootSegment = parsed.root.replace(/[^a-zA-Z0-9._-]+/g, "_") || "root";
25
+ const relativePath = path.relative(parsed.root, absolutePath);
26
+
27
+ return path.join(trashDir, rootSegment, relativePath);
28
+ }
29
+
30
+ async function pathExists(candidatePath) {
31
+ try {
32
+ await access(candidatePath);
33
+ return true;
34
+ } catch (error) {
35
+ if (error?.code === "ENOENT") {
36
+ return false;
37
+ }
38
+
39
+ throw error;
40
+ }
41
+ }
42
+
43
+ async function uniqueTrashPathFor(entry, trashDir) {
44
+ const destination = trashPathFor(entry, trashDir);
45
+
46
+ if (!(await pathExists(destination))) {
47
+ return destination;
48
+ }
49
+
50
+ for (let suffix = 1; ; suffix += 1) {
51
+ const candidate = `${destination}.${suffix}`;
52
+
53
+ if (!(await pathExists(candidate))) {
54
+ return candidate;
55
+ }
56
+ }
57
+ }
58
+
59
+ function shouldSkip(entry) {
60
+ return entry.protected || entry.action === "keep" || entry.action === "delegate";
61
+ }
62
+
63
+ async function moveToTrash(entry, trashDir) {
64
+ const destination = await uniqueTrashPathFor(entry, trashDir);
65
+ await mkdir(path.dirname(destination), { recursive: true });
66
+
67
+ try {
68
+ await rename(entry.path, destination);
69
+ } catch (error) {
70
+ if (error?.code !== "EXDEV") {
71
+ throw error;
72
+ }
73
+
74
+ await cp(entry.path, destination, { recursive: true, force: false, errorOnExist: true });
75
+ await rm(entry.path, { recursive: true, force: false });
76
+ }
77
+ }
78
+
79
+ export async function applyManifest(input, options = {}) {
80
+ const manifest = normalizeManifest(input);
81
+ const result = {
82
+ deleted: 0,
83
+ trashed: 0,
84
+ skipped: 0,
85
+ errors: [...manifest.errors],
86
+ };
87
+ const trashDir = path.resolve(options.trashDir ?? defaultTrashDir(options));
88
+
89
+ for (const entry of manifest.entries) {
90
+ if (shouldSkip(entry)) {
91
+ result.skipped += 1;
92
+ continue;
93
+ }
94
+
95
+ try {
96
+ if (options.permanent || entry.action === "delete") {
97
+ await rm(entry.path, { recursive: true, force: false });
98
+ result.deleted += 1;
99
+ continue;
100
+ }
101
+
102
+ if (entry.action === "trash") {
103
+ await moveToTrash(entry, trashDir);
104
+ result.trashed += 1;
105
+ continue;
106
+ }
107
+
108
+ result.skipped += 1;
109
+ } catch (error) {
110
+ result.errors.push(`Failed to apply ${entry.action} to ${entry.path}: ${error.message}`);
111
+ }
112
+ }
113
+
114
+ return result;
115
+ }
package/src/cli.js ADDED
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ import os from "node:os";
3
+ import process from "node:process";
4
+ import { fileURLToPath } from "node:url";
5
+ import { Command } from "commander";
6
+ import { parseDurationMs } from "./age.js";
7
+ import { runDelegateDryRuns } from "./delegates.js";
8
+ import { formatJson, formatTable } from "./format.js";
9
+ import { scanRemnants } from "./scanner.js";
10
+ import { applyManifest } from "./apply.js";
11
+ import { runInteractive } from "./interactive.js";
12
+ const TOOLS = new Set(["claude", "pi", "omp", "temp"]);
13
+
14
+ function usage() {
15
+ return buildProgram().helpInformation().trimEnd();
16
+ }
17
+
18
+ function collectTools(value, previous) {
19
+ const collected = [...previous];
20
+ for (const tool of value.split(",").map((part) => part.trim()).filter(Boolean)) {
21
+ if (!TOOLS.has(tool)) {
22
+ throw new Error(`Unknown tool: ${tool}`);
23
+ }
24
+ collected.push(tool);
25
+ }
26
+ return collected;
27
+ }
28
+
29
+ function buildDefaultOptions() {
30
+ return {
31
+ olderThan: "14d",
32
+ includeCache: false,
33
+ apply: false,
34
+ permanent: false,
35
+ json: false,
36
+ delegates: false,
37
+ homeDir: os.homedir(),
38
+ tempDir: os.tmpdir(),
39
+ dryRun: false,
40
+ tools: new Set(TOOLS),
41
+ };
42
+ }
43
+
44
+ function toCliArgs(command, options) {
45
+ return { command, options };
46
+ }
47
+
48
+ export function buildProgram() {
49
+ const program = new Command();
50
+ const selectCommand = (name) => () => {
51
+ program.selectedCommand = name;
52
+ };
53
+
54
+ program
55
+ .name("agent-session-kill")
56
+ .description("NPKILL-style cleanup for Claude, Pi, and OMP agent session remnants")
57
+ .exitOverride()
58
+ .allowExcessArguments(false)
59
+ .option("--older-than <duration>", "minimum age to consider stale", "14d")
60
+ .option("--tool <name>", "limit to claude, pi, omp, or temp; repeat or comma-separate", collectTools, [])
61
+ .option("--include-cache", "include cache roots in cleanup candidates", false)
62
+ .option("--json", "print JSON instead of a table", false)
63
+ .option("--delegates", "run delegate dry-run cleanup commands", false)
64
+ .option("--home <path>", "home directory to scan", os.homedir())
65
+ .option("--temp <path>", "temp directory to scan", os.tmpdir())
66
+ .option("--dry-run", "force dry-run mode", false)
67
+ .option("--permanent", "permanently delete instead of trash", false);
68
+
69
+ program.command("interactive", { isDefault: true }).description("open the interactive picker").action(selectCommand("interactive"));
70
+ program.command("scan").description("print a non-interactive inventory").action(selectCommand("scan"));
71
+ program
72
+ .command("clean")
73
+ .description("print cleanup manifest and optionally apply")
74
+ .option("--apply", "apply cleanup changes", false)
75
+ .action(selectCommand("clean"));
76
+
77
+ return program;
78
+ }
79
+
80
+ export function parseCliArgs(argv) {
81
+ const program = buildProgram();
82
+ const defaults = buildDefaultOptions();
83
+
84
+ program.configureOutput({
85
+ writeOut() {},
86
+ writeErr() {},
87
+ outputError() {},
88
+ });
89
+
90
+ try {
91
+ program.parse(argv, { from: "user" });
92
+ } catch (error) {
93
+ if (error?.code === "commander.helpDisplayed") {
94
+ return toCliArgs("interactive", { ...defaults, help: true });
95
+ }
96
+ throw new Error(error.message);
97
+ }
98
+
99
+ const parsed = program.opts();
100
+ const cleanCommand = program.commands.find((command) => command.name() === "clean");
101
+ const cleanOptions = cleanCommand ? cleanCommand.opts() : {};
102
+ const command = program.selectedCommand ?? "interactive";
103
+ const options = {
104
+ ...defaults,
105
+ olderThan: parsed.olderThan,
106
+ includeCache: parsed.includeCache,
107
+ apply: command === "clean" ? Boolean(cleanOptions.apply) : false,
108
+ permanent: parsed.permanent,
109
+ json: parsed.json,
110
+ delegates: parsed.delegates,
111
+ homeDir: parsed.home,
112
+ tempDir: parsed.temp,
113
+ dryRun: parsed.dryRun,
114
+ tools: parsed.tool.length > 0 ? new Set(parsed.tool) : new Set(TOOLS),
115
+ };
116
+
117
+ if (options.dryRun) {
118
+ options.apply = false;
119
+ }
120
+
121
+ return toCliArgs(command, options);
122
+ }
123
+
124
+ function writeDelegateOutput(stderr, result) {
125
+ if (result.stdout) {
126
+ stderr.write(result.stdout);
127
+ }
128
+ if (result.stderr) {
129
+ stderr.write(result.stderr);
130
+ }
131
+ }
132
+
133
+ export async function main(argv = process.argv.slice(2), io = { stdin: process.stdin, stdout: process.stdout, stderr: process.stderr }) {
134
+ const args = parseCliArgs(argv);
135
+ const { options } = args;
136
+ if (options.help) {
137
+ io.stdout.write(`${usage()}\n`);
138
+ return 0;
139
+ }
140
+
141
+ const stdin = io.stdin ?? process.stdin;
142
+ const stdout = io.stdout ?? process.stdout;
143
+
144
+ if (args.command === "interactive") {
145
+ if (!stdin.isTTY || !stdout.isTTY) {
146
+ io.stderr.write("Interactive mode requires a TTY. Use `scan`, `clean --dry-run`, or `--json` for scripts.\n");
147
+ return 1;
148
+ }
149
+
150
+ return await runInteractive({
151
+ homeDir: options.homeDir,
152
+ tempDir: options.tempDir,
153
+ nowMs: Date.now(),
154
+ olderThanMs: parseDurationMs(options.olderThan),
155
+ includeCache: options.includeCache,
156
+ apply: false,
157
+ permanent: options.permanent,
158
+ tools: options.tools,
159
+ }, {
160
+ stdin,
161
+ stdout,
162
+ stderr: io.stderr,
163
+ });
164
+ }
165
+
166
+ const results = await scanRemnants({
167
+ homeDir: options.homeDir,
168
+ tempDir: options.tempDir,
169
+ nowMs: Date.now(),
170
+ olderThanMs: parseDurationMs(options.olderThan),
171
+ includeCache: options.includeCache,
172
+ apply: options.apply,
173
+ permanent: options.permanent,
174
+ tools: options.tools,
175
+ });
176
+
177
+ if (args.command === "clean" && !options.apply) {
178
+ io.stderr.write("Dry run only; no files were changed. Re-run with --apply when ready to cleanup.\n");
179
+ io.stdout.write(options.json ? formatJson(results) : `${formatTable(results)}\n`);
180
+ } else if (args.command === "clean" && options.apply) {
181
+ io.stdout.write(options.json ? formatJson(results) : `${formatTable(results)}\n`);
182
+ const scanErrorCount = results.errors.length;
183
+ const applyResult = await applyManifest(results, {
184
+ homeDir: options.homeDir,
185
+ permanent: options.permanent,
186
+ });
187
+ const applyErrors = applyResult.errors.slice(scanErrorCount);
188
+ results.errors.push(...applyErrors);
189
+ io.stderr.write(`Applied: ${applyResult.deleted} deleted, ${applyResult.trashed} trashed, ${applyResult.skipped} skipped.\n`);
190
+ for (const error of applyErrors) {
191
+ io.stderr.write(`${error}\n`);
192
+ }
193
+ } else {
194
+ io.stdout.write(options.json ? formatJson(results) : `${formatTable(results)}\n`);
195
+ }
196
+
197
+ if (args.command === "clean" && options.delegates) {
198
+ for (const result of runDelegateDryRuns(options.homeDir)) {
199
+ writeDelegateOutput(io.stderr, result);
200
+ }
201
+ }
202
+
203
+ return results.errors.length > 0 ? 1 : 0;
204
+ }
205
+
206
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
207
+ main().then((exitCode) => {
208
+ process.exitCode = exitCode;
209
+ }).catch((error) => {
210
+ process.stderr.write(`${error.message}\n${usage()}\n`);
211
+ process.exitCode = 1;
212
+ });
213
+ }
@@ -0,0 +1,31 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ function resolveHomeDir(input) {
4
+ return typeof input === "string" ? input : input?.homeDir;
5
+ }
6
+
7
+ export function buildDelegateDryRunCommands(homeDir) {
8
+ const resolvedHomeDir = resolveHomeDir(homeDir);
9
+ if (!resolvedHomeDir) {
10
+ throw new Error("homeDir is required");
11
+ }
12
+
13
+ return [
14
+ ["claude", "project", "purge", resolvedHomeDir, "--dry-run"],
15
+ ["omp", "worktree", "clear", "--dry-run"],
16
+ ];
17
+ }
18
+
19
+ export function runDelegateDryRuns(homeDir) {
20
+ return buildDelegateDryRunCommands(homeDir).map(([command, ...args]) => {
21
+ const result = spawnSync(command, args, { encoding: "utf8" });
22
+ return {
23
+ command: [command, ...args],
24
+ status: result.status,
25
+ signal: result.signal,
26
+ error: result.error,
27
+ stdout: result.stdout ?? "",
28
+ stderr: result.stderr ?? "",
29
+ };
30
+ });
31
+ }