linkshell-cli 0.3.1 → 0.3.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,187 @@
1
+ import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, join, resolve } from "node:path";
4
+
5
+ const SAMPLE_BYTES = 64 * 1024;
6
+ const MAX_SESSIONS = 200;
7
+
8
+ export interface ClaudeStoredSession {
9
+ id: string;
10
+ cwd: string;
11
+ title?: string;
12
+ createdAt?: number;
13
+ lastModified: number;
14
+ }
15
+
16
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
17
+ return value && typeof value === "object" && !Array.isArray(value)
18
+ ? value as Record<string, unknown>
19
+ : undefined;
20
+ }
21
+
22
+ function parseTimestamp(value: unknown): number | undefined {
23
+ if (typeof value === "number" && Number.isFinite(value)) {
24
+ return value > 10_000_000_000 ? value : value * 1000;
25
+ }
26
+ if (typeof value === "string" && value.trim()) {
27
+ const parsed = Date.parse(value);
28
+ return Number.isNaN(parsed) ? undefined : parsed;
29
+ }
30
+ return undefined;
31
+ }
32
+
33
+ function normalizeTitle(value: string | undefined): string | undefined {
34
+ const compact = value?.replace(/\s+/g, " ").trim();
35
+ if (!compact) return undefined;
36
+ return compact.length > 80 ? `${compact.slice(0, 77)}...` : compact;
37
+ }
38
+
39
+ function findStringDeep(value: unknown, keys: string[], depth = 0): string | undefined {
40
+ if (depth > 4) return undefined;
41
+ const record = asRecord(value);
42
+ if (!record) return undefined;
43
+ for (const key of keys) {
44
+ const candidate = record[key];
45
+ if (typeof candidate === "string" && candidate.trim()) return candidate;
46
+ }
47
+ for (const candidate of Object.values(record)) {
48
+ if (candidate && typeof candidate === "object") {
49
+ const found = findStringDeep(candidate, keys, depth + 1);
50
+ if (found) return found;
51
+ }
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ function extractMessageText(value: unknown): string | undefined {
57
+ if (typeof value === "string") return normalizeTitle(value);
58
+ if (Array.isArray(value)) {
59
+ const text = value
60
+ .map((part) => {
61
+ if (typeof part === "string") return part;
62
+ const record = asRecord(part);
63
+ return typeof record?.text === "string" ? record.text : "";
64
+ })
65
+ .join(" ");
66
+ return normalizeTitle(text);
67
+ }
68
+ const record = asRecord(value);
69
+ if (!record) return undefined;
70
+ return extractMessageText(record.content ?? record.text);
71
+ }
72
+
73
+ function guessCwdFromProjectDir(projectDirName: string, fallbackCwd: string): string {
74
+ const trimmed = projectDirName.replace(/^-+/, "");
75
+ if (!trimmed) return resolve(fallbackCwd);
76
+ return `/${trimmed.split("-").filter(Boolean).join("/")}`;
77
+ }
78
+
79
+ function readSample(filePath: string, size: number): string {
80
+ let fd: number | undefined;
81
+ try {
82
+ fd = openSync(filePath, "r");
83
+ if (size <= SAMPLE_BYTES * 2) {
84
+ const buffer = Buffer.alloc(size);
85
+ const bytesRead = readSync(fd, buffer, 0, size, 0);
86
+ return buffer.subarray(0, bytesRead).toString("utf8");
87
+ }
88
+
89
+ const head = Buffer.alloc(SAMPLE_BYTES);
90
+ const tail = Buffer.alloc(SAMPLE_BYTES);
91
+ const headBytes = readSync(fd, head, 0, SAMPLE_BYTES, 0);
92
+ const tailBytes = readSync(fd, tail, 0, SAMPLE_BYTES, Math.max(0, size - SAMPLE_BYTES));
93
+ return `${head.subarray(0, headBytes).toString("utf8")}\n${tail.subarray(0, tailBytes).toString("utf8")}`;
94
+ } catch {
95
+ return "";
96
+ } finally {
97
+ if (typeof fd === "number") {
98
+ try {
99
+ closeSync(fd);
100
+ } catch {
101
+ // Ignore close failures while listing best-effort local history.
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ function readClaudeSessionMetadata(filePath: string, fallbackCwd: string): Omit<ClaudeStoredSession, "id" | "lastModified"> & {
108
+ lastModified?: number;
109
+ } {
110
+ let statMtime: number | undefined;
111
+ let statSize = 0;
112
+ try {
113
+ const stat = statSync(filePath);
114
+ statMtime = stat.mtimeMs;
115
+ statSize = stat.size;
116
+ } catch {
117
+ // Best effort only.
118
+ }
119
+
120
+ const sample = readSample(filePath, statSize);
121
+ let cwd: string | undefined;
122
+ let title: string | undefined;
123
+ let createdAt: number | undefined;
124
+ let lastActivityAt: number | undefined;
125
+
126
+ for (const line of sample.split(/\r?\n/)) {
127
+ const trimmed = line.trim();
128
+ if (!trimmed.startsWith("{")) continue;
129
+ try {
130
+ const entry = JSON.parse(trimmed) as unknown;
131
+ const record = asRecord(entry);
132
+ if (!record) continue;
133
+ cwd ??= findStringDeep(record, ["cwd", "workingDirectory", "workspacePath"]);
134
+ const timestamp = parseTimestamp(record.timestamp ?? record.createdAt ?? record.created_at);
135
+ createdAt ??= timestamp;
136
+ if (timestamp) lastActivityAt = timestamp;
137
+ if (!title && record.type === "user") {
138
+ title = extractMessageText(asRecord(record.message)?.content ?? record.content);
139
+ }
140
+ } catch {
141
+ // The sample may start in the middle of a JSONL line; skip partial lines.
142
+ }
143
+ }
144
+
145
+ return {
146
+ cwd: cwd ?? resolve(fallbackCwd),
147
+ title,
148
+ createdAt,
149
+ lastModified: lastActivityAt ?? statMtime,
150
+ };
151
+ }
152
+
153
+ export function listClaudeStoredSessions(inputCwd: string): { sessions: ClaudeStoredSession[] } {
154
+ const root = join(homedir(), ".claude", "projects");
155
+ if (!existsSync(root)) return { sessions: [] };
156
+
157
+ const sessions: ClaudeStoredSession[] = [];
158
+ try {
159
+ for (const projectEntry of readdirSync(root, { withFileTypes: true })) {
160
+ if (!projectEntry.isDirectory()) continue;
161
+ const projectDir = join(root, projectEntry.name);
162
+ const fallbackCwd = guessCwdFromProjectDir(projectEntry.name, inputCwd);
163
+ for (const sessionEntry of readdirSync(projectDir, { withFileTypes: true })) {
164
+ if (!sessionEntry.isFile() || !sessionEntry.name.endsWith(".jsonl")) continue;
165
+ const filePath = join(projectDir, sessionEntry.name);
166
+ try {
167
+ const stat = statSync(filePath);
168
+ const metadata = readClaudeSessionMetadata(filePath, fallbackCwd);
169
+ sessions.push({
170
+ id: basename(sessionEntry.name, ".jsonl"),
171
+ cwd: metadata.cwd,
172
+ title: metadata.title,
173
+ createdAt: metadata.createdAt,
174
+ lastModified: metadata.lastModified ?? stat.mtimeMs,
175
+ });
176
+ } catch {
177
+ // Skip individual history files that disappear or are unreadable during the scan.
178
+ }
179
+ }
180
+ }
181
+ } catch {
182
+ // Ignore unreadable Claude storage; the caller treats an empty list as no local history.
183
+ }
184
+
185
+ sessions.sort((a, b) => b.lastModified - a.lastModified);
186
+ return { sessions: sessions.slice(0, MAX_SESSIONS) };
187
+ }
@@ -1,8 +1,6 @@
1
1
  import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
2
  import { createInterface } from "node:readline";
3
- import { homedir } from "node:os";
4
- import { readdirSync, existsSync } from "node:fs";
5
- import { join, resolve } from "node:path";
3
+ import { listClaudeStoredSessions } from "./claude-sessions.js";
6
4
  import type { AgentFraming, AgentProtocol } from "./provider-resolver.js";
7
5
 
8
6
  type AgentPermissionMode = "read_only" | "workspace_write" | "full_access";
@@ -37,16 +35,6 @@ interface ClaudeContentBlock {
37
35
  signature?: string;
38
36
  }
39
37
 
40
- // Hash a directory path the same way Claude Code does for project storage
41
- function projectHash(cwd: string): string {
42
- return (
43
- "-" +
44
- resolve(cwd)
45
- .replace(/\/$/, "")
46
- .replace(/\//g, "-")
47
- );
48
- }
49
-
50
38
  function id(prefix: string): string {
51
39
  return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
52
40
  }
@@ -474,30 +462,7 @@ export class ClaudeStreamJsonClient {
474
462
  }
475
463
 
476
464
  async listSessions(): Promise<unknown> {
477
- const home = homedir();
478
- const projectDir = join(home, ".claude", "projects", projectHash(this.input.cwd));
479
-
480
- if (!existsSync(projectDir)) {
481
- return { sessions: [] };
482
- }
483
-
484
- const sessions: Array<{ id: string; cwd: string; lastModified: number }> = [];
485
- try {
486
- for (const entry of readdirSync(projectDir)) {
487
- if (entry.endsWith(".jsonl")) {
488
- const sessionId = entry.replace(".jsonl", "");
489
- sessions.push({
490
- id: sessionId,
491
- cwd: this.input.cwd,
492
- lastModified: 0, // would need fs.statSync for accurate time
493
- });
494
- }
495
- }
496
- } catch {
497
- // directory read failed
498
- }
499
-
500
- return { sessions };
465
+ return listClaudeStoredSessions(this.input.cwd);
501
466
  }
502
467
 
503
468
  async listModels(): Promise<unknown> {