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.
@@ -1,17 +1,77 @@
1
- import { spawnSync } from "node:child_process";
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
- function buildTrashErrorHint(result: ReturnType<typeof spawnSync>): string | null {
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?.toString().trim();
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 `trash: ${details.join(" · ").slice(0, 200)}`;
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(sessionPath: string): Promise<DeleteSessionResult> {
30
- const trashArgs = sessionPath.startsWith("-") ? ["--", sessionPath] : [sessionPath];
31
- const trashResult = spawnSync("trash", trashArgs, { encoding: "utf-8" });
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
- if (trashResult.status === 0 || !existsSync(sessionPath)) {
34
- return { ok: true, method: "trash" };
185
+ const hint = buildTrashErrorHint(provider.name, result);
186
+ if (hint) {
187
+ trashHints.push(hint);
188
+ }
35
189
  }
36
190
 
37
191
  try {
38
- await unlink(sessionPath);
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 = buildTrashErrorHint(trashResult);
196
+ const trashHint = trashHints.length > 0 ? trashHints.join("; ") : null;
43
197
 
44
198
  return {
45
199
  ok: false,
@@ -1,23 +1,23 @@
1
- import type { SessionManager } from "@mariozechner/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
- }
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
+ }
@@ -1,98 +1,98 @@
1
- import { homedir } from "node:os";
2
-
3
- import type { SessionInfo } from "@mariozechner/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
- }
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
+ }