opencode-session 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.
@@ -0,0 +1,312 @@
1
+ import { readdir, rm, readFile, writeFile } from "fs/promises"
2
+ import { join } from "path"
3
+ import {
4
+ SESSION_DIR,
5
+ MESSAGE_DIR,
6
+ PART_DIR,
7
+ PROJECT_DIR,
8
+ SESSION_DIFF_DIR,
9
+ TODO_DIR,
10
+ SNAPSHOT_DIR,
11
+ FRECENCY_FILE,
12
+ } from "../config"
13
+ import type { SessionInfo, ProjectInfo, LogFile, Message, FrecencyEntry } from "../types"
14
+
15
+ // ============================================================================
16
+ // Utility Functions
17
+ // ============================================================================
18
+
19
+ async function safeRm(path: string, options?: { recursive?: boolean }): Promise<boolean> {
20
+ try {
21
+ await rm(path, { force: true, ...options })
22
+ return true
23
+ } catch {
24
+ return false
25
+ }
26
+ }
27
+
28
+ async function readJsonFile<T>(path: string): Promise<T | null> {
29
+ try {
30
+ const content = await readFile(path, "utf-8")
31
+ return JSON.parse(content) as T
32
+ } catch {
33
+ return null
34
+ }
35
+ }
36
+
37
+ // ============================================================================
38
+ // Session Deletion
39
+ // ============================================================================
40
+
41
+ export interface DeleteSessionResult {
42
+ success: boolean
43
+ sessionID: string
44
+ filesDeleted: number
45
+ bytesFreed: number
46
+ error?: string
47
+ }
48
+
49
+ export async function deleteSession(session: SessionInfo): Promise<DeleteSessionResult> {
50
+ let filesDeleted = 0
51
+ const bytesFreed = session.sizeBytes
52
+
53
+ try {
54
+ // 1. Get all message IDs for this session
55
+ const messageDir = join(MESSAGE_DIR, session.id)
56
+ let messageIDs: string[] = []
57
+
58
+ try {
59
+ const messageFiles = await readdir(messageDir)
60
+ for (const file of messageFiles) {
61
+ if (!file.endsWith(".json")) continue
62
+ const message = await readJsonFile<Message>(join(messageDir, file))
63
+ if (message) {
64
+ messageIDs.push(message.id)
65
+ }
66
+ }
67
+ } catch {
68
+ // No messages directory
69
+ }
70
+
71
+ // 2. Delete all parts for each message
72
+ for (const messageID of messageIDs) {
73
+ const partDir = join(PART_DIR, messageID)
74
+ if (await safeRm(partDir, { recursive: true })) {
75
+ filesDeleted++
76
+ }
77
+ }
78
+
79
+ // 3. Delete messages directory
80
+ if (await safeRm(messageDir, { recursive: true })) {
81
+ filesDeleted++
82
+ }
83
+
84
+ // 4. Delete session diff
85
+ const diffPath = join(SESSION_DIFF_DIR, `${session.id}.json`)
86
+ if (await safeRm(diffPath)) {
87
+ filesDeleted++
88
+ }
89
+
90
+ // 5. Delete session todo
91
+ const todoPath = join(TODO_DIR, `${session.id}.json`)
92
+ if (await safeRm(todoPath)) {
93
+ filesDeleted++
94
+ }
95
+
96
+ // 6. Delete session file itself
97
+ const sessionPath = join(SESSION_DIR, session.projectID, `${session.id}.json`)
98
+ if (await safeRm(sessionPath)) {
99
+ filesDeleted++
100
+ }
101
+
102
+ return {
103
+ success: true,
104
+ sessionID: session.id,
105
+ filesDeleted,
106
+ bytesFreed,
107
+ }
108
+ } catch (error) {
109
+ return {
110
+ success: false,
111
+ sessionID: session.id,
112
+ filesDeleted,
113
+ bytesFreed: 0,
114
+ error: error instanceof Error ? error.message : "Unknown error",
115
+ }
116
+ }
117
+ }
118
+
119
+ export async function deleteSessions(
120
+ sessions: SessionInfo[]
121
+ ): Promise<DeleteSessionResult[]> {
122
+ const results: DeleteSessionResult[] = []
123
+ for (const session of sessions) {
124
+ results.push(await deleteSession(session))
125
+ }
126
+ return results
127
+ }
128
+
129
+ // ============================================================================
130
+ // Project Deletion
131
+ // ============================================================================
132
+
133
+ export interface DeleteProjectResult {
134
+ success: boolean
135
+ projectID: string
136
+ sessionsDeleted: number
137
+ filesDeleted: number
138
+ bytesFreed: number
139
+ frecencyEntriesRemoved: number
140
+ error?: string
141
+ }
142
+
143
+ export async function deleteProject(project: ProjectInfo): Promise<DeleteProjectResult> {
144
+ let sessionsDeleted = 0
145
+ let totalFilesDeleted = 0
146
+ let totalBytesFreed = 0
147
+ let frecencyEntriesRemoved = 0
148
+
149
+ try {
150
+ // 1. Delete all sessions for this project
151
+ for (const session of project.sessions) {
152
+ const result = await deleteSession(session)
153
+ if (result.success) {
154
+ sessionsDeleted++
155
+ totalFilesDeleted += result.filesDeleted
156
+ totalBytesFreed += result.bytesFreed
157
+ }
158
+ }
159
+
160
+ // 2. Delete project file
161
+ const projectPath = join(PROJECT_DIR, `${project.id}.json`)
162
+ if (await safeRm(projectPath)) {
163
+ totalFilesDeleted++
164
+ }
165
+
166
+ // 3. Delete session directory for this project (should be empty now)
167
+ const projectSessionDir = join(SESSION_DIR, project.id)
168
+ await safeRm(projectSessionDir, { recursive: true })
169
+
170
+ // 4. Delete snapshot directory
171
+ const snapshotDir = join(SNAPSHOT_DIR, project.id)
172
+ if (await safeRm(snapshotDir, { recursive: true })) {
173
+ totalFilesDeleted++
174
+ }
175
+
176
+ // 5. Clean up frecency entries
177
+ frecencyEntriesRemoved = await cleanFrecencyForDirectory(project.worktree)
178
+
179
+ return {
180
+ success: true,
181
+ projectID: project.id,
182
+ sessionsDeleted,
183
+ filesDeleted: totalFilesDeleted,
184
+ bytesFreed: totalBytesFreed,
185
+ frecencyEntriesRemoved,
186
+ }
187
+ } catch (error) {
188
+ return {
189
+ success: false,
190
+ projectID: project.id,
191
+ sessionsDeleted,
192
+ filesDeleted: totalFilesDeleted,
193
+ bytesFreed: totalBytesFreed,
194
+ frecencyEntriesRemoved,
195
+ error: error instanceof Error ? error.message : "Unknown error",
196
+ }
197
+ }
198
+ }
199
+
200
+ // ============================================================================
201
+ // Frecency Cleanup
202
+ // ============================================================================
203
+
204
+ export async function cleanFrecencyForDirectory(directory: string): Promise<number> {
205
+ try {
206
+ const content = await readFile(FRECENCY_FILE, "utf-8")
207
+ const lines = content.trim().split("\n")
208
+ const filteredLines: string[] = []
209
+ let removedCount = 0
210
+
211
+ for (const line of lines) {
212
+ if (!line) continue
213
+ try {
214
+ const entry = JSON.parse(line) as FrecencyEntry
215
+ // Keep entries that don't start with the deleted directory
216
+ if (!entry.path.startsWith(directory)) {
217
+ filteredLines.push(line)
218
+ } else {
219
+ removedCount++
220
+ }
221
+ } catch {
222
+ // Keep invalid lines as-is
223
+ filteredLines.push(line)
224
+ }
225
+ }
226
+
227
+ // Write back the filtered content
228
+ await writeFile(FRECENCY_FILE, filteredLines.join("\n") + "\n")
229
+
230
+ return removedCount
231
+ } catch {
232
+ return 0
233
+ }
234
+ }
235
+
236
+ // ============================================================================
237
+ // Log Deletion
238
+ // ============================================================================
239
+
240
+ export interface DeleteLogResult {
241
+ success: boolean
242
+ filename: string
243
+ bytesFreed: number
244
+ error?: string
245
+ }
246
+
247
+ export async function deleteLog(log: LogFile): Promise<DeleteLogResult> {
248
+ try {
249
+ await rm(log.path, { force: true })
250
+ return {
251
+ success: true,
252
+ filename: log.filename,
253
+ bytesFreed: log.sizeBytes,
254
+ }
255
+ } catch (error) {
256
+ return {
257
+ success: false,
258
+ filename: log.filename,
259
+ bytesFreed: 0,
260
+ error: error instanceof Error ? error.message : "Unknown error",
261
+ }
262
+ }
263
+ }
264
+
265
+ export async function deleteLogs(logs: LogFile[]): Promise<DeleteLogResult[]> {
266
+ const results: DeleteLogResult[] = []
267
+ for (const log of logs) {
268
+ results.push(await deleteLog(log))
269
+ }
270
+ return results
271
+ }
272
+
273
+ // ============================================================================
274
+ // Check if project has remaining sessions
275
+ // ============================================================================
276
+
277
+ export async function getProjectSessionCount(projectID: string): Promise<number> {
278
+ try {
279
+ const projectSessionDir = join(SESSION_DIR, projectID)
280
+ const files = await readdir(projectSessionDir)
281
+ return files.filter((f) => f.endsWith(".json")).length
282
+ } catch {
283
+ return 0
284
+ }
285
+ }
286
+
287
+ // After deleting sessions, check if we should clean up the project too
288
+ export async function cleanupEmptyProject(
289
+ projectID: string,
290
+ worktree: string
291
+ ): Promise<boolean> {
292
+ const remainingCount = await getProjectSessionCount(projectID)
293
+ if (remainingCount === 0) {
294
+ // Delete project file
295
+ const projectPath = join(PROJECT_DIR, `${projectID}.json`)
296
+ await safeRm(projectPath)
297
+
298
+ // Delete session directory
299
+ const projectSessionDir = join(SESSION_DIR, projectID)
300
+ await safeRm(projectSessionDir, { recursive: true })
301
+
302
+ // Delete snapshots
303
+ const snapshotDir = join(SNAPSHOT_DIR, projectID)
304
+ await safeRm(snapshotDir, { recursive: true })
305
+
306
+ // Clean frecency
307
+ await cleanFrecencyForDirectory(worktree)
308
+
309
+ return true
310
+ }
311
+ return false
312
+ }
package/src/index.ts ADDED
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { App } from "./ui/app"
4
+
5
+ async function main(): Promise<void> {
6
+ const args = process.argv.slice(2)
7
+
8
+ // Handle --help and --version flags
9
+ if (args.includes("--help") || args.includes("-h")) {
10
+ console.log(`
11
+ opencode-session - Manage OpenCode sessions
12
+
13
+ Usage: opencode-session [options]
14
+
15
+ Options:
16
+ -h, --help Show this help message
17
+ -v, --version Show version number
18
+
19
+ Navigation:
20
+ j / ↓ Move down
21
+ k / ↑ Move up
22
+ Space Toggle selection
23
+ a Select all / Deselect all
24
+ Enter Confirm action
25
+
26
+ Actions:
27
+ d Delete selected item(s)
28
+ D Delete ALL (in orphans/logs view)
29
+ o View orphan sessions
30
+ p View projects
31
+ l View log files
32
+ r Reload data
33
+
34
+ General:
35
+ Esc / q Go back / Quit
36
+ ? Show help
37
+ `)
38
+ process.exit(0)
39
+ }
40
+
41
+ if (args.includes("--version") || args.includes("-v")) {
42
+ const pkg = await import("../package.json")
43
+ console.log(pkg.version)
44
+ process.exit(0)
45
+ }
46
+
47
+ // Start the app
48
+ const app = new App()
49
+ await app.init()
50
+ app.run()
51
+ }
52
+
53
+ main().catch((error) => {
54
+ console.error("Fatal error:", error)
55
+ process.exit(1)
56
+ })
package/src/types.ts ADDED
@@ -0,0 +1,173 @@
1
+ // ============================================================================
2
+ // Session Types
3
+ // ============================================================================
4
+
5
+ export interface SessionTime {
6
+ created: number
7
+ updated: number
8
+ }
9
+
10
+ export interface SessionSummary {
11
+ additions: number
12
+ deletions: number
13
+ files: number
14
+ }
15
+
16
+ export interface Session {
17
+ id: string
18
+ slug: string
19
+ version: string
20
+ projectID: string
21
+ directory: string
22
+ parentID?: string
23
+ title?: string
24
+ time: SessionTime
25
+ summary?: SessionSummary
26
+ }
27
+
28
+ export interface SessionInfo extends Session {
29
+ sizeBytes: number
30
+ messageCount: number
31
+ isOrphan: boolean
32
+ projectWorktree: string
33
+ }
34
+
35
+ // ============================================================================
36
+ // Message & Part Types
37
+ // ============================================================================
38
+
39
+ export interface MessageTime {
40
+ created: number
41
+ completed?: number
42
+ }
43
+
44
+ export interface MessageTokens {
45
+ input: number
46
+ output: number
47
+ reasoning: number
48
+ cache: { read: number; write: number }
49
+ }
50
+
51
+ export interface Message {
52
+ id: string
53
+ sessionID: string
54
+ role: "user" | "assistant"
55
+ time: MessageTime
56
+ parentID?: string
57
+ modelID?: string
58
+ providerID?: string
59
+ mode?: string
60
+ agent?: string
61
+ cost?: number
62
+ tokens?: MessageTokens
63
+ finish?: string
64
+ }
65
+
66
+ export interface Part {
67
+ id: string
68
+ sessionID: string
69
+ messageID: string
70
+ type: "text" | "tool" | "step-start" | "step-finish"
71
+ text?: string
72
+ callID?: string
73
+ tool?: string
74
+ state?: {
75
+ status?: string
76
+ input?: Record<string, unknown>
77
+ output?: string
78
+ title?: string
79
+ metadata?: Record<string, unknown>
80
+ time?: { start: number; end: number }
81
+ }
82
+ time?: { start: number; end: number }
83
+ }
84
+
85
+ // ============================================================================
86
+ // Todo Types
87
+ // ============================================================================
88
+
89
+ export interface Todo {
90
+ id: string
91
+ content: string
92
+ status: "pending" | "in_progress" | "completed" | "cancelled"
93
+ priority: "high" | "medium" | "low"
94
+ }
95
+
96
+ // ============================================================================
97
+ // Project Storage Info
98
+ // ============================================================================
99
+
100
+ export interface ProjectStorageInfo {
101
+ sessionFiles: number
102
+ messageFiles: number
103
+ partFiles: number
104
+ diffSize: number
105
+ todoSize: number
106
+ totalMessages: number
107
+ totalParts: number
108
+ }
109
+
110
+ // ============================================================================
111
+ // Project Types
112
+ // ============================================================================
113
+
114
+ export interface ProjectTime {
115
+ created: number
116
+ updated: number
117
+ }
118
+
119
+ export interface Project {
120
+ id: string
121
+ worktree: string
122
+ vcs?: string
123
+ sandboxes?: string[]
124
+ time: ProjectTime
125
+ }
126
+
127
+ export interface ProjectInfo extends Project {
128
+ sessionCount: number
129
+ totalSizeBytes: number
130
+ isOrphan: boolean
131
+ sessions: SessionInfo[]
132
+ }
133
+
134
+ // ============================================================================
135
+ // Frecency & Log Types
136
+ // ============================================================================
137
+
138
+ export interface FrecencyEntry {
139
+ path: string
140
+ frequency: number
141
+ lastOpen: number
142
+ }
143
+
144
+ export interface LogFile {
145
+ path: string
146
+ filename: string
147
+ date: Date
148
+ sizeBytes: number
149
+ }
150
+
151
+ // ============================================================================
152
+ // UI Types
153
+ // ============================================================================
154
+
155
+ export type ViewType = "main" | "orphans" | "logs"
156
+
157
+ // View order for Tab navigation
158
+ export const VIEW_ORDER: ViewType[] = ["main", "orphans", "logs"]
159
+
160
+ export interface AppState {
161
+ view: ViewType
162
+ sessions: SessionInfo[]
163
+ projects: ProjectInfo[]
164
+ logs: LogFile[]
165
+ selectedIndices: Set<number>
166
+ currentIndex: number
167
+ expandedProjects: Set<string>
168
+ showConfirm: boolean
169
+ confirmAction: (() => Promise<void>) | null
170
+ confirmMessage: string
171
+ isLoading: boolean
172
+ statusMessage: string
173
+ }