prjct-cli 0.11.0 → 0.11.1

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.
Files changed (80) hide show
  1. package/package.json +11 -1
  2. package/packages/shared/dist/index.d.ts +615 -0
  3. package/packages/shared/dist/index.js +204 -0
  4. package/packages/shared/package.json +29 -0
  5. package/packages/shared/src/index.ts +9 -0
  6. package/packages/shared/src/schemas.ts +124 -0
  7. package/packages/shared/src/types.ts +187 -0
  8. package/packages/shared/src/utils.ts +148 -0
  9. package/packages/shared/tsconfig.json +18 -0
  10. package/packages/web/README.md +36 -0
  11. package/packages/web/app/api/claude/sessions/route.ts +44 -0
  12. package/packages/web/app/api/claude/status/route.ts +34 -0
  13. package/packages/web/app/api/projects/[id]/delete/route.ts +21 -0
  14. package/packages/web/app/api/projects/[id]/icon/route.ts +33 -0
  15. package/packages/web/app/api/projects/[id]/route.ts +29 -0
  16. package/packages/web/app/api/projects/[id]/stats/route.ts +36 -0
  17. package/packages/web/app/api/projects/[id]/status/route.ts +21 -0
  18. package/packages/web/app/api/projects/route.ts +16 -0
  19. package/packages/web/app/api/sessions/history/route.ts +122 -0
  20. package/packages/web/app/api/stats/route.ts +38 -0
  21. package/packages/web/app/error.tsx +34 -0
  22. package/packages/web/app/favicon.ico +0 -0
  23. package/packages/web/app/globals.css +155 -0
  24. package/packages/web/app/layout.tsx +43 -0
  25. package/packages/web/app/loading.tsx +7 -0
  26. package/packages/web/app/not-found.tsx +25 -0
  27. package/packages/web/app/page.tsx +227 -0
  28. package/packages/web/app/project/[id]/error.tsx +41 -0
  29. package/packages/web/app/project/[id]/loading.tsx +9 -0
  30. package/packages/web/app/project/[id]/not-found.tsx +27 -0
  31. package/packages/web/app/project/[id]/page.tsx +253 -0
  32. package/packages/web/app/project/[id]/stats/page.tsx +447 -0
  33. package/packages/web/app/sessions/page.tsx +165 -0
  34. package/packages/web/app/settings/page.tsx +150 -0
  35. package/packages/web/components/AppSidebar.tsx +113 -0
  36. package/packages/web/components/CommandButton.tsx +39 -0
  37. package/packages/web/components/ConnectionStatus.tsx +29 -0
  38. package/packages/web/components/Logo.tsx +65 -0
  39. package/packages/web/components/MarkdownContent.tsx +123 -0
  40. package/packages/web/components/ProjectAvatar.tsx +54 -0
  41. package/packages/web/components/TechStackBadges.tsx +20 -0
  42. package/packages/web/components/TerminalTab.tsx +84 -0
  43. package/packages/web/components/TerminalTabs.tsx +210 -0
  44. package/packages/web/components/charts/SessionsChart.tsx +172 -0
  45. package/packages/web/components/providers.tsx +45 -0
  46. package/packages/web/components/ui/alert-dialog.tsx +157 -0
  47. package/packages/web/components/ui/badge.tsx +46 -0
  48. package/packages/web/components/ui/button.tsx +60 -0
  49. package/packages/web/components/ui/card.tsx +92 -0
  50. package/packages/web/components/ui/chart.tsx +385 -0
  51. package/packages/web/components/ui/dropdown-menu.tsx +257 -0
  52. package/packages/web/components/ui/scroll-area.tsx +58 -0
  53. package/packages/web/components/ui/sheet.tsx +139 -0
  54. package/packages/web/components/ui/tabs.tsx +66 -0
  55. package/packages/web/components/ui/tooltip.tsx +61 -0
  56. package/packages/web/components.json +22 -0
  57. package/packages/web/context/TerminalContext.tsx +45 -0
  58. package/packages/web/context/TerminalTabsContext.tsx +136 -0
  59. package/packages/web/eslint.config.mjs +18 -0
  60. package/packages/web/hooks/useClaudeTerminal.ts +375 -0
  61. package/packages/web/hooks/useProjectStats.ts +38 -0
  62. package/packages/web/hooks/useProjects.ts +73 -0
  63. package/packages/web/hooks/useStats.ts +28 -0
  64. package/packages/web/lib/format.ts +23 -0
  65. package/packages/web/lib/parse-prjct-files.ts +1122 -0
  66. package/packages/web/lib/projects.ts +452 -0
  67. package/packages/web/lib/pty.ts +101 -0
  68. package/packages/web/lib/query-config.ts +44 -0
  69. package/packages/web/lib/utils.ts +6 -0
  70. package/packages/web/next-env.d.ts +6 -0
  71. package/packages/web/next.config.ts +7 -0
  72. package/packages/web/package.json +53 -0
  73. package/packages/web/postcss.config.mjs +7 -0
  74. package/packages/web/public/file.svg +1 -0
  75. package/packages/web/public/globe.svg +1 -0
  76. package/packages/web/public/next.svg +1 -0
  77. package/packages/web/public/vercel.svg +1 -0
  78. package/packages/web/public/window.svg +1 -0
  79. package/packages/web/server.ts +262 -0
  80. package/packages/web/tsconfig.json +34 -0
@@ -0,0 +1,452 @@
1
+ /**
2
+ * Project utilities for prjct
3
+ */
4
+
5
+ import { promises as fs } from 'fs'
6
+ import { join, dirname } from 'path'
7
+ import { homedir } from 'os'
8
+ import { exec } from 'child_process'
9
+ import { promisify } from 'util'
10
+
11
+ const execAsync = promisify(exec)
12
+
13
+ export const GLOBAL_STORAGE = join(homedir(), '.prjct-cli', 'projects')
14
+ export const TRASH_PATH = join(homedir(), '.prjct-cli', '.trash')
15
+
16
+ // Cache for project paths (projectId -> real path)
17
+ const projectPathCache = new Map<string, string>()
18
+
19
+ /**
20
+ * Scan common directories for .prjct/prjct.config.json files
21
+ */
22
+ export async function scanForProjects(): Promise<Map<string, string>> {
23
+ const searchPaths = [
24
+ join(homedir(), 'Apps'),
25
+ join(homedir(), 'Projects'),
26
+ join(homedir(), 'Documents'),
27
+ join(homedir(), 'Development'),
28
+ join(homedir(), 'Code'),
29
+ join(homedir(), 'dev'),
30
+ ]
31
+
32
+ for (const searchPath of searchPaths) {
33
+ try {
34
+ const { stdout } = await execAsync(
35
+ `find "${searchPath}" -maxdepth 4 -type f -name "prjct.config.json" -path "*/.prjct/*" 2>/dev/null`,
36
+ { timeout: 5000 }
37
+ )
38
+
39
+ const configFiles = stdout.trim().split('\n').filter(Boolean)
40
+
41
+ for (const configFile of configFiles) {
42
+ try {
43
+ const content = await fs.readFile(configFile, 'utf-8')
44
+ const config = JSON.parse(content)
45
+ if (config.projectId) {
46
+ const projectPath = dirname(dirname(configFile))
47
+ projectPathCache.set(config.projectId, projectPath)
48
+ }
49
+ } catch {
50
+ // Skip invalid config files
51
+ }
52
+ }
53
+ } catch {
54
+ // Skip directories that don't exist or are not accessible
55
+ }
56
+ }
57
+
58
+ return projectPathCache
59
+ }
60
+
61
+ /**
62
+ * Extract project path from CLAUDE.md
63
+ */
64
+ export function extractProjectPath(claudeMd: string): string | null {
65
+ const pathMatch = claudeMd.match(/(?:Path|Location|Directory):\s*`?([^\n`]+)`?/i)
66
+ if (pathMatch) return pathMatch[1].trim()
67
+
68
+ const infoMatch = claudeMd.match(/\*\*Path\*\*:\s*`?([^\n`]+)`?/i)
69
+ if (infoMatch) return infoMatch[1].trim()
70
+
71
+ return null
72
+ }
73
+
74
+ /**
75
+ * Get all projects with rich metadata
76
+ */
77
+ export async function getProjects() {
78
+ if (projectPathCache.size === 0) {
79
+ await scanForProjects()
80
+ }
81
+
82
+ const projects = []
83
+
84
+ try {
85
+ const dirs = await fs.readdir(GLOBAL_STORAGE)
86
+
87
+ for (const projectId of dirs) {
88
+ // Skip hidden directories like .trash
89
+ if (projectId.startsWith('.')) continue
90
+
91
+ const storagePath = join(GLOBAL_STORAGE, projectId)
92
+
93
+ // Try project.json first (source of truth)
94
+ let name: string = projectId
95
+ let repoPath: string | null = null
96
+ let techStack: string[] = []
97
+
98
+ try {
99
+ const projectJsonPath = join(storagePath, 'project.json')
100
+ const projectJson = JSON.parse(await fs.readFile(projectJsonPath, 'utf-8'))
101
+ name = projectJson.name || projectId
102
+ repoPath = projectJson.repoPath || null
103
+ techStack = projectJson.techStack || []
104
+ } catch {
105
+ // Fallback to CLAUDE.md for name/repoPath only
106
+ // techStack comes from project.json (populated by /p:sync)
107
+ try {
108
+ const claudeMd = await fs.readFile(join(storagePath, 'CLAUDE.md'), 'utf-8')
109
+ const nameMatch = claudeMd.match(/# (.+) - Project Context/)
110
+ if (nameMatch) name = nameMatch[1]
111
+
112
+ const cachedPath = projectPathCache.get(projectId)
113
+ repoPath = cachedPath || extractProjectPath(claudeMd)
114
+ } catch {
115
+ // Skip projects without valid config
116
+ continue
117
+ }
118
+ }
119
+
120
+ // Get current task
121
+ let currentTask: string | null = null
122
+ try {
123
+ const nowContent = await fs.readFile(join(storagePath, 'core', 'now.md'), 'utf-8')
124
+ // Skip headers like "# NOW", "# Current Task" and find the actual task content
125
+ // Look for **bold text** (task description) or first non-header, non-metadata line
126
+ const boldMatch = nowContent.match(/\*\*([^*]+)\*\*/)
127
+ if (boldMatch && boldMatch[1].trim() && !boldMatch[1].includes(':')) {
128
+ currentTask = boldMatch[1].trim()
129
+ } else {
130
+ // Find first content line that's not a header, metadata, or empty
131
+ const lines = nowContent.split('\n')
132
+ for (const line of lines) {
133
+ const trimmed = line.trim()
134
+ // Skip headers, empty lines, metadata lines (key: value), and "No active/current task" messages
135
+ if (trimmed &&
136
+ !trimmed.startsWith('#') &&
137
+ !trimmed.toLowerCase().includes('no active task') &&
138
+ !trimmed.toLowerCase().includes('no current task') &&
139
+ !trimmed.match(/^(Feature|Started|Status|Agent):/i) &&
140
+ !trimmed.startsWith('**') &&
141
+ !trimmed.startsWith('-')) {
142
+ currentTask = trimmed
143
+ break
144
+ }
145
+ }
146
+ }
147
+ // Truncate if too long
148
+ if (currentTask && currentTask.length > 60) {
149
+ currentTask = currentTask.substring(0, 57) + '...'
150
+ }
151
+ } catch {}
152
+
153
+ // Get session status and last activity
154
+ let hasActiveSession = false
155
+ let lastActivity: string | null = null
156
+
157
+ // Try current session first
158
+ try {
159
+ const sessionPath = join(storagePath, 'sessions', 'current.json')
160
+ const sessionData = JSON.parse(await fs.readFile(sessionPath, 'utf-8'))
161
+ hasActiveSession = sessionData.status === 'active'
162
+ lastActivity = sessionData.startedAt || sessionData.updatedAt
163
+ } catch {}
164
+
165
+ // If no session, get last modified time from key files
166
+ if (!lastActivity) {
167
+ const filesToCheck = [
168
+ join(storagePath, 'core', 'now.md'),
169
+ join(storagePath, 'core', 'next.md'),
170
+ join(storagePath, 'planning', 'ideas.md'),
171
+ join(storagePath, 'progress', 'shipped.md'),
172
+ join(storagePath, 'memory', 'context.jsonl'),
173
+ join(storagePath, 'CLAUDE.md')
174
+ ]
175
+
176
+ let latestMtime = 0
177
+ for (const filePath of filesToCheck) {
178
+ try {
179
+ const stat = await fs.stat(filePath)
180
+ if (stat.mtimeMs > latestMtime) {
181
+ latestMtime = stat.mtimeMs
182
+ }
183
+ } catch {}
184
+ }
185
+
186
+ if (latestMtime > 0) {
187
+ lastActivity = new Date(latestMtime).toISOString()
188
+ }
189
+ }
190
+
191
+ // Count ideas and next tasks
192
+ let ideasCount = 0
193
+ let nextTasksCount = 0
194
+ try {
195
+ const ideasContent = await fs.readFile(join(storagePath, 'planning', 'ideas.md'), 'utf-8')
196
+ ideasCount = (ideasContent.match(/^- /gm) || []).length
197
+ } catch {}
198
+ try {
199
+ const nextContent = await fs.readFile(join(storagePath, 'core', 'next.md'), 'utf-8')
200
+ nextTasksCount = (nextContent.match(/^- /gm) || []).length
201
+ } catch {}
202
+
203
+ // Find favicon/icon in project repo
204
+ let iconPath: string | null = null
205
+ if (repoPath) {
206
+ const iconPatterns = [
207
+ 'public/favicon.ico',
208
+ 'public/favicon.svg',
209
+ 'public/icon.svg',
210
+ 'public/icon.png',
211
+ 'public/logo.svg',
212
+ 'public/logo.png',
213
+ 'app/favicon.ico',
214
+ 'app/icon.svg',
215
+ 'app/icon.png',
216
+ 'favicon.ico',
217
+ 'favicon.svg'
218
+ ]
219
+ for (const pattern of iconPatterns) {
220
+ try {
221
+ const fullPath = join(repoPath, pattern)
222
+ await fs.access(fullPath)
223
+ iconPath = fullPath
224
+ break
225
+ } catch {}
226
+ }
227
+ }
228
+
229
+ projects.push({
230
+ id: projectId,
231
+ name,
232
+ path: repoPath || storagePath,
233
+ repoPath,
234
+ storagePath,
235
+ currentTask,
236
+ hasActiveSession,
237
+ lastActivity,
238
+ ideasCount,
239
+ nextTasksCount,
240
+ techStack,
241
+ iconPath
242
+ })
243
+ }
244
+ } catch {
245
+ // Storage directory doesn't exist
246
+ }
247
+
248
+ // Sort by lastActivity (most recent first), then by name
249
+ projects.sort((a, b) => {
250
+ if (a.lastActivity && b.lastActivity) {
251
+ return new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()
252
+ }
253
+ if (a.lastActivity) return -1
254
+ if (b.lastActivity) return 1
255
+ return a.name.localeCompare(b.name)
256
+ })
257
+
258
+ return projects
259
+ }
260
+
261
+ /**
262
+ * Get project by ID
263
+ */
264
+ export async function getProject(projectId: string) {
265
+ const storagePath = join(GLOBAL_STORAGE, projectId)
266
+
267
+ // 1. Try to read from project.json (source of truth)
268
+ let repoPath: string | null = null
269
+ let name: string = projectId
270
+
271
+ try {
272
+ const projectJsonPath = join(storagePath, 'project.json')
273
+ const projectJson = JSON.parse(await fs.readFile(projectJsonPath, 'utf-8'))
274
+ repoPath = projectJson.repoPath || null
275
+ name = projectJson.name || projectId
276
+ } catch {
277
+ // project.json doesn't exist - fallback to scan
278
+ if (projectPathCache.size === 0) {
279
+ await scanForProjects()
280
+ }
281
+ repoPath = projectPathCache.get(projectId) || null
282
+ }
283
+
284
+ try {
285
+ const claudeMd = await fs.readFile(join(storagePath, 'CLAUDE.md'), 'utf-8')
286
+
287
+ // If still no repoPath, try extracting from CLAUDE.md
288
+ if (!repoPath) {
289
+ repoPath = extractProjectPath(claudeMd)
290
+ }
291
+
292
+ // If name is still projectId, try CLAUDE.md
293
+ if (name === projectId) {
294
+ const nameMatch = claudeMd.match(/# (.+) - Project Context/)
295
+ if (nameMatch) name = nameMatch[1]
296
+ }
297
+
298
+ let currentSession = null
299
+ try {
300
+ const sessionPath = join(storagePath, 'sessions', 'current.json')
301
+ const sessionData = await fs.readFile(sessionPath, 'utf-8')
302
+ currentSession = JSON.parse(sessionData)
303
+ } catch {}
304
+
305
+ let currentTask = null
306
+ try {
307
+ const nowPath = join(storagePath, 'core', 'now.md')
308
+ currentTask = await fs.readFile(nowPath, 'utf-8')
309
+ } catch {}
310
+
311
+ // Extract stats from claudeMd Quick Reference table
312
+ let version: string | null = null
313
+ let stack: string | null = null
314
+ let filesCount: string | null = null
315
+ let commitsCount: string | null = null
316
+ let techStack: string[] = []
317
+
318
+ // Parse version from | **Version** | 0.10.13 |
319
+ const versionMatch = claudeMd.match(/\*\*Version\*\*\s*\|\s*([^\n|]+)/)
320
+ if (versionMatch) version = versionMatch[1].trim()
321
+
322
+ // Parse stack from | **Stack** | Node.js CLI (CommonJS) |
323
+ const stackMatch = claudeMd.match(/\*\*Stack\*\*\s*\|\s*([^\n|]+)/)
324
+ if (stackMatch) stack = stackMatch[1].trim()
325
+
326
+ // Parse files from | **Files** | 130+ |
327
+ const filesMatch = claudeMd.match(/\*\*Files\*\*\s*\|\s*([^\n|]+)/)
328
+ if (filesMatch) filesCount = filesMatch[1].trim()
329
+
330
+ // Parse commits from | **Commits** | 175 |
331
+ const commitsMatch = claudeMd.match(/\*\*Commits\*\*\s*\|\s*([^\n|]+)/)
332
+ if (commitsMatch) commitsCount = commitsMatch[1].trim()
333
+
334
+ // Get techStack from project.json
335
+ try {
336
+ const projectJsonPath = join(storagePath, 'project.json')
337
+ const projectJson = JSON.parse(await fs.readFile(projectJsonPath, 'utf-8'))
338
+ techStack = projectJson.techStack || []
339
+ } catch {}
340
+
341
+ // Find favicon/icon in project repo
342
+ let iconPath: string | null = null
343
+ if (repoPath) {
344
+ const iconPatterns = [
345
+ 'public/favicon.ico',
346
+ 'public/favicon.svg',
347
+ 'public/icon.svg',
348
+ 'public/icon.png',
349
+ 'public/logo.svg',
350
+ 'public/logo.png',
351
+ 'app/favicon.ico',
352
+ 'app/icon.svg',
353
+ 'app/icon.png',
354
+ 'favicon.ico',
355
+ 'favicon.svg'
356
+ ]
357
+ for (const pattern of iconPatterns) {
358
+ try {
359
+ const fullPath = join(repoPath, pattern)
360
+ await fs.access(fullPath)
361
+ iconPath = fullPath
362
+ break
363
+ } catch {}
364
+ }
365
+ }
366
+
367
+ return {
368
+ id: projectId,
369
+ name,
370
+ path: storagePath, // Storage path (for prjct data)
371
+ repoPath, // Real repository path (for terminal/Claude)
372
+ storagePath,
373
+ claudeMd,
374
+ currentSession,
375
+ currentTask,
376
+ // Parsed stats
377
+ version,
378
+ stack,
379
+ filesCount,
380
+ commitsCount,
381
+ techStack,
382
+ iconPath
383
+ }
384
+ } catch {
385
+ return null
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Move project to trash (soft delete)
391
+ */
392
+ export async function moveToTrash(projectId: string) {
393
+ const sourcePath = join(GLOBAL_STORAGE, projectId)
394
+ const trashPath = join(TRASH_PATH, projectId)
395
+
396
+ // Verify source exists
397
+ try {
398
+ await fs.access(sourcePath)
399
+ } catch {
400
+ throw new Error(`Project ${projectId} not found`)
401
+ }
402
+
403
+ // Create trash directory if it doesn't exist
404
+ await fs.mkdir(TRASH_PATH, { recursive: true })
405
+
406
+ // Move to trash
407
+ await fs.rename(sourcePath, trashPath)
408
+
409
+ // Write deletion metadata
410
+ const deletedAt = new Date().toISOString()
411
+ await fs.writeFile(
412
+ join(trashPath, '.deleted'),
413
+ JSON.stringify({ deletedAt, projectId })
414
+ )
415
+
416
+ return { trashedAt: deletedAt }
417
+ }
418
+
419
+ /**
420
+ * Get project status
421
+ */
422
+ export async function getProjectStatus(projectId: string) {
423
+ const projectPath = join(GLOBAL_STORAGE, projectId)
424
+
425
+ let session = null
426
+ try {
427
+ const sessionPath = join(projectPath, 'sessions', 'current.json')
428
+ session = JSON.parse(await fs.readFile(sessionPath, 'utf-8'))
429
+ } catch {}
430
+
431
+ let ideas: string[] = []
432
+ try {
433
+ const ideasPath = join(projectPath, 'planning', 'ideas.md')
434
+ const content = await fs.readFile(ideasPath, 'utf-8')
435
+ ideas = content.split('\n').filter(l => l.startsWith('- ')).slice(0, 5)
436
+ } catch {}
437
+
438
+ let nextTasks: string[] = []
439
+ try {
440
+ const nextPath = join(projectPath, 'core', 'next.md')
441
+ const content = await fs.readFile(nextPath, 'utf-8')
442
+ nextTasks = content.split('\n').filter(l => l.startsWith('- ')).slice(0, 5)
443
+ } catch {}
444
+
445
+ return {
446
+ projectId,
447
+ session,
448
+ hasActiveSession: session?.status === 'active',
449
+ ideas,
450
+ nextTasks
451
+ }
452
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * PTY Manager - Handle Claude Code CLI sessions via pseudo-terminal
3
+ */
4
+
5
+ import * as pty from 'node-pty'
6
+ import type { IPty } from 'node-pty'
7
+
8
+ interface Session {
9
+ pty: IPty
10
+ projectDir: string
11
+ createdAt: Date
12
+ }
13
+
14
+ const sessions = new Map<string, Session>()
15
+
16
+ export function createClaudeSession(sessionId: string, projectDir: string): IPty {
17
+ // Kill existing session if any
18
+ const existing = sessions.get(sessionId)
19
+ if (existing) {
20
+ try {
21
+ existing.pty.kill()
22
+ } catch {
23
+ // Ignore
24
+ }
25
+ sessions.delete(sessionId)
26
+ }
27
+
28
+ // Spawn claude CLI
29
+ const shell = process.platform === 'win32' ? 'cmd.exe' : 'bash'
30
+ const args = process.platform === 'win32' ? [] : ['-l']
31
+
32
+ const ptyProcess = pty.spawn(shell, args, {
33
+ name: 'xterm-256color',
34
+ cols: 120,
35
+ rows: 30,
36
+ cwd: projectDir,
37
+ env: {
38
+ ...process.env,
39
+ TERM: 'xterm-256color',
40
+ COLORTERM: 'truecolor'
41
+ }
42
+ })
43
+
44
+ // Store session
45
+ sessions.set(sessionId, {
46
+ pty: ptyProcess,
47
+ projectDir,
48
+ createdAt: new Date()
49
+ })
50
+
51
+ // Auto-start Claude Code CLI
52
+ setTimeout(() => {
53
+ ptyProcess.write('claude\r')
54
+ }, 500)
55
+
56
+ return ptyProcess
57
+ }
58
+
59
+ export function getSession(sessionId: string): IPty | null {
60
+ const session = sessions.get(sessionId)
61
+ return session?.pty || null
62
+ }
63
+
64
+ export function killSession(sessionId: string): boolean {
65
+ const session = sessions.get(sessionId)
66
+ if (session) {
67
+ try {
68
+ session.pty.kill()
69
+ } catch {
70
+ // Ignore
71
+ }
72
+ sessions.delete(sessionId)
73
+ return true
74
+ }
75
+ return false
76
+ }
77
+
78
+ export function resizeSession(sessionId: string, cols: number, rows: number): boolean {
79
+ const session = sessions.get(sessionId)
80
+ if (session) {
81
+ try {
82
+ session.pty.resize(cols, rows)
83
+ return true
84
+ } catch {
85
+ return false
86
+ }
87
+ }
88
+ return false
89
+ }
90
+
91
+ export function listSessions(): { sessionId: string; projectDir: string; createdAt: Date }[] {
92
+ const result: { sessionId: string; projectDir: string; createdAt: Date }[] = []
93
+ sessions.forEach((session, sessionId) => {
94
+ result.push({
95
+ sessionId,
96
+ projectDir: session.projectDir,
97
+ createdAt: session.createdAt
98
+ })
99
+ })
100
+ return result
101
+ }
@@ -0,0 +1,44 @@
1
+ import type { UseQueryOptions } from '@tanstack/react-query'
2
+
3
+ // Refresh intervals in milliseconds
4
+ export const REFRESH_INTERVALS = {
5
+ realtime: 2000, // 2s - for active sessions, connection status
6
+ fast: 5000, // 5s - for project status, current task
7
+ normal: 10000, // 10s - for project list, stats
8
+ slow: 30000, // 30s - for historical data
9
+ } as const
10
+
11
+ // Default query options for different data freshness needs
12
+ export const queryPresets = {
13
+ // For data that needs to feel "live" (sessions, connection status)
14
+ realtime: {
15
+ staleTime: 0,
16
+ refetchInterval: REFRESH_INTERVALS.realtime,
17
+ refetchOnWindowFocus: true,
18
+ refetchOnReconnect: true,
19
+ },
20
+
21
+ // For frequently changing data (project status, tasks)
22
+ fast: {
23
+ staleTime: REFRESH_INTERVALS.fast / 2,
24
+ refetchInterval: REFRESH_INTERVALS.fast,
25
+ refetchOnWindowFocus: true,
26
+ refetchOnReconnect: true,
27
+ },
28
+
29
+ // For moderately changing data (project list, stats)
30
+ normal: {
31
+ staleTime: REFRESH_INTERVALS.normal / 2,
32
+ refetchInterval: REFRESH_INTERVALS.normal,
33
+ refetchOnWindowFocus: true,
34
+ refetchOnReconnect: true,
35
+ },
36
+
37
+ // For rarely changing data (settings, historical)
38
+ slow: {
39
+ staleTime: REFRESH_INTERVALS.slow / 2,
40
+ refetchInterval: REFRESH_INTERVALS.slow,
41
+ refetchOnWindowFocus: true,
42
+ refetchOnReconnect: false,
43
+ },
44
+ } as const satisfies Record<string, Partial<UseQueryOptions>>
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
@@ -0,0 +1,6 @@
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+ import "./.next/dev/types/routes.d.ts";
4
+
5
+ // NOTE: This file should not be edited
6
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -0,0 +1,7 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ /* config options here */
5
+ };
6
+
7
+ export default nextConfig;
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "web",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "tsx server.ts",
7
+ "dev:next": "next dev",
8
+ "build": "next build",
9
+ "start": "node server.js",
10
+ "lint": "eslint"
11
+ },
12
+ "dependencies": {
13
+ "@radix-ui/react-alert-dialog": "^1.1.15",
14
+ "@radix-ui/react-dialog": "^1.1.15",
15
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
16
+ "@radix-ui/react-scroll-area": "^1.2.10",
17
+ "@radix-ui/react-slot": "^1.2.4",
18
+ "@radix-ui/react-tabs": "^1.1.13",
19
+ "@radix-ui/react-tooltip": "^1.2.8",
20
+ "@tanstack/react-query": "^5.90.12",
21
+ "@types/mime-types": "^3.0.1",
22
+ "@xterm/addon-fit": "^0.10.0",
23
+ "@xterm/addon-web-links": "^0.11.0",
24
+ "@xterm/xterm": "^5.5.0",
25
+ "class-variance-authority": "^0.7.1",
26
+ "clsx": "^2.1.1",
27
+ "lucide-react": "^0.556.0",
28
+ "mime-types": "^3.0.2",
29
+ "next": "16.0.7",
30
+ "next-themes": "^0.4.6",
31
+ "node-pty": "^1.0.0",
32
+ "react": "19.2.0",
33
+ "react-dom": "19.2.0",
34
+ "react-markdown": "^10.1.0",
35
+ "recharts": "^3.5.1",
36
+ "remark-gfm": "^4.0.1",
37
+ "tailwind-merge": "^3.4.0",
38
+ "ws": "^8.18.3"
39
+ },
40
+ "devDependencies": {
41
+ "@tailwindcss/postcss": "^4",
42
+ "@types/node": "^20",
43
+ "@types/react": "^19",
44
+ "@types/react-dom": "^19",
45
+ "@types/ws": "^8.18.0",
46
+ "eslint": "^9",
47
+ "eslint-config-next": "16.0.7",
48
+ "tailwindcss": "^4",
49
+ "tsx": "^4.19.2",
50
+ "tw-animate-css": "^1.4.0",
51
+ "typescript": "^5"
52
+ }
53
+ }
@@ -0,0 +1,7 @@
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
6
+
7
+ export default config;
@@ -0,0 +1 @@
1
+ <svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
@@ -0,0 +1 @@
1
+ <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>