prjct-cli 1.2.2 → 1.3.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.
- package/CHANGELOG.md +52 -0
- package/bin/prjct.ts +26 -0
- package/core/commands/analysis.ts +8 -2
- package/core/index.ts +26 -1
- package/core/services/session-tracker.ts +287 -0
- package/core/services/staleness-checker.ts +52 -0
- package/dist/bin/prjct.mjs +1135 -838
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.3.0] - 2026-02-06
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- session state tracking for multi-command workflows (PRJ-109) (#114)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## [1.3.0] - 2026-02-06
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
- session state tracking for multi-command workflows (PRJ-109)
|
|
15
|
+
|
|
16
|
+
### Implementation Details
|
|
17
|
+
|
|
18
|
+
New `SessionTracker` service (`core/services/session-tracker.ts`) that manages lightweight session lifecycle for tracking command sequences and file access across CLI invocations.
|
|
19
|
+
|
|
20
|
+
Key behaviors:
|
|
21
|
+
- **Auto-create**: Sessions start automatically on first CLI command
|
|
22
|
+
- **Auto-resume**: Subsequent commands within 30 min extend the existing session
|
|
23
|
+
- **Auto-expire**: Sessions expire after 30 minutes of idle time, cleaned up on next startup
|
|
24
|
+
- **Command tracking**: Records command name, timestamp, and duration (up to 50 commands)
|
|
25
|
+
- **File tracking**: Records file reads/writes with timestamps (up to 200 records)
|
|
26
|
+
|
|
27
|
+
Integration points:
|
|
28
|
+
- `core/index.ts` — touch/track in main CLI dispatch (all standard commands)
|
|
29
|
+
- `bin/prjct.ts` — `trackSession()` helper for context/hooks/doctor commands
|
|
30
|
+
- `core/commands/analysis.ts` — session info in `prjct status` output (JSON + human-readable)
|
|
31
|
+
- `core/services/staleness-checker.ts` — `getSessionInfo()` and `formatSessionInfo()` with box-drawing display
|
|
32
|
+
|
|
33
|
+
Storage: `~/.prjct-cli/projects/{projectId}/storage/session.json`
|
|
34
|
+
|
|
35
|
+
### Learnings
|
|
36
|
+
|
|
37
|
+
- Non-critical tracking should always be wrapped in try/catch with silent fail — session tracking must never break CLI commands
|
|
38
|
+
- Touch-on-every-command pattern is simple but effective for session detection — no explicit start/stop needed
|
|
39
|
+
- Box-drawing characters (`┌─┐│└─┘`) provide clean structured output without external dependencies
|
|
40
|
+
|
|
41
|
+
### Test Plan
|
|
42
|
+
|
|
43
|
+
#### For QA
|
|
44
|
+
1. Run `prjct status` — verify "Session: ▶ Active" with duration, commands, idle timer
|
|
45
|
+
2. Wait 30+ minutes, run `prjct status` — verify "Session: ○ No active session"
|
|
46
|
+
3. Run multiple commands in sequence — verify command count increments
|
|
47
|
+
4. Run `prjct status --json` — verify session object in JSON output
|
|
48
|
+
5. Run `bun run build && bun run typecheck` — zero errors
|
|
49
|
+
|
|
50
|
+
#### For Users
|
|
51
|
+
**What changed:** CLI now tracks session state across commands for workflow visibility
|
|
52
|
+
**How to use:** Run `prjct status` to see active session info
|
|
53
|
+
**Breaking changes:** None
|
|
54
|
+
|
|
3
55
|
## [1.2.2] - 2026-02-06
|
|
4
56
|
|
|
5
57
|
### Performance
|
package/bin/prjct.ts
CHANGED
|
@@ -64,6 +64,26 @@ if (isQuietMode) {
|
|
|
64
64
|
|
|
65
65
|
// Colors for output (chalk respects NO_COLOR env)
|
|
66
66
|
|
|
67
|
+
// Session tracking for commands that bypass core/index.ts
|
|
68
|
+
async function trackSession(command: string): Promise<() => void> {
|
|
69
|
+
const start = Date.now()
|
|
70
|
+
try {
|
|
71
|
+
const projectId = await configManager.getProjectId(process.cwd())
|
|
72
|
+
if (projectId) {
|
|
73
|
+
const { sessionTracker } = await import('../core/services/session-tracker')
|
|
74
|
+
await sessionTracker.expireIfStale(projectId)
|
|
75
|
+
await sessionTracker.touch(projectId)
|
|
76
|
+
return () => {
|
|
77
|
+
const durationMs = Date.now() - start
|
|
78
|
+
sessionTracker.trackCommand(projectId, command, durationMs).catch(() => {})
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// Non-critical
|
|
83
|
+
}
|
|
84
|
+
return () => {}
|
|
85
|
+
}
|
|
86
|
+
|
|
67
87
|
if (args[0] === 'start' || args[0] === 'setup') {
|
|
68
88
|
// Interactive setup with beautiful UI
|
|
69
89
|
const { runStart } = await import('../core/cli/start')
|
|
@@ -99,22 +119,28 @@ if (args[0] === 'start' || args[0] === 'setup') {
|
|
|
99
119
|
console.error('No prjct project found. Run "prjct init" first.')
|
|
100
120
|
process.exitCode = 1
|
|
101
121
|
} else {
|
|
122
|
+
const done = await trackSession('context')
|
|
102
123
|
const { runContextTool } = await import('../core/context-tools')
|
|
103
124
|
const result = await runContextTool(args.slice(1), projectId, projectPath)
|
|
104
125
|
console.log(JSON.stringify(result, null, 2))
|
|
105
126
|
process.exitCode = result.tool === 'error' ? 1 : 0
|
|
127
|
+
done()
|
|
106
128
|
}
|
|
107
129
|
} else if (args[0] === 'hooks') {
|
|
108
130
|
// Git hooks management
|
|
131
|
+
const done = await trackSession('hooks')
|
|
109
132
|
const { hooksService } = await import('../core/services/hooks-service')
|
|
110
133
|
const subcommand = args[1] || 'status'
|
|
111
134
|
const exitCode = await hooksService.run(process.cwd(), subcommand)
|
|
112
135
|
process.exitCode = exitCode
|
|
136
|
+
done()
|
|
113
137
|
} else if (args[0] === 'doctor') {
|
|
114
138
|
// Health check command
|
|
139
|
+
const done = await trackSession('doctor')
|
|
115
140
|
const { doctorService } = await import('../core/services/doctor-service')
|
|
116
141
|
const exitCode = await doctorService.run(process.cwd())
|
|
117
142
|
process.exitCode = exitCode
|
|
143
|
+
done()
|
|
118
144
|
} else if (args[0] === 'uninstall') {
|
|
119
145
|
// Complete system removal
|
|
120
146
|
const { uninstall } = await import('../core/commands/uninstall')
|
|
@@ -761,23 +761,29 @@ export class AnalysisCommands extends PrjctCommandsBase {
|
|
|
761
761
|
const checker = createStalenessChecker(projectPath)
|
|
762
762
|
const status = await checker.check(projectId)
|
|
763
763
|
|
|
764
|
+
// Get session info
|
|
765
|
+
const sessionInfo = await checker.getSessionInfo(projectId)
|
|
766
|
+
|
|
764
767
|
// JSON output mode
|
|
765
768
|
if (options.json) {
|
|
766
769
|
console.log(
|
|
767
770
|
JSON.stringify({
|
|
768
771
|
success: true,
|
|
769
772
|
...status,
|
|
773
|
+
session: sessionInfo,
|
|
770
774
|
})
|
|
771
775
|
)
|
|
772
|
-
return { success: true, data: status }
|
|
776
|
+
return { success: true, data: { ...status, session: sessionInfo } }
|
|
773
777
|
}
|
|
774
778
|
|
|
775
779
|
// Human-readable output
|
|
776
780
|
console.log('')
|
|
777
781
|
console.log(checker.formatStatus(status))
|
|
778
782
|
console.log('')
|
|
783
|
+
console.log(checker.formatSessionInfo(sessionInfo))
|
|
784
|
+
console.log('')
|
|
779
785
|
|
|
780
|
-
return { success: true, data: status }
|
|
786
|
+
return { success: true, data: { ...status, session: sessionInfo } }
|
|
781
787
|
} catch (error) {
|
|
782
788
|
const errMsg = (error as Error).message
|
|
783
789
|
if (options.json) {
|
package/core/index.ts
CHANGED
|
@@ -13,6 +13,8 @@ import path from 'node:path'
|
|
|
13
13
|
import chalk from 'chalk'
|
|
14
14
|
import type { CommandMeta } from './commands/registry'
|
|
15
15
|
import { detectAllProviders, detectAntigravity } from './infrastructure/ai-provider'
|
|
16
|
+
import configManager from './infrastructure/config-manager'
|
|
17
|
+
import { sessionTracker } from './services/session-tracker'
|
|
16
18
|
import out from './utils/output'
|
|
17
19
|
|
|
18
20
|
interface ParsedCommandArgs {
|
|
@@ -79,6 +81,19 @@ async function main(): Promise<void> {
|
|
|
79
81
|
// 4. Parse arguments
|
|
80
82
|
const { parsedArgs, options } = parseCommandArgs(cmd, rawArgs)
|
|
81
83
|
|
|
84
|
+
// 4.5. Session tracking — touch/create session before command execution
|
|
85
|
+
let projectId: string | null = null
|
|
86
|
+
const commandStartTime = Date.now()
|
|
87
|
+
try {
|
|
88
|
+
projectId = await configManager.getProjectId(process.cwd())
|
|
89
|
+
if (projectId) {
|
|
90
|
+
await sessionTracker.expireIfStale(projectId)
|
|
91
|
+
await sessionTracker.touch(projectId)
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// Session tracking is non-critical — silent fail
|
|
95
|
+
}
|
|
96
|
+
|
|
82
97
|
// 5. Instantiate commands handler
|
|
83
98
|
const commands = new PrjctCommands()
|
|
84
99
|
|
|
@@ -149,7 +164,17 @@ async function main(): Promise<void> {
|
|
|
149
164
|
}
|
|
150
165
|
}
|
|
151
166
|
|
|
152
|
-
// 7.
|
|
167
|
+
// 7. Track command in session
|
|
168
|
+
if (projectId) {
|
|
169
|
+
const durationMs = Date.now() - commandStartTime
|
|
170
|
+
try {
|
|
171
|
+
await sessionTracker.trackCommand(projectId, commandName, durationMs)
|
|
172
|
+
} catch {
|
|
173
|
+
// Non-critical
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 8. Display result
|
|
153
178
|
if (result?.message) {
|
|
154
179
|
console.log(result.message)
|
|
155
180
|
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionTracker - Lightweight session state tracking for multi-command workflows
|
|
3
|
+
*
|
|
4
|
+
* Tracks command sequences and file access across CLI invocations.
|
|
5
|
+
* Sessions auto-create on first command and expire after 30 min idle.
|
|
6
|
+
*
|
|
7
|
+
* Storage: ~/.prjct-cli/projects/{projectId}/storage/session.json
|
|
8
|
+
*
|
|
9
|
+
* @see PRJ-109
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs/promises'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import pathManager from '../infrastructure/path-manager'
|
|
15
|
+
import { isNotFoundError } from '../types/fs'
|
|
16
|
+
import { formatDuration, getTimestamp } from '../utils/date-helper'
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// TYPES
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
export interface CommandRecord {
|
|
23
|
+
command: string
|
|
24
|
+
timestamp: string
|
|
25
|
+
durationMs: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface FileRecord {
|
|
29
|
+
path: string
|
|
30
|
+
operation: 'read' | 'write'
|
|
31
|
+
timestamp: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SessionData {
|
|
35
|
+
id: string
|
|
36
|
+
projectId: string
|
|
37
|
+
status: 'active' | 'expired'
|
|
38
|
+
createdAt: string
|
|
39
|
+
lastActivity: string
|
|
40
|
+
commands: CommandRecord[]
|
|
41
|
+
files: FileRecord[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SessionFile {
|
|
45
|
+
current: SessionData | null
|
|
46
|
+
config: {
|
|
47
|
+
idleTimeoutMs: number
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SessionInfo {
|
|
52
|
+
active: boolean
|
|
53
|
+
id: string | null
|
|
54
|
+
duration: string | null
|
|
55
|
+
idleSince: string | null
|
|
56
|
+
idleMs: number
|
|
57
|
+
expiresIn: string | null
|
|
58
|
+
commandCount: number
|
|
59
|
+
commands: string[]
|
|
60
|
+
filesRead: number
|
|
61
|
+
filesWritten: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// =============================================================================
|
|
65
|
+
// CONSTANTS
|
|
66
|
+
// =============================================================================
|
|
67
|
+
|
|
68
|
+
const SESSION_FILENAME = 'session.json'
|
|
69
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes
|
|
70
|
+
const MAX_COMMAND_HISTORY = 50
|
|
71
|
+
const MAX_FILE_HISTORY = 200
|
|
72
|
+
|
|
73
|
+
// =============================================================================
|
|
74
|
+
// SESSION TRACKER
|
|
75
|
+
// =============================================================================
|
|
76
|
+
|
|
77
|
+
class SessionTracker {
|
|
78
|
+
private getPath(projectId: string): string {
|
|
79
|
+
return pathManager.getStoragePath(projectId, SESSION_FILENAME)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Read session file from disk
|
|
84
|
+
*/
|
|
85
|
+
private async read(projectId: string): Promise<SessionFile> {
|
|
86
|
+
const filePath = this.getPath(projectId)
|
|
87
|
+
try {
|
|
88
|
+
const content = await fs.readFile(filePath, 'utf-8')
|
|
89
|
+
return JSON.parse(content) as SessionFile
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (isNotFoundError(error) || error instanceof SyntaxError) {
|
|
92
|
+
return this.getDefault()
|
|
93
|
+
}
|
|
94
|
+
throw error
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Write session file to disk
|
|
100
|
+
*/
|
|
101
|
+
private async write(projectId: string, data: SessionFile): Promise<void> {
|
|
102
|
+
const filePath = this.getPath(projectId)
|
|
103
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
|
104
|
+
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private getDefault(): SessionFile {
|
|
108
|
+
return {
|
|
109
|
+
current: null,
|
|
110
|
+
config: {
|
|
111
|
+
idleTimeoutMs: DEFAULT_IDLE_TIMEOUT_MS,
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Check if a session has expired based on idle timeout
|
|
118
|
+
*/
|
|
119
|
+
private isExpired(session: SessionData, timeoutMs: number): boolean {
|
|
120
|
+
const lastActivity = new Date(session.lastActivity).getTime()
|
|
121
|
+
const now = Date.now()
|
|
122
|
+
return now - lastActivity > timeoutMs
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Touch session — create new or resume existing.
|
|
127
|
+
* Called at the start of every CLI command.
|
|
128
|
+
* Returns the active session.
|
|
129
|
+
*/
|
|
130
|
+
async touch(projectId: string): Promise<SessionData> {
|
|
131
|
+
const file = await this.read(projectId)
|
|
132
|
+
const now = getTimestamp()
|
|
133
|
+
|
|
134
|
+
// If active session exists and not expired, resume it
|
|
135
|
+
if (file.current && !this.isExpired(file.current, file.config.idleTimeoutMs)) {
|
|
136
|
+
file.current.lastActivity = now
|
|
137
|
+
await this.write(projectId, file)
|
|
138
|
+
return file.current
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Create new session (old one expired or doesn't exist)
|
|
142
|
+
const session: SessionData = {
|
|
143
|
+
id: crypto.randomUUID(),
|
|
144
|
+
projectId,
|
|
145
|
+
status: 'active',
|
|
146
|
+
createdAt: now,
|
|
147
|
+
lastActivity: now,
|
|
148
|
+
commands: [],
|
|
149
|
+
files: [],
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
file.current = session
|
|
153
|
+
await this.write(projectId, file)
|
|
154
|
+
return session
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Record a command execution in the current session
|
|
159
|
+
*/
|
|
160
|
+
async trackCommand(projectId: string, command: string, durationMs: number): Promise<void> {
|
|
161
|
+
const file = await this.read(projectId)
|
|
162
|
+
if (!file.current) return
|
|
163
|
+
|
|
164
|
+
const now = getTimestamp()
|
|
165
|
+
file.current.lastActivity = now
|
|
166
|
+
file.current.commands.push({
|
|
167
|
+
command,
|
|
168
|
+
timestamp: now,
|
|
169
|
+
durationMs,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// Trim old commands if over limit
|
|
173
|
+
if (file.current.commands.length > MAX_COMMAND_HISTORY) {
|
|
174
|
+
file.current.commands = file.current.commands.slice(-MAX_COMMAND_HISTORY)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
await this.write(projectId, file)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Record a file access in the current session
|
|
182
|
+
*/
|
|
183
|
+
async trackFile(projectId: string, filePath: string, operation: 'read' | 'write'): Promise<void> {
|
|
184
|
+
const file = await this.read(projectId)
|
|
185
|
+
if (!file.current) return
|
|
186
|
+
|
|
187
|
+
const now = getTimestamp()
|
|
188
|
+
file.current.lastActivity = now
|
|
189
|
+
file.current.files.push({
|
|
190
|
+
path: filePath,
|
|
191
|
+
operation,
|
|
192
|
+
timestamp: now,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// Trim old file records if over limit
|
|
196
|
+
if (file.current.files.length > MAX_FILE_HISTORY) {
|
|
197
|
+
file.current.files = file.current.files.slice(-MAX_FILE_HISTORY)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
await this.write(projectId, file)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get session info for display (used by `prjct status`)
|
|
205
|
+
*/
|
|
206
|
+
async getInfo(projectId: string): Promise<SessionInfo> {
|
|
207
|
+
const file = await this.read(projectId)
|
|
208
|
+
|
|
209
|
+
if (!file.current || this.isExpired(file.current, file.config.idleTimeoutMs)) {
|
|
210
|
+
return {
|
|
211
|
+
active: false,
|
|
212
|
+
id: null,
|
|
213
|
+
duration: null,
|
|
214
|
+
idleSince: null,
|
|
215
|
+
idleMs: 0,
|
|
216
|
+
expiresIn: null,
|
|
217
|
+
commandCount: 0,
|
|
218
|
+
commands: [],
|
|
219
|
+
filesRead: 0,
|
|
220
|
+
filesWritten: 0,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const session = file.current
|
|
225
|
+
const now = Date.now()
|
|
226
|
+
const createdAt = new Date(session.createdAt).getTime()
|
|
227
|
+
const lastActivity = new Date(session.lastActivity).getTime()
|
|
228
|
+
const idleMs = now - lastActivity
|
|
229
|
+
const timeoutMs = file.config.idleTimeoutMs
|
|
230
|
+
const expiresInMs = Math.max(0, timeoutMs - idleMs)
|
|
231
|
+
|
|
232
|
+
const uniqueCommands = session.commands.map((c) => c.command)
|
|
233
|
+
const filesRead = new Set(
|
|
234
|
+
session.files.filter((f) => f.operation === 'read').map((f) => f.path)
|
|
235
|
+
).size
|
|
236
|
+
const filesWritten = new Set(
|
|
237
|
+
session.files.filter((f) => f.operation === 'write').map((f) => f.path)
|
|
238
|
+
).size
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
active: true,
|
|
242
|
+
id: session.id,
|
|
243
|
+
duration: formatDuration(now - createdAt),
|
|
244
|
+
idleSince: session.lastActivity,
|
|
245
|
+
idleMs,
|
|
246
|
+
expiresIn: formatDuration(expiresInMs),
|
|
247
|
+
commandCount: session.commands.length,
|
|
248
|
+
commands: uniqueCommands,
|
|
249
|
+
filesRead,
|
|
250
|
+
filesWritten,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Expire the current session (cleanup)
|
|
256
|
+
*/
|
|
257
|
+
async expire(projectId: string): Promise<void> {
|
|
258
|
+
const file = await this.read(projectId)
|
|
259
|
+
if (file.current) {
|
|
260
|
+
file.current.status = 'expired'
|
|
261
|
+
file.current = null
|
|
262
|
+
await this.write(projectId, file)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Check and expire stale session if needed.
|
|
268
|
+
* Called on startup to clean up leftover sessions.
|
|
269
|
+
* Returns true if a session was expired.
|
|
270
|
+
*/
|
|
271
|
+
async expireIfStale(projectId: string): Promise<boolean> {
|
|
272
|
+
const file = await this.read(projectId)
|
|
273
|
+
if (file.current && this.isExpired(file.current, file.config.idleTimeoutMs)) {
|
|
274
|
+
file.current = null
|
|
275
|
+
await this.write(projectId, file)
|
|
276
|
+
return true
|
|
277
|
+
}
|
|
278
|
+
return false
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// =============================================================================
|
|
283
|
+
// EXPORTS
|
|
284
|
+
// =============================================================================
|
|
285
|
+
|
|
286
|
+
export const sessionTracker = new SessionTracker()
|
|
287
|
+
export default sessionTracker
|
|
@@ -12,6 +12,7 @@ import fs from 'node:fs/promises'
|
|
|
12
12
|
import path from 'node:path'
|
|
13
13
|
import { promisify } from 'node:util'
|
|
14
14
|
import pathManager from '../infrastructure/path-manager'
|
|
15
|
+
import { type SessionInfo, sessionTracker } from './session-tracker'
|
|
15
16
|
|
|
16
17
|
const execAsync = promisify(exec)
|
|
17
18
|
|
|
@@ -248,6 +249,57 @@ export class StalenessChecker {
|
|
|
248
249
|
return lines.join('\n')
|
|
249
250
|
}
|
|
250
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Get session info for the project
|
|
254
|
+
*/
|
|
255
|
+
async getSessionInfo(projectId: string): Promise<SessionInfo> {
|
|
256
|
+
return sessionTracker.getInfo(projectId)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Format session info for display
|
|
261
|
+
*/
|
|
262
|
+
formatSessionInfo(info: SessionInfo): string {
|
|
263
|
+
const lines: string[] = []
|
|
264
|
+
|
|
265
|
+
if (!info.active) {
|
|
266
|
+
lines.push('Session: ○ No active session')
|
|
267
|
+
return lines.join('\n')
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
lines.push(`Session: ▶ Active (${info.duration})`)
|
|
271
|
+
|
|
272
|
+
const details: string[] = []
|
|
273
|
+
if (info.commandCount > 0) {
|
|
274
|
+
// Show unique command sequence
|
|
275
|
+
const seen = new Set<string>()
|
|
276
|
+
const unique: string[] = []
|
|
277
|
+
for (const cmd of info.commands) {
|
|
278
|
+
if (!seen.has(cmd)) {
|
|
279
|
+
seen.add(cmd)
|
|
280
|
+
unique.push(cmd)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
details.push(`Commands: ${unique.join(' → ')} (${info.commandCount} total)`)
|
|
284
|
+
}
|
|
285
|
+
if (info.filesRead > 0 || info.filesWritten > 0) {
|
|
286
|
+
details.push(`Files: ${info.filesRead} read, ${info.filesWritten} written`)
|
|
287
|
+
}
|
|
288
|
+
details.push(`Idle: ${info.expiresIn} until timeout`)
|
|
289
|
+
|
|
290
|
+
if (details.length > 0) {
|
|
291
|
+
const maxLen = Math.max(...details.map((l) => l.length))
|
|
292
|
+
const border = '─'.repeat(maxLen + 2)
|
|
293
|
+
lines.push(`┌${border}┐`)
|
|
294
|
+
for (const detail of details) {
|
|
295
|
+
lines.push(`│ ${detail.padEnd(maxLen)} │`)
|
|
296
|
+
}
|
|
297
|
+
lines.push(`└${border}┘`)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return lines.join('\n')
|
|
301
|
+
}
|
|
302
|
+
|
|
251
303
|
/**
|
|
252
304
|
* Get a short warning message if stale (for other commands)
|
|
253
305
|
*/
|