pi-session-cleanup 1.1.0 → 1.1.2
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/CHANGELOG.md +27 -17
- package/package.json +3 -3
- package/src/agent-target.ts +361 -361
- package/src/index.ts +1 -1
- package/src/session-agent.ts +103 -103
- package/src/session-cleanup-command.ts +268 -268
- package/src/session-delete.ts +165 -11
- package/src/session-entry.ts +23 -23
- package/src/session-format.ts +98 -98
- package/src/session-nix-command.ts +353 -329
- package/src/session-quit-shutdown.ts +40 -40
- package/src/session-selection.ts +167 -167
- package/src/session-sort.ts +137 -137
- package/src/session-source.ts +32 -32
- package/src/tui/agent-target-picker.ts +306 -306
- package/src/tui/session-cleanup-picker.ts +592 -592
- package/src/types-shims.d.ts +23 -9
- package/src/types.ts +1 -1
package/src/session-delete.ts
CHANGED
|
@@ -1,17 +1,77 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { unlink } from "node:fs/promises";
|
|
4
4
|
|
|
5
5
|
import type { DeleteSessionResult } from "./types.js";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
const TRASH_PROVIDER_TIMEOUT_MS = 5_000;
|
|
8
|
+
const TRASH_PROVIDER_MAX_STDERR_BYTES = 64 * 1024;
|
|
9
|
+
|
|
10
|
+
interface TrashProcessResult {
|
|
11
|
+
status: number | null;
|
|
12
|
+
stderr?: string;
|
|
13
|
+
error?: Error;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type TrashProcessRunner = (
|
|
17
|
+
command: string,
|
|
18
|
+
args: readonly string[],
|
|
19
|
+
options: { timeout: number; maxStderrBytes: number },
|
|
20
|
+
) => Promise<TrashProcessResult>;
|
|
21
|
+
|
|
22
|
+
interface TrashProvider {
|
|
23
|
+
name: string;
|
|
24
|
+
command: string;
|
|
25
|
+
getArgs: (sessionPath: string) => string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DeleteSessionFileOptions {
|
|
29
|
+
spawn?: TrashProcessRunner;
|
|
30
|
+
existsSync?: (path: string) => boolean;
|
|
31
|
+
unlink?: (path: string) => Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function argsWithDashSafety(prefix: string[], sessionPath: string, suffix: string[] = []): string[] {
|
|
35
|
+
const pathArgs = sessionPath.startsWith("-") ? ["--", sessionPath] : [sessionPath];
|
|
36
|
+
return [...prefix, ...pathArgs, ...suffix];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const TRASH_PROVIDERS: readonly TrashProvider[] = [
|
|
40
|
+
{
|
|
41
|
+
name: "trash",
|
|
42
|
+
command: "trash",
|
|
43
|
+
getArgs: (sessionPath) => argsWithDashSafety([], sessionPath),
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "trash-put",
|
|
47
|
+
command: "trash-put",
|
|
48
|
+
getArgs: (sessionPath) => argsWithDashSafety([], sessionPath),
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "gio trash",
|
|
52
|
+
command: "gio",
|
|
53
|
+
getArgs: (sessionPath) => argsWithDashSafety(["trash"], sessionPath),
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "kioclient5 move",
|
|
57
|
+
command: "kioclient5",
|
|
58
|
+
getArgs: (sessionPath) => argsWithDashSafety(["move"], sessionPath, ["trash:/"]),
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "kioclient move",
|
|
62
|
+
command: "kioclient",
|
|
63
|
+
getArgs: (sessionPath) => argsWithDashSafety(["move"], sessionPath, ["trash:/"]),
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
function buildTrashErrorHint(providerName: string, result: TrashProcessResult): string | null {
|
|
8
68
|
const details: string[] = [];
|
|
9
69
|
|
|
10
70
|
if (result.error) {
|
|
11
71
|
details.push(result.error.message);
|
|
12
72
|
}
|
|
13
73
|
|
|
14
|
-
const stderr = result.stderr?.
|
|
74
|
+
const stderr = result.stderr?.trim();
|
|
15
75
|
if (stderr) {
|
|
16
76
|
details.push(stderr.split("\n")[0] ?? stderr);
|
|
17
77
|
}
|
|
@@ -20,26 +80,120 @@ function buildTrashErrorHint(result: ReturnType<typeof spawnSync>): string | nul
|
|
|
20
80
|
return null;
|
|
21
81
|
}
|
|
22
82
|
|
|
23
|
-
return
|
|
83
|
+
return `${providerName}: ${details.join(" · ").slice(0, 200)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function runTrashProcess(
|
|
87
|
+
command: string,
|
|
88
|
+
args: readonly string[],
|
|
89
|
+
options: { timeout: number; maxStderrBytes: number },
|
|
90
|
+
): Promise<TrashProcessResult> {
|
|
91
|
+
return new Promise((resolve) => {
|
|
92
|
+
let child: ReturnType<typeof spawn>;
|
|
93
|
+
try {
|
|
94
|
+
child = spawn(command, [...args], {
|
|
95
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
96
|
+
windowsHide: true,
|
|
97
|
+
});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
resolve({
|
|
100
|
+
status: null,
|
|
101
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const stderrChunks: Buffer[] = [];
|
|
107
|
+
let stderrBytes = 0;
|
|
108
|
+
let processError: Error | undefined;
|
|
109
|
+
let settled = false;
|
|
110
|
+
|
|
111
|
+
const finish = (result: TrashProcessResult): void => {
|
|
112
|
+
if (settled) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
settled = true;
|
|
117
|
+
clearTimeout(timeout);
|
|
118
|
+
resolve(result);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const failAndKill = (error: Error): void => {
|
|
122
|
+
if (!processError) {
|
|
123
|
+
processError = error;
|
|
124
|
+
}
|
|
125
|
+
child.kill();
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const timeout = setTimeout(() => {
|
|
129
|
+
failAndKill(new Error(`Trash provider timed out after ${options.timeout}ms.`));
|
|
130
|
+
}, options.timeout);
|
|
131
|
+
timeout.unref?.();
|
|
132
|
+
|
|
133
|
+
child.stderr?.on("data", (chunk: unknown) => {
|
|
134
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
135
|
+
stderrBytes += buffer.length;
|
|
136
|
+
if (stderrBytes > options.maxStderrBytes) {
|
|
137
|
+
failAndKill(new Error(`Trash provider stderr exceeded ${options.maxStderrBytes} bytes.`));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
stderrChunks.push(buffer);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
child.on("error", (error: Error) => {
|
|
145
|
+
processError = error;
|
|
146
|
+
finish({
|
|
147
|
+
status: null,
|
|
148
|
+
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
|
149
|
+
error: processError,
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
child.on("close", (status: number | null) => {
|
|
154
|
+
finish({
|
|
155
|
+
status,
|
|
156
|
+
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
|
157
|
+
error: processError,
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
24
161
|
}
|
|
25
162
|
|
|
26
163
|
/**
|
|
27
164
|
* Mirrors Pi's built-in behavior: try moving to trash first, then fallback to unlink.
|
|
28
165
|
*/
|
|
29
|
-
export async function deleteSessionFile(
|
|
30
|
-
|
|
31
|
-
|
|
166
|
+
export async function deleteSessionFile(
|
|
167
|
+
sessionPath: string,
|
|
168
|
+
options: DeleteSessionFileOptions = {},
|
|
169
|
+
): Promise<DeleteSessionResult> {
|
|
170
|
+
const runProcess = options.spawn ?? runTrashProcess;
|
|
171
|
+
const pathExists = options.existsSync ?? existsSync;
|
|
172
|
+
const unlinkFile = options.unlink ?? unlink;
|
|
173
|
+
const trashHints: string[] = [];
|
|
174
|
+
|
|
175
|
+
for (const provider of TRASH_PROVIDERS) {
|
|
176
|
+
const result = await runProcess(provider.command, provider.getArgs(sessionPath), {
|
|
177
|
+
timeout: TRASH_PROVIDER_TIMEOUT_MS,
|
|
178
|
+
maxStderrBytes: TRASH_PROVIDER_MAX_STDERR_BYTES,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (result.status === 0 || !pathExists(sessionPath)) {
|
|
182
|
+
return { ok: true, method: "trash" };
|
|
183
|
+
}
|
|
32
184
|
|
|
33
|
-
|
|
34
|
-
|
|
185
|
+
const hint = buildTrashErrorHint(provider.name, result);
|
|
186
|
+
if (hint) {
|
|
187
|
+
trashHints.push(hint);
|
|
188
|
+
}
|
|
35
189
|
}
|
|
36
190
|
|
|
37
191
|
try {
|
|
38
|
-
await
|
|
192
|
+
await unlinkFile(sessionPath);
|
|
39
193
|
return { ok: true, method: "unlink" };
|
|
40
194
|
} catch (error) {
|
|
41
195
|
const unlinkError = error instanceof Error ? error.message : String(error);
|
|
42
|
-
const trashHint =
|
|
196
|
+
const trashHint = trashHints.length > 0 ? trashHints.join("; ") : null;
|
|
43
197
|
|
|
44
198
|
return {
|
|
45
199
|
ok: false,
|
package/src/session-entry.ts
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
import type { SessionManager } from "@
|
|
2
|
-
|
|
3
|
-
export interface ActiveAgentSessionEntryData {
|
|
4
|
-
name: string | null;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
type SessionManagerWithCustomEntry = SessionManager & {
|
|
8
|
-
appendCustomEntry?: (customType: string, data?: unknown) => string;
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
export function appendActiveAgentSessionEntry(
|
|
12
|
-
sessionManager: SessionManager,
|
|
13
|
-
agentName: string | null,
|
|
14
|
-
): void {
|
|
15
|
-
const writableSessionManager = sessionManager as SessionManagerWithCustomEntry;
|
|
16
|
-
if (typeof writableSessionManager.appendCustomEntry !== "function") {
|
|
17
|
-
throw new Error("The current Pi build does not expose sessionManager.appendCustomEntry().");
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
writableSessionManager.appendCustomEntry("active_agent", {
|
|
21
|
-
name: agentName,
|
|
22
|
-
} satisfies ActiveAgentSessionEntryData);
|
|
23
|
-
}
|
|
1
|
+
import type { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export interface ActiveAgentSessionEntryData {
|
|
4
|
+
name: string | null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
type SessionManagerWithCustomEntry = SessionManager & {
|
|
8
|
+
appendCustomEntry?: (customType: string, data?: unknown) => string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function appendActiveAgentSessionEntry(
|
|
12
|
+
sessionManager: SessionManager,
|
|
13
|
+
agentName: string | null,
|
|
14
|
+
): void {
|
|
15
|
+
const writableSessionManager = sessionManager as SessionManagerWithCustomEntry;
|
|
16
|
+
if (typeof writableSessionManager.appendCustomEntry !== "function") {
|
|
17
|
+
throw new Error("The current Pi build does not expose sessionManager.appendCustomEntry().");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
writableSessionManager.appendCustomEntry("active_agent", {
|
|
21
|
+
name: agentName,
|
|
22
|
+
} satisfies ActiveAgentSessionEntryData);
|
|
23
|
+
}
|
package/src/session-format.ts
CHANGED
|
@@ -1,98 +1,98 @@
|
|
|
1
|
-
import { homedir } from "node:os";
|
|
2
|
-
|
|
3
|
-
import type { SessionInfo } from "@
|
|
4
|
-
|
|
5
|
-
const LABEL_MAX_TEXT = 56;
|
|
6
|
-
|
|
7
|
-
type SessionDisplayInfo = SessionInfo & {
|
|
8
|
-
responsibleAgentName?: string | null;
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
function compactWhitespace(value: string): string {
|
|
12
|
-
return value.replace(/\s+/g, " ").trim();
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function clipText(value: string, max = LABEL_MAX_TEXT): string {
|
|
16
|
-
if (value.length <= max) {
|
|
17
|
-
return value;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
return `${value.slice(0, Math.max(1, max - 1))}…`;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function shortenPath(path: string): string {
|
|
24
|
-
const home = homedir();
|
|
25
|
-
if (path.startsWith(home)) {
|
|
26
|
-
return `~${path.slice(home.length)}`;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
return path;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function formatSessionAge(date: Date, now = Date.now()): string {
|
|
33
|
-
const ageMs = Math.max(0, now - date.getTime());
|
|
34
|
-
const minutes = Math.floor(ageMs / 60000);
|
|
35
|
-
|
|
36
|
-
if (minutes < 1) {
|
|
37
|
-
return "now";
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
if (minutes < 60) {
|
|
41
|
-
return `${minutes}m`;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const hours = Math.floor(minutes / 60);
|
|
45
|
-
if (hours < 24) {
|
|
46
|
-
return `${hours}h`;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const days = Math.floor(hours / 24);
|
|
50
|
-
if (days < 7) {
|
|
51
|
-
return `${days}d`;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (days < 30) {
|
|
55
|
-
return `${Math.floor(days / 7)}w`;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if (days < 365) {
|
|
59
|
-
return `${Math.floor(days / 30)}mo`;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return `${Math.floor(days / 365)}y`;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function getSessionTitle(session: SessionInfo): string {
|
|
66
|
-
const preferred = compactWhitespace(session.name ?? "");
|
|
67
|
-
if (preferred) {
|
|
68
|
-
return clipText(preferred);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const preview = compactWhitespace(session.firstMessage ?? "");
|
|
72
|
-
if (preview) {
|
|
73
|
-
return clipText(preview);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return "(no preview)";
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function getResponsibleAgentDisplayName(session: SessionDisplayInfo): string {
|
|
80
|
-
const normalizedAgentName = compactWhitespace(session.responsibleAgentName ?? "");
|
|
81
|
-
return normalizedAgentName || "unknown";
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export function buildSessionSelectionLabel(
|
|
85
|
-
session: SessionDisplayInfo,
|
|
86
|
-
index: number,
|
|
87
|
-
selected: boolean,
|
|
88
|
-
): string {
|
|
89
|
-
const marker = selected ? "[x]" : "[ ]";
|
|
90
|
-
const title = getSessionTitle(session);
|
|
91
|
-
const agent = `@${getResponsibleAgentDisplayName(session)}`;
|
|
92
|
-
const age = formatSessionAge(session.modified);
|
|
93
|
-
const shortId = session.id.slice(0, 8);
|
|
94
|
-
const cwd = shortenPath(session.cwd || "(unknown cwd)");
|
|
95
|
-
const position = String(index + 1).padStart(3, "0");
|
|
96
|
-
|
|
97
|
-
return `${position} ${marker} ${title} · ${agent} · ${age} · ${shortId} · ${cwd}`;
|
|
98
|
-
}
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
|
|
3
|
+
import type { SessionInfo } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
const LABEL_MAX_TEXT = 56;
|
|
6
|
+
|
|
7
|
+
type SessionDisplayInfo = SessionInfo & {
|
|
8
|
+
responsibleAgentName?: string | null;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function compactWhitespace(value: string): string {
|
|
12
|
+
return value.replace(/\s+/g, " ").trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function clipText(value: string, max = LABEL_MAX_TEXT): string {
|
|
16
|
+
if (value.length <= max) {
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return `${value.slice(0, Math.max(1, max - 1))}…`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function shortenPath(path: string): string {
|
|
24
|
+
const home = homedir();
|
|
25
|
+
if (path.startsWith(home)) {
|
|
26
|
+
return `~${path.slice(home.length)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return path;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatSessionAge(date: Date, now = Date.now()): string {
|
|
33
|
+
const ageMs = Math.max(0, now - date.getTime());
|
|
34
|
+
const minutes = Math.floor(ageMs / 60000);
|
|
35
|
+
|
|
36
|
+
if (minutes < 1) {
|
|
37
|
+
return "now";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (minutes < 60) {
|
|
41
|
+
return `${minutes}m`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const hours = Math.floor(minutes / 60);
|
|
45
|
+
if (hours < 24) {
|
|
46
|
+
return `${hours}h`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const days = Math.floor(hours / 24);
|
|
50
|
+
if (days < 7) {
|
|
51
|
+
return `${days}d`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (days < 30) {
|
|
55
|
+
return `${Math.floor(days / 7)}w`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (days < 365) {
|
|
59
|
+
return `${Math.floor(days / 30)}mo`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return `${Math.floor(days / 365)}y`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getSessionTitle(session: SessionInfo): string {
|
|
66
|
+
const preferred = compactWhitespace(session.name ?? "");
|
|
67
|
+
if (preferred) {
|
|
68
|
+
return clipText(preferred);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const preview = compactWhitespace(session.firstMessage ?? "");
|
|
72
|
+
if (preview) {
|
|
73
|
+
return clipText(preview);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return "(no preview)";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getResponsibleAgentDisplayName(session: SessionDisplayInfo): string {
|
|
80
|
+
const normalizedAgentName = compactWhitespace(session.responsibleAgentName ?? "");
|
|
81
|
+
return normalizedAgentName || "unknown";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function buildSessionSelectionLabel(
|
|
85
|
+
session: SessionDisplayInfo,
|
|
86
|
+
index: number,
|
|
87
|
+
selected: boolean,
|
|
88
|
+
): string {
|
|
89
|
+
const marker = selected ? "[x]" : "[ ]";
|
|
90
|
+
const title = getSessionTitle(session);
|
|
91
|
+
const agent = `@${getResponsibleAgentDisplayName(session)}`;
|
|
92
|
+
const age = formatSessionAge(session.modified);
|
|
93
|
+
const shortId = session.id.slice(0, 8);
|
|
94
|
+
const cwd = shortenPath(session.cwd || "(unknown cwd)");
|
|
95
|
+
const position = String(index + 1).padStart(3, "0");
|
|
96
|
+
|
|
97
|
+
return `${position} ${marker} ${title} · ${agent} · ${age} · ${shortId} · ${cwd}`;
|
|
98
|
+
}
|