@lzdi/pty-remote-cli 0.1.3

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,390 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import type { Dirent } from 'node:fs';
3
+ import type { FileHandle } from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ import type { ProjectSessionSummary } from '@lzdi/pty-remote-protocol/protocol.ts';
8
+
9
+ const DEFAULT_MAX_SESSIONS = 12;
10
+ const DEFAULT_HISTORY_TAIL_MAX_BYTES = 8 * 1024 * 1024;
11
+ const DEFAULT_HISTORY_TAIL_CHUNK_BYTES = 256 * 1024;
12
+ const SESSION_ID_PATTERN = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
13
+ const PENDING_INPUT_LABEL = '待输入';
14
+
15
+ interface SessionMetaPayload {
16
+ id?: string;
17
+ cwd?: string;
18
+ }
19
+
20
+ interface HistoryEntry {
21
+ sessionId: string;
22
+ tsMs: number;
23
+ text: string;
24
+ filePath: string;
25
+ cwd: string;
26
+ }
27
+
28
+ function normalizePreview(text: string): string {
29
+ return text.replace(/\s+/g, ' ').trim();
30
+ }
31
+
32
+ function compactTitle(text: string): string {
33
+ if (text.length <= 44) {
34
+ return text;
35
+ }
36
+ return `${text.slice(0, 41)}...`;
37
+ }
38
+
39
+ function normalizeMaxSessions(maxSessions: number): number {
40
+ return Number.isFinite(maxSessions) ? Math.max(1, Math.min(Math.floor(maxSessions), 50)) : DEFAULT_MAX_SESSIONS;
41
+ }
42
+
43
+ async function readFirstLine(filePath: string, maxBytes = 64 * 1024): Promise<string> {
44
+ const file = await fs.open(filePath, 'r');
45
+ try {
46
+ const buffer = Buffer.alloc(maxBytes);
47
+ const { bytesRead } = await file.read(buffer, 0, maxBytes, 0);
48
+ if (bytesRead <= 0) {
49
+ return '';
50
+ }
51
+ const raw = buffer.toString('utf8', 0, bytesRead);
52
+ return raw.split('\n', 1)[0] ?? '';
53
+ } finally {
54
+ await file.close();
55
+ }
56
+ }
57
+
58
+ async function parseSessionMeta(filePath: string): Promise<SessionMetaPayload | null> {
59
+ try {
60
+ const firstLine = (await readFirstLine(filePath)).trim();
61
+ if (!firstLine) {
62
+ return null;
63
+ }
64
+
65
+ const parsed = JSON.parse(firstLine) as { type?: string; payload?: SessionMetaPayload };
66
+ if (parsed.type !== 'session_meta' || !parsed.payload) {
67
+ return null;
68
+ }
69
+ return parsed.payload;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ async function walkSessionFiles(
76
+ rootPath: string,
77
+ onFile: (filePath: string, fileName: string) => Promise<boolean | void>
78
+ ): Promise<boolean> {
79
+ let entries: Dirent[];
80
+ try {
81
+ entries = await fs.readdir(rootPath, { withFileTypes: true });
82
+ } catch {
83
+ return false;
84
+ }
85
+
86
+ for (const entry of entries) {
87
+ const nextPath = path.join(rootPath, entry.name);
88
+ if (entry.isDirectory()) {
89
+ const shouldStop = await walkSessionFiles(nextPath, onFile);
90
+ if (shouldStop) {
91
+ return true;
92
+ }
93
+ continue;
94
+ }
95
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) {
96
+ continue;
97
+ }
98
+ const shouldStop = await onFile(nextPath, entry.name);
99
+ if (shouldStop) {
100
+ return true;
101
+ }
102
+ }
103
+
104
+ return false;
105
+ }
106
+
107
+ function extractSessionIdFromName(fileName: string): string | null {
108
+ return SESSION_ID_PATTERN.exec(fileName)?.[1] ?? null;
109
+ }
110
+
111
+ async function buildSessionFileIndex(rootPath: string): Promise<Map<string, string>> {
112
+ const index = new Map<string, string>();
113
+ await walkSessionFiles(rootPath, async (filePath, fileName) => {
114
+ const sessionId = extractSessionIdFromName(fileName);
115
+ if (!sessionId || index.has(sessionId)) {
116
+ return false;
117
+ }
118
+ index.set(sessionId, filePath);
119
+ return false;
120
+ });
121
+ return index;
122
+ }
123
+
124
+ async function findSessionFileById(rootPath: string, sessionId: string): Promise<string | null> {
125
+ const normalizedSuffix = `${sessionId.trim().toLowerCase()}.jsonl`;
126
+ if (!normalizedSuffix || normalizedSuffix === '.jsonl') {
127
+ return null;
128
+ }
129
+
130
+ let found: string | null = null;
131
+ await walkSessionFiles(rootPath, async (filePath, fileName) => {
132
+ if (fileName.toLowerCase().endsWith(normalizedSuffix)) {
133
+ found = filePath;
134
+ return true;
135
+ }
136
+ return false;
137
+ });
138
+
139
+ return found;
140
+ }
141
+
142
+ async function resolveSessionFilePath(
143
+ sessionId: string,
144
+ index: Map<string, string>,
145
+ sessionsRootPath: string
146
+ ): Promise<string | null> {
147
+ const fromIndex = index.get(sessionId);
148
+ if (fromIndex) {
149
+ return fromIndex;
150
+ }
151
+ return findSessionFileById(sessionsRootPath, sessionId);
152
+ }
153
+
154
+ function coerceHistoryTimestampMs(value: unknown): number | null {
155
+ if (typeof value === 'number' && Number.isFinite(value)) {
156
+ return value > 1e12 ? value : value * 1000;
157
+ }
158
+ if (typeof value === 'string') {
159
+ const parsed = Number(value);
160
+ if (Number.isFinite(parsed)) {
161
+ return parsed > 1e12 ? parsed : parsed * 1000;
162
+ }
163
+ }
164
+ return null;
165
+ }
166
+
167
+ async function readHistoryTail(
168
+ historyPath: string,
169
+ sessionsRootPath: string,
170
+ maxSessions: number,
171
+ minTimestampMs?: number,
172
+ projectRoot?: string
173
+ ): Promise<HistoryEntry[]> {
174
+ const normalizedProjectRoot = projectRoot ? path.resolve(projectRoot) : null;
175
+ const fileIndex = await buildSessionFileIndex(sessionsRootPath);
176
+ const results: HistoryEntry[] = [];
177
+ const seen = new Set<string>();
178
+
179
+ let file: FileHandle | null = null;
180
+ try {
181
+ file = await fs.open(historyPath, 'r');
182
+ } catch (error) {
183
+ const code = (error as NodeJS.ErrnoException).code;
184
+ if (code === 'ENOENT') {
185
+ return [];
186
+ }
187
+ throw error;
188
+ }
189
+
190
+ try {
191
+ const stat = await file.stat();
192
+ let position = stat.size;
193
+ let scanned = 0;
194
+ let leftover = '';
195
+ let stop = false;
196
+
197
+ while (position > 0 && results.length < maxSessions && scanned < DEFAULT_HISTORY_TAIL_MAX_BYTES && !stop) {
198
+ const readSize = Math.min(DEFAULT_HISTORY_TAIL_CHUNK_BYTES, position);
199
+ position -= readSize;
200
+ const buffer = Buffer.alloc(readSize);
201
+ const { bytesRead } = await file.read(buffer, 0, readSize, position);
202
+ if (bytesRead <= 0) {
203
+ break;
204
+ }
205
+ scanned += bytesRead;
206
+
207
+ const chunk = buffer.toString('utf8', 0, bytesRead) + leftover;
208
+ const lines = chunk.split('\n');
209
+ if (position > 0) {
210
+ leftover = lines.shift() ?? '';
211
+ } else {
212
+ leftover = '';
213
+ }
214
+
215
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
216
+ const line = lines[index]?.trim();
217
+ if (!line) {
218
+ continue;
219
+ }
220
+
221
+ let parsed: { session_id?: string; ts?: unknown; text?: unknown };
222
+ try {
223
+ parsed = JSON.parse(line) as { session_id?: string; ts?: unknown; text?: unknown };
224
+ } catch {
225
+ continue;
226
+ }
227
+
228
+ const sessionId = typeof parsed.session_id === 'string' ? parsed.session_id : null;
229
+ if (!sessionId || seen.has(sessionId)) {
230
+ continue;
231
+ }
232
+
233
+ const text = typeof parsed.text === 'string' ? parsed.text.trim() : '';
234
+ const tsMs = coerceHistoryTimestampMs(parsed.ts);
235
+ if (tsMs === null) {
236
+ continue;
237
+ }
238
+ if (minTimestampMs !== undefined && tsMs < minTimestampMs) {
239
+ stop = true;
240
+ break;
241
+ }
242
+
243
+ const filePath = await resolveSessionFilePath(sessionId, fileIndex, sessionsRootPath);
244
+ if (!filePath) {
245
+ continue;
246
+ }
247
+
248
+ const sessionMeta = await parseSessionMeta(filePath);
249
+ const cwd = typeof sessionMeta?.cwd === 'string' ? sessionMeta.cwd.trim() : '';
250
+ if (!cwd) {
251
+ continue;
252
+ }
253
+ const normalizedCwd = path.resolve(cwd);
254
+ if (normalizedProjectRoot && normalizedCwd !== normalizedProjectRoot) {
255
+ continue;
256
+ }
257
+
258
+ if (sessionMeta?.id && sessionMeta.id !== sessionId) {
259
+ continue;
260
+ }
261
+
262
+ results.push({
263
+ sessionId,
264
+ tsMs,
265
+ text,
266
+ filePath,
267
+ cwd: normalizedCwd
268
+ });
269
+ seen.add(sessionId);
270
+ if (results.length >= maxSessions) {
271
+ break;
272
+ }
273
+ }
274
+ }
275
+
276
+ return results;
277
+ } finally {
278
+ await file.close();
279
+ }
280
+ }
281
+
282
+ export interface CodexHistoryOptions {
283
+ sessionsRootPath?: string;
284
+ historyPath?: string;
285
+ }
286
+
287
+ export interface CodexSessionLookupResult {
288
+ filePath: string;
289
+ sessionId: string;
290
+ timestamp: string | null;
291
+ }
292
+
293
+ export function resolveCodexHistoryPaths(options: CodexHistoryOptions): { historyPath: string; sessionsRootPath: string } {
294
+ const codexRoot = process.env.CODEX_HOME?.trim() || path.join(os.homedir(), '.codex');
295
+ return {
296
+ historyPath: options.historyPath ?? path.join(codexRoot, 'history.jsonl'),
297
+ sessionsRootPath: options.sessionsRootPath ?? path.join(codexRoot, 'sessions')
298
+ };
299
+ }
300
+
301
+ export async function findCodexSessionFile(sessionId: string, options: CodexHistoryOptions = {}): Promise<string | null> {
302
+ const normalized = sessionId.trim();
303
+ if (!normalized) {
304
+ return null;
305
+ }
306
+
307
+ const { sessionsRootPath } = resolveCodexHistoryPaths(options);
308
+ return findSessionFileById(sessionsRootPath, normalized);
309
+ }
310
+
311
+ export async function findLatestCodexSessionForCwdSince(
312
+ projectRoot: string,
313
+ sinceMs: number,
314
+ options: CodexHistoryOptions = {}
315
+ ): Promise<CodexSessionLookupResult | null> {
316
+ const resolvedProjectRoot = path.resolve(projectRoot);
317
+ const canonicalProjectRoot = await fs.realpath(resolvedProjectRoot).catch(() => resolvedProjectRoot);
318
+ const { historyPath, sessionsRootPath } = resolveCodexHistoryPaths(options);
319
+ const toleranceMs = 15_000;
320
+ const minTimestampMs = Math.max(0, sinceMs - toleranceMs);
321
+
322
+ const matches = await readHistoryTail(historyPath, sessionsRootPath, 1, minTimestampMs, canonicalProjectRoot);
323
+ if (matches.length === 0) {
324
+ return null;
325
+ }
326
+
327
+ const match = matches[0];
328
+ return {
329
+ filePath: match.filePath,
330
+ sessionId: match.sessionId,
331
+ timestamp: new Date(match.tsMs).toISOString()
332
+ };
333
+ }
334
+
335
+ export async function listCodexRecentSessions(
336
+ maxSessions = DEFAULT_MAX_SESSIONS,
337
+ options: CodexHistoryOptions = {}
338
+ ): Promise<ProjectSessionSummary[]> {
339
+ const normalizedMax = normalizeMaxSessions(maxSessions);
340
+ const { historyPath, sessionsRootPath } = resolveCodexHistoryPaths(options);
341
+
342
+ const matches = await readHistoryTail(historyPath, sessionsRootPath, normalizedMax);
343
+ return matches
344
+ .map((entry) => {
345
+ const preview = normalizePreview(entry.text) || PENDING_INPUT_LABEL;
346
+ return {
347
+ providerId: 'codex',
348
+ sessionId: entry.sessionId,
349
+ cwd: entry.cwd,
350
+ title: compactTitle(preview),
351
+ preview,
352
+ updatedAt: new Date(entry.tsMs).toISOString(),
353
+ messageCount: 0
354
+ } satisfies ProjectSessionSummary;
355
+ })
356
+ .sort((left, right) => {
357
+ const timestampDiff = new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime();
358
+ return timestampDiff || right.sessionId.localeCompare(left.sessionId);
359
+ });
360
+ }
361
+
362
+ export async function listCodexProjectSessions(
363
+ projectRoot: string,
364
+ maxSessions = DEFAULT_MAX_SESSIONS,
365
+ options: CodexHistoryOptions = {}
366
+ ): Promise<ProjectSessionSummary[]> {
367
+ const resolvedProjectRoot = path.resolve(projectRoot);
368
+ const canonicalProjectRoot = await fs.realpath(resolvedProjectRoot).catch(() => resolvedProjectRoot);
369
+ const normalizedMax = normalizeMaxSessions(maxSessions);
370
+ const { historyPath, sessionsRootPath } = resolveCodexHistoryPaths(options);
371
+
372
+ const matches = await readHistoryTail(historyPath, sessionsRootPath, normalizedMax, undefined, canonicalProjectRoot);
373
+ return matches
374
+ .map((entry) => {
375
+ const preview = normalizePreview(entry.text) || PENDING_INPUT_LABEL;
376
+ return {
377
+ providerId: 'codex',
378
+ sessionId: entry.sessionId,
379
+ cwd: entry.cwd,
380
+ title: compactTitle(preview),
381
+ preview,
382
+ updatedAt: new Date(entry.tsMs).toISOString(),
383
+ messageCount: 0
384
+ } satisfies ProjectSessionSummary;
385
+ })
386
+ .sort((left, right) => {
387
+ const timestampDiff = new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime();
388
+ return timestampDiff || right.sessionId.localeCompare(left.sessionId);
389
+ });
390
+ }