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
|
@@ -1,40 +1,40 @@
|
|
|
1
|
-
import type { ExtensionContext } from "@
|
|
2
|
-
|
|
3
|
-
import { deleteSessionFile } from "./session-delete.js";
|
|
4
|
-
|
|
5
|
-
let pendingSessionFileForQuitDeletion: string | undefined;
|
|
6
|
-
|
|
7
|
-
export function scheduleSessionDeletionForQuit(sessionFile: string | undefined): void {
|
|
8
|
-
pendingSessionFileForQuitDeletion = sessionFile;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function clearScheduledSessionDeletionForQuit(): void {
|
|
12
|
-
pendingSessionFileForQuitDeletion = undefined;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export async function flushScheduledSessionDeletionForQuit(
|
|
16
|
-
ctx: ExtensionContext,
|
|
17
|
-
): Promise<void> {
|
|
18
|
-
const sessionFile = pendingSessionFileForQuitDeletion;
|
|
19
|
-
pendingSessionFileForQuitDeletion = undefined;
|
|
20
|
-
|
|
21
|
-
if (!sessionFile) {
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
try {
|
|
26
|
-
const deleteResult = await deleteSessionFile(sessionFile);
|
|
27
|
-
if (!deleteResult.ok) {
|
|
28
|
-
ctx.ui.notify(
|
|
29
|
-
`Failed to delete the current session during shutdown: ${deleteResult.error}`,
|
|
30
|
-
"warning",
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
} catch (error) {
|
|
34
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
35
|
-
ctx.ui.notify(
|
|
36
|
-
`Failed to delete the current session during shutdown: ${message}`,
|
|
37
|
-
"warning",
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { deleteSessionFile } from "./session-delete.js";
|
|
4
|
+
|
|
5
|
+
let pendingSessionFileForQuitDeletion: string | undefined;
|
|
6
|
+
|
|
7
|
+
export function scheduleSessionDeletionForQuit(sessionFile: string | undefined): void {
|
|
8
|
+
pendingSessionFileForQuitDeletion = sessionFile;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function clearScheduledSessionDeletionForQuit(): void {
|
|
12
|
+
pendingSessionFileForQuitDeletion = undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function flushScheduledSessionDeletionForQuit(
|
|
16
|
+
ctx: ExtensionContext,
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
const sessionFile = pendingSessionFileForQuitDeletion;
|
|
19
|
+
pendingSessionFileForQuitDeletion = undefined;
|
|
20
|
+
|
|
21
|
+
if (!sessionFile) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const deleteResult = await deleteSessionFile(sessionFile);
|
|
27
|
+
if (!deleteResult.ok) {
|
|
28
|
+
ctx.ui.notify(
|
|
29
|
+
`Failed to delete the current session during shutdown: ${deleteResult.error}`,
|
|
30
|
+
"warning",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
} catch (error) {
|
|
34
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
35
|
+
ctx.ui.notify(
|
|
36
|
+
`Failed to delete the current session during shutdown: ${message}`,
|
|
37
|
+
"warning",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/session-selection.ts
CHANGED
|
@@ -1,167 +1,167 @@
|
|
|
1
|
-
import type { ExtensionCommandContext } from "@
|
|
2
|
-
|
|
3
|
-
import { buildSessionSelectionLabel } from "./session-format.js";
|
|
4
|
-
import { showSessionCleanupPicker } from "./tui/session-cleanup-picker.js";
|
|
5
|
-
import type { SessionCleanupSession, SessionSelectionResult } from "./types.js";
|
|
6
|
-
|
|
7
|
-
interface ConfirmAction {
|
|
8
|
-
kind: "confirm";
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
interface ToggleAllAction {
|
|
12
|
-
kind: "toggle-all";
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
interface RefreshAction {
|
|
16
|
-
kind: "refresh";
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface CancelAction {
|
|
20
|
-
kind: "cancel";
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
interface ToggleSessionAction {
|
|
24
|
-
kind: "toggle-session";
|
|
25
|
-
sessionPath: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
type SelectionAction =
|
|
29
|
-
| ConfirmAction
|
|
30
|
-
| ToggleAllAction
|
|
31
|
-
| RefreshAction
|
|
32
|
-
| CancelAction
|
|
33
|
-
| ToggleSessionAction;
|
|
34
|
-
|
|
35
|
-
function buildMenu(
|
|
36
|
-
sessions: readonly SessionCleanupSession[],
|
|
37
|
-
selectedPaths: ReadonlySet<string>,
|
|
38
|
-
): {
|
|
39
|
-
labels: string[];
|
|
40
|
-
actionsByLabel: Map<string, SelectionAction>;
|
|
41
|
-
} {
|
|
42
|
-
const labels: string[] = [];
|
|
43
|
-
const actionsByLabel = new Map<string, SelectionAction>();
|
|
44
|
-
|
|
45
|
-
const confirmLabel = `✅ Delete selected (${selectedPaths.size})`;
|
|
46
|
-
labels.push(confirmLabel);
|
|
47
|
-
actionsByLabel.set(confirmLabel, { kind: "confirm" });
|
|
48
|
-
|
|
49
|
-
const toggleAllLabel =
|
|
50
|
-
selectedPaths.size === sessions.length
|
|
51
|
-
? "☐ Clear all selections"
|
|
52
|
-
: "☑ Select all sessions";
|
|
53
|
-
labels.push(toggleAllLabel);
|
|
54
|
-
actionsByLabel.set(toggleAllLabel, { kind: "toggle-all" });
|
|
55
|
-
|
|
56
|
-
const refreshLabel = "↻ Refresh session list";
|
|
57
|
-
labels.push(refreshLabel);
|
|
58
|
-
actionsByLabel.set(refreshLabel, { kind: "refresh" });
|
|
59
|
-
|
|
60
|
-
const cancelLabel = "✖ Cancel";
|
|
61
|
-
labels.push(cancelLabel);
|
|
62
|
-
actionsByLabel.set(cancelLabel, { kind: "cancel" });
|
|
63
|
-
|
|
64
|
-
sessions.forEach((session, index) => {
|
|
65
|
-
const label = buildSessionSelectionLabel(
|
|
66
|
-
session,
|
|
67
|
-
index,
|
|
68
|
-
selectedPaths.has(session.path),
|
|
69
|
-
);
|
|
70
|
-
labels.push(label);
|
|
71
|
-
actionsByLabel.set(label, {
|
|
72
|
-
kind: "toggle-session",
|
|
73
|
-
sessionPath: session.path,
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
return { labels, actionsByLabel };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async function selectSessionsWithLegacyMenu(
|
|
81
|
-
ctx: ExtensionCommandContext,
|
|
82
|
-
sessions: readonly SessionCleanupSession[],
|
|
83
|
-
): Promise<SessionSelectionResult> {
|
|
84
|
-
const selectedPaths = new Set<string>();
|
|
85
|
-
|
|
86
|
-
while (true) {
|
|
87
|
-
const menu = buildMenu(sessions, selectedPaths);
|
|
88
|
-
const selectedLabel = await ctx.ui.select(
|
|
89
|
-
"Select sessions to delete (toggle multiple, then confirm)",
|
|
90
|
-
menu.labels,
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
if (!selectedLabel) {
|
|
94
|
-
return {
|
|
95
|
-
cancelled: true,
|
|
96
|
-
refreshRequested: false,
|
|
97
|
-
selectedPaths,
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const action = menu.actionsByLabel.get(selectedLabel);
|
|
102
|
-
if (!action) {
|
|
103
|
-
ctx.ui.notify("Unknown selection action. Please try again.", "warning");
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
switch (action.kind) {
|
|
108
|
-
case "confirm": {
|
|
109
|
-
return {
|
|
110
|
-
cancelled: false,
|
|
111
|
-
refreshRequested: false,
|
|
112
|
-
selectedPaths,
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
case "toggle-all": {
|
|
116
|
-
if (selectedPaths.size === sessions.length) {
|
|
117
|
-
selectedPaths.clear();
|
|
118
|
-
} else {
|
|
119
|
-
sessions.forEach((session) => selectedPaths.add(session.path));
|
|
120
|
-
}
|
|
121
|
-
break;
|
|
122
|
-
}
|
|
123
|
-
case "refresh": {
|
|
124
|
-
return {
|
|
125
|
-
cancelled: false,
|
|
126
|
-
refreshRequested: true,
|
|
127
|
-
selectedPaths,
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
case "cancel": {
|
|
131
|
-
return {
|
|
132
|
-
cancelled: true,
|
|
133
|
-
refreshRequested: false,
|
|
134
|
-
selectedPaths,
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
case "toggle-session": {
|
|
138
|
-
if (selectedPaths.has(action.sessionPath)) {
|
|
139
|
-
selectedPaths.delete(action.sessionPath);
|
|
140
|
-
} else {
|
|
141
|
-
selectedPaths.add(action.sessionPath);
|
|
142
|
-
}
|
|
143
|
-
break;
|
|
144
|
-
}
|
|
145
|
-
default: {
|
|
146
|
-
const unreachable: never = action;
|
|
147
|
-
throw new Error(`Unhandled selection action: ${String(unreachable)}`);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export async function selectSessionsForCleanup(
|
|
154
|
-
ctx: ExtensionCommandContext,
|
|
155
|
-
sessions: readonly SessionCleanupSession[],
|
|
156
|
-
): Promise<SessionSelectionResult> {
|
|
157
|
-
try {
|
|
158
|
-
return await showSessionCleanupPicker(ctx, sessions);
|
|
159
|
-
} catch (error) {
|
|
160
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
161
|
-
ctx.ui.notify(
|
|
162
|
-
`Interactive picker failed (${message}). Falling back to basic selector.`,
|
|
163
|
-
"warning",
|
|
164
|
-
);
|
|
165
|
-
return selectSessionsWithLegacyMenu(ctx, sessions);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { buildSessionSelectionLabel } from "./session-format.js";
|
|
4
|
+
import { showSessionCleanupPicker } from "./tui/session-cleanup-picker.js";
|
|
5
|
+
import type { SessionCleanupSession, SessionSelectionResult } from "./types.js";
|
|
6
|
+
|
|
7
|
+
interface ConfirmAction {
|
|
8
|
+
kind: "confirm";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ToggleAllAction {
|
|
12
|
+
kind: "toggle-all";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface RefreshAction {
|
|
16
|
+
kind: "refresh";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface CancelAction {
|
|
20
|
+
kind: "cancel";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ToggleSessionAction {
|
|
24
|
+
kind: "toggle-session";
|
|
25
|
+
sessionPath: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type SelectionAction =
|
|
29
|
+
| ConfirmAction
|
|
30
|
+
| ToggleAllAction
|
|
31
|
+
| RefreshAction
|
|
32
|
+
| CancelAction
|
|
33
|
+
| ToggleSessionAction;
|
|
34
|
+
|
|
35
|
+
function buildMenu(
|
|
36
|
+
sessions: readonly SessionCleanupSession[],
|
|
37
|
+
selectedPaths: ReadonlySet<string>,
|
|
38
|
+
): {
|
|
39
|
+
labels: string[];
|
|
40
|
+
actionsByLabel: Map<string, SelectionAction>;
|
|
41
|
+
} {
|
|
42
|
+
const labels: string[] = [];
|
|
43
|
+
const actionsByLabel = new Map<string, SelectionAction>();
|
|
44
|
+
|
|
45
|
+
const confirmLabel = `✅ Delete selected (${selectedPaths.size})`;
|
|
46
|
+
labels.push(confirmLabel);
|
|
47
|
+
actionsByLabel.set(confirmLabel, { kind: "confirm" });
|
|
48
|
+
|
|
49
|
+
const toggleAllLabel =
|
|
50
|
+
selectedPaths.size === sessions.length
|
|
51
|
+
? "☐ Clear all selections"
|
|
52
|
+
: "☑ Select all sessions";
|
|
53
|
+
labels.push(toggleAllLabel);
|
|
54
|
+
actionsByLabel.set(toggleAllLabel, { kind: "toggle-all" });
|
|
55
|
+
|
|
56
|
+
const refreshLabel = "↻ Refresh session list";
|
|
57
|
+
labels.push(refreshLabel);
|
|
58
|
+
actionsByLabel.set(refreshLabel, { kind: "refresh" });
|
|
59
|
+
|
|
60
|
+
const cancelLabel = "✖ Cancel";
|
|
61
|
+
labels.push(cancelLabel);
|
|
62
|
+
actionsByLabel.set(cancelLabel, { kind: "cancel" });
|
|
63
|
+
|
|
64
|
+
sessions.forEach((session, index) => {
|
|
65
|
+
const label = buildSessionSelectionLabel(
|
|
66
|
+
session,
|
|
67
|
+
index,
|
|
68
|
+
selectedPaths.has(session.path),
|
|
69
|
+
);
|
|
70
|
+
labels.push(label);
|
|
71
|
+
actionsByLabel.set(label, {
|
|
72
|
+
kind: "toggle-session",
|
|
73
|
+
sessionPath: session.path,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return { labels, actionsByLabel };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function selectSessionsWithLegacyMenu(
|
|
81
|
+
ctx: ExtensionCommandContext,
|
|
82
|
+
sessions: readonly SessionCleanupSession[],
|
|
83
|
+
): Promise<SessionSelectionResult> {
|
|
84
|
+
const selectedPaths = new Set<string>();
|
|
85
|
+
|
|
86
|
+
while (true) {
|
|
87
|
+
const menu = buildMenu(sessions, selectedPaths);
|
|
88
|
+
const selectedLabel = await ctx.ui.select(
|
|
89
|
+
"Select sessions to delete (toggle multiple, then confirm)",
|
|
90
|
+
menu.labels,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (!selectedLabel) {
|
|
94
|
+
return {
|
|
95
|
+
cancelled: true,
|
|
96
|
+
refreshRequested: false,
|
|
97
|
+
selectedPaths,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const action = menu.actionsByLabel.get(selectedLabel);
|
|
102
|
+
if (!action) {
|
|
103
|
+
ctx.ui.notify("Unknown selection action. Please try again.", "warning");
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
switch (action.kind) {
|
|
108
|
+
case "confirm": {
|
|
109
|
+
return {
|
|
110
|
+
cancelled: false,
|
|
111
|
+
refreshRequested: false,
|
|
112
|
+
selectedPaths,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
case "toggle-all": {
|
|
116
|
+
if (selectedPaths.size === sessions.length) {
|
|
117
|
+
selectedPaths.clear();
|
|
118
|
+
} else {
|
|
119
|
+
sessions.forEach((session) => selectedPaths.add(session.path));
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
case "refresh": {
|
|
124
|
+
return {
|
|
125
|
+
cancelled: false,
|
|
126
|
+
refreshRequested: true,
|
|
127
|
+
selectedPaths,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
case "cancel": {
|
|
131
|
+
return {
|
|
132
|
+
cancelled: true,
|
|
133
|
+
refreshRequested: false,
|
|
134
|
+
selectedPaths,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
case "toggle-session": {
|
|
138
|
+
if (selectedPaths.has(action.sessionPath)) {
|
|
139
|
+
selectedPaths.delete(action.sessionPath);
|
|
140
|
+
} else {
|
|
141
|
+
selectedPaths.add(action.sessionPath);
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
default: {
|
|
146
|
+
const unreachable: never = action;
|
|
147
|
+
throw new Error(`Unhandled selection action: ${String(unreachable)}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function selectSessionsForCleanup(
|
|
154
|
+
ctx: ExtensionCommandContext,
|
|
155
|
+
sessions: readonly SessionCleanupSession[],
|
|
156
|
+
): Promise<SessionSelectionResult> {
|
|
157
|
+
try {
|
|
158
|
+
return await showSessionCleanupPicker(ctx, sessions);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
161
|
+
ctx.ui.notify(
|
|
162
|
+
`Interactive picker failed (${message}). Falling back to basic selector.`,
|
|
163
|
+
"warning",
|
|
164
|
+
);
|
|
165
|
+
return selectSessionsWithLegacyMenu(ctx, sessions);
|
|
166
|
+
}
|
|
167
|
+
}
|
package/src/session-sort.ts
CHANGED
|
@@ -1,137 +1,137 @@
|
|
|
1
|
-
import { basename } from "node:path";
|
|
2
|
-
|
|
3
|
-
import type { SessionInfo } from "@
|
|
4
|
-
|
|
5
|
-
function toTimestamp(value: unknown): number | null {
|
|
6
|
-
if (value instanceof Date) {
|
|
7
|
-
const timestamp = value.getTime();
|
|
8
|
-
return Number.isFinite(timestamp) ? timestamp : null;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
if (typeof value === "number") {
|
|
12
|
-
return Number.isFinite(value) ? value : null;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
if (typeof value !== "string") {
|
|
16
|
-
return null;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const parsed = Date.parse(value);
|
|
20
|
-
if (!Number.isFinite(parsed)) {
|
|
21
|
-
return null;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
return parsed;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function parseEpochCandidate(token: string): number | null {
|
|
28
|
-
if (!/^\d{10,17}$/.test(token)) {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const numeric = Number(token);
|
|
33
|
-
if (!Number.isFinite(numeric) || numeric <= 0) {
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
if (token.length <= 10) {
|
|
38
|
-
return numeric * 1000;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return numeric;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function parseCalendarCandidate(path: string): number | null {
|
|
45
|
-
const match = path.match(
|
|
46
|
-
/(\d{4})[-_]?([01]\d)[-_]?([0-3]\d)(?:[tT _-]?([0-2]\d)[:_\-]?([0-5]\d)?[:_\-]?([0-5]\d)?)?/,
|
|
47
|
-
);
|
|
48
|
-
|
|
49
|
-
if (!match) {
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const year = Number(match[1]);
|
|
54
|
-
const month = Number(match[2]);
|
|
55
|
-
const day = Number(match[3]);
|
|
56
|
-
const hour = Number(match[4] ?? "0");
|
|
57
|
-
const minute = Number(match[5] ?? "0");
|
|
58
|
-
const second = Number(match[6] ?? "0");
|
|
59
|
-
|
|
60
|
-
if (
|
|
61
|
-
!Number.isFinite(year) ||
|
|
62
|
-
!Number.isFinite(month) ||
|
|
63
|
-
!Number.isFinite(day) ||
|
|
64
|
-
!Number.isFinite(hour) ||
|
|
65
|
-
!Number.isFinite(minute) ||
|
|
66
|
-
!Number.isFinite(second)
|
|
67
|
-
) {
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const utcTimestamp = Date.UTC(year, month - 1, day, hour, minute, second);
|
|
72
|
-
return Number.isFinite(utcTimestamp) ? utcTimestamp : null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function timestampFromPath(sessionPath: string): number {
|
|
76
|
-
const fileName = basename(sessionPath);
|
|
77
|
-
const epochCandidates = fileName.match(/\d{10,17}/g) ?? [];
|
|
78
|
-
|
|
79
|
-
let bestEpoch = 0;
|
|
80
|
-
for (const candidate of epochCandidates) {
|
|
81
|
-
const parsed = parseEpochCandidate(candidate);
|
|
82
|
-
if (parsed && parsed > bestEpoch) {
|
|
83
|
-
bestEpoch = parsed;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if (bestEpoch > 0) {
|
|
88
|
-
return bestEpoch;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const calendarTimestamp = parseCalendarCandidate(fileName);
|
|
92
|
-
if (calendarTimestamp) {
|
|
93
|
-
return calendarTimestamp;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return 0;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function compareDescending(left: number, right: number): number {
|
|
100
|
-
if (left === right) {
|
|
101
|
-
return 0;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return right - left;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export function sortSessionsNewestFirst(
|
|
108
|
-
sessions: readonly SessionInfo[],
|
|
109
|
-
): SessionInfo[] {
|
|
110
|
-
return [...sessions].sort((left, right) => {
|
|
111
|
-
const modifiedOrder = compareDescending(
|
|
112
|
-
toTimestamp(left.modified) ?? 0,
|
|
113
|
-
toTimestamp(right.modified) ?? 0,
|
|
114
|
-
);
|
|
115
|
-
if (modifiedOrder !== 0) {
|
|
116
|
-
return modifiedOrder;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const createdOrder = compareDescending(
|
|
120
|
-
toTimestamp(left.created) ?? 0,
|
|
121
|
-
toTimestamp(right.created) ?? 0,
|
|
122
|
-
);
|
|
123
|
-
if (createdOrder !== 0) {
|
|
124
|
-
return createdOrder;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const pathOrder = compareDescending(
|
|
128
|
-
timestampFromPath(left.path),
|
|
129
|
-
timestampFromPath(right.path),
|
|
130
|
-
);
|
|
131
|
-
if (pathOrder !== 0) {
|
|
132
|
-
return pathOrder;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return right.path.localeCompare(left.path);
|
|
136
|
-
});
|
|
137
|
-
}
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
import type { SessionInfo } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
function toTimestamp(value: unknown): number | null {
|
|
6
|
+
if (value instanceof Date) {
|
|
7
|
+
const timestamp = value.getTime();
|
|
8
|
+
return Number.isFinite(timestamp) ? timestamp : null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (typeof value === "number") {
|
|
12
|
+
return Number.isFinite(value) ? value : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (typeof value !== "string") {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const parsed = Date.parse(value);
|
|
20
|
+
if (!Number.isFinite(parsed)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return parsed;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseEpochCandidate(token: string): number | null {
|
|
28
|
+
if (!/^\d{10,17}$/.test(token)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const numeric = Number(token);
|
|
33
|
+
if (!Number.isFinite(numeric) || numeric <= 0) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (token.length <= 10) {
|
|
38
|
+
return numeric * 1000;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return numeric;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseCalendarCandidate(path: string): number | null {
|
|
45
|
+
const match = path.match(
|
|
46
|
+
/(\d{4})[-_]?([01]\d)[-_]?([0-3]\d)(?:[tT _-]?([0-2]\d)[:_\-]?([0-5]\d)?[:_\-]?([0-5]\d)?)?/,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
if (!match) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const year = Number(match[1]);
|
|
54
|
+
const month = Number(match[2]);
|
|
55
|
+
const day = Number(match[3]);
|
|
56
|
+
const hour = Number(match[4] ?? "0");
|
|
57
|
+
const minute = Number(match[5] ?? "0");
|
|
58
|
+
const second = Number(match[6] ?? "0");
|
|
59
|
+
|
|
60
|
+
if (
|
|
61
|
+
!Number.isFinite(year) ||
|
|
62
|
+
!Number.isFinite(month) ||
|
|
63
|
+
!Number.isFinite(day) ||
|
|
64
|
+
!Number.isFinite(hour) ||
|
|
65
|
+
!Number.isFinite(minute) ||
|
|
66
|
+
!Number.isFinite(second)
|
|
67
|
+
) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const utcTimestamp = Date.UTC(year, month - 1, day, hour, minute, second);
|
|
72
|
+
return Number.isFinite(utcTimestamp) ? utcTimestamp : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function timestampFromPath(sessionPath: string): number {
|
|
76
|
+
const fileName = basename(sessionPath);
|
|
77
|
+
const epochCandidates = fileName.match(/\d{10,17}/g) ?? [];
|
|
78
|
+
|
|
79
|
+
let bestEpoch = 0;
|
|
80
|
+
for (const candidate of epochCandidates) {
|
|
81
|
+
const parsed = parseEpochCandidate(candidate);
|
|
82
|
+
if (parsed && parsed > bestEpoch) {
|
|
83
|
+
bestEpoch = parsed;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (bestEpoch > 0) {
|
|
88
|
+
return bestEpoch;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const calendarTimestamp = parseCalendarCandidate(fileName);
|
|
92
|
+
if (calendarTimestamp) {
|
|
93
|
+
return calendarTimestamp;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function compareDescending(left: number, right: number): number {
|
|
100
|
+
if (left === right) {
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return right - left;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function sortSessionsNewestFirst(
|
|
108
|
+
sessions: readonly SessionInfo[],
|
|
109
|
+
): SessionInfo[] {
|
|
110
|
+
return [...sessions].sort((left, right) => {
|
|
111
|
+
const modifiedOrder = compareDescending(
|
|
112
|
+
toTimestamp(left.modified) ?? 0,
|
|
113
|
+
toTimestamp(right.modified) ?? 0,
|
|
114
|
+
);
|
|
115
|
+
if (modifiedOrder !== 0) {
|
|
116
|
+
return modifiedOrder;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const createdOrder = compareDescending(
|
|
120
|
+
toTimestamp(left.created) ?? 0,
|
|
121
|
+
toTimestamp(right.created) ?? 0,
|
|
122
|
+
);
|
|
123
|
+
if (createdOrder !== 0) {
|
|
124
|
+
return createdOrder;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const pathOrder = compareDescending(
|
|
128
|
+
timestampFromPath(left.path),
|
|
129
|
+
timestampFromPath(right.path),
|
|
130
|
+
);
|
|
131
|
+
if (pathOrder !== 0) {
|
|
132
|
+
return pathOrder;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return right.path.localeCompare(left.path);
|
|
136
|
+
});
|
|
137
|
+
}
|