linkshell-cli 0.3.7 → 0.3.9

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.
@@ -9,8 +9,8 @@ import {
9
9
  import { AcpClient } from "./acp-client.js";
10
10
  import { ClaudeSdkClient } from "./claude-sdk-client.js";
11
11
  import { ClaudeStreamJsonClient } from "./claude-stream-json-client.js";
12
- import { listClaudeStoredSessions } from "./claude-sessions.js";
13
- import { listCodexStoredSessions } from "./codex-sessions.js";
12
+ import { listClaudeStoredSessions, loadClaudeStoredTimeline } from "./claude-sessions.js";
13
+ import { listCodexStoredSessions, loadCodexStoredTimeline } from "./codex-sessions.js";
14
14
  import type { AgentProtocol, AgentProvider } from "./provider-resolver.js";
15
15
  import { resolveAgentCommand } from "./provider-resolver.js";
16
16
 
@@ -1564,24 +1564,6 @@ export class AgentWorkspaceProxy {
1564
1564
  title?: string;
1565
1565
  }): Promise<AgentConversation | undefined> {
1566
1566
  const provider = payload.provider ?? this.input.availableProviders[0];
1567
- if (!provider) {
1568
- return this.openFailure(payload, "没有可用的 Agent provider。");
1569
- }
1570
- if (!this.input.availableProviders.includes(provider)) {
1571
- return this.openFailure(
1572
- payload,
1573
- `${providerLabel(provider)} 未安装或不可用。`,
1574
- );
1575
- }
1576
-
1577
- const client = await this.ensureProviderClient(provider);
1578
- if (!client) {
1579
- return this.openFailure(
1580
- payload,
1581
- `${providerLabel(provider)} 启动失败。请确认 CLI 已安装并可用。`,
1582
- );
1583
- }
1584
-
1585
1567
  const cwd = payload.cwd ?? this.input.cwd;
1586
1568
  let agentSessionId = payload.agentSessionId;
1587
1569
  let existingConversation =
@@ -1592,6 +1574,7 @@ export class AgentWorkspaceProxy {
1592
1574
  if (payload.conversationId && existingConversation.id !== payload.conversationId) {
1593
1575
  existingConversation = this.adoptConversationId(existingConversation.id, payload.conversationId);
1594
1576
  }
1577
+ this.hydrateStoredTimeline(existingConversation);
1595
1578
  this.activeConversationId = existingConversation.id;
1596
1579
  this.input.send(createEnvelope({
1597
1580
  type: "agent.v2.conversation.opened",
@@ -1604,6 +1587,24 @@ export class AgentWorkspaceProxy {
1604
1587
  return existingConversation;
1605
1588
  }
1606
1589
 
1590
+ if (!provider) {
1591
+ return this.openFailure(payload, "没有可用的 Agent provider。");
1592
+ }
1593
+ if (!this.input.availableProviders.includes(provider)) {
1594
+ return this.openFailure(
1595
+ payload,
1596
+ `${providerLabel(provider)} 未安装或不可用。`,
1597
+ );
1598
+ }
1599
+
1600
+ const client = await this.ensureProviderClient(provider);
1601
+ if (!client) {
1602
+ return this.openFailure(
1603
+ payload,
1604
+ `${providerLabel(provider)} 启动失败。请确认 CLI 已安装并可用。`,
1605
+ );
1606
+ }
1607
+
1607
1608
  try {
1608
1609
  const result = agentSessionId
1609
1610
  ? await client.loadSession({ sessionId: agentSessionId, cwd })
@@ -1632,6 +1633,7 @@ export class AgentWorkspaceProxy {
1632
1633
  this.conversationByAgentSessionId.set(agentSessionId, conversation.id);
1633
1634
  this.activeConversationId = conversation.id;
1634
1635
  this.timelines.set(conversation.id, this.timelines.get(conversation.id) ?? []);
1636
+ this.hydrateStoredTimeline(conversation);
1635
1637
  this.input.send(createEnvelope({
1636
1638
  type: "agent.v2.conversation.opened",
1637
1639
  hostDeviceId: this.input.hostDeviceId,
@@ -2973,6 +2975,13 @@ export class AgentWorkspaceProxy {
2973
2975
  }
2974
2976
 
2975
2977
  private sendSnapshot(conversationId?: string): void {
2978
+ if (conversationId) {
2979
+ const conversation = this.conversations.get(conversationId);
2980
+ if (conversation) this.hydrateStoredTimeline(conversation);
2981
+ } else if (this.activeConversationId) {
2982
+ const conversation = this.conversations.get(this.activeConversationId);
2983
+ if (conversation) this.hydrateStoredTimeline(conversation);
2984
+ }
2976
2985
  const conversations = [...this.conversations.values()];
2977
2986
  const items = conversationId
2978
2987
  ? this.timelines.get(conversationId) ?? []
@@ -2988,6 +2997,31 @@ export class AgentWorkspaceProxy {
2988
2997
  }));
2989
2998
  }
2990
2999
 
3000
+ private hydrateStoredTimeline(conversation: AgentConversation): void {
3001
+ if (!conversation.agentSessionId) return;
3002
+ const existing = this.timelines.get(conversation.id) ?? [];
3003
+ if (existing.length > 0) return;
3004
+ const result = conversation.provider === "codex"
3005
+ ? loadCodexStoredTimeline(conversation.agentSessionId, conversation.id, conversation.cwd || this.input.cwd)
3006
+ : conversation.provider === "claude"
3007
+ ? loadClaudeStoredTimeline(conversation.agentSessionId, conversation.id)
3008
+ : { items: [] };
3009
+ if (result.items.length === 0) return;
3010
+ const items = result.items
3011
+ .sort((a, b) => a.createdAt - b.createdAt)
3012
+ .slice(-MAX_TIMELINE_ITEMS) as AgentTimelineItem[];
3013
+ this.timelines.set(conversation.id, items);
3014
+ for (const item of items) this.rememberItemConversationId(conversation.id, item);
3015
+ const lastMessage = [...items].reverse().find((item) => item.text?.trim());
3016
+ if (lastMessage?.text && !conversation.lastMessagePreview) {
3017
+ conversation.lastMessagePreview = previewText(lastMessage.text);
3018
+ }
3019
+ const lastActivityAt = items.at(-1)?.createdAt;
3020
+ if (lastActivityAt) {
3021
+ conversation.lastActivityAt = Math.max(conversation.lastActivityAt, lastActivityAt);
3022
+ }
3023
+ }
3024
+
2991
3025
  private conversationIdFromParams(params: unknown): string | undefined {
2992
3026
  const raw = asRecord(params);
2993
3027
  if (!raw) return undefined;
@@ -1,9 +1,11 @@
1
- import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from "node:fs";
1
+ import { closeSync, existsSync, openSync, readFileSync, readdirSync, readSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, join, resolve } from "node:path";
4
4
 
5
5
  const SAMPLE_BYTES = 64 * 1024;
6
+ const HISTORY_BYTES = 2 * 1024 * 1024;
6
7
  const MAX_SESSIONS = 200;
8
+ const MAX_HISTORY_ITEMS = 200;
7
9
 
8
10
  export interface ClaudeStoredSession {
9
11
  id: string;
@@ -13,6 +15,18 @@ export interface ClaudeStoredSession {
13
15
  lastModified: number;
14
16
  }
15
17
 
18
+ export interface StoredAgentTimelineItem {
19
+ id: string;
20
+ conversationId: string;
21
+ type: "message";
22
+ role: "user" | "assistant" | "system";
23
+ content: Array<{ type: "text"; text: string }>;
24
+ text: string;
25
+ createdAt: number;
26
+ updatedAt?: number;
27
+ metadata?: Record<string, unknown>;
28
+ }
29
+
16
30
  function asRecord(value: unknown): Record<string, unknown> | undefined {
17
31
  return value && typeof value === "object" && !Array.isArray(value)
18
32
  ? value as Record<string, unknown>
@@ -70,6 +84,32 @@ function extractMessageText(value: unknown): string | undefined {
70
84
  return extractMessageText(record.content ?? record.text);
71
85
  }
72
86
 
87
+ function extractHistoryText(value: unknown): string | undefined {
88
+ if (typeof value === "string") {
89
+ const text = value.replace(/\r\n/g, "\n").trim();
90
+ return text || undefined;
91
+ }
92
+ if (Array.isArray(value)) {
93
+ const text = value
94
+ .map((part) => {
95
+ if (typeof part === "string") return part;
96
+ const record = asRecord(part);
97
+ return typeof record?.text === "string"
98
+ ? record.text
99
+ : typeof record?.content === "string"
100
+ ? record.content
101
+ : "";
102
+ })
103
+ .filter(Boolean)
104
+ .join("\n")
105
+ .trim();
106
+ return text || undefined;
107
+ }
108
+ const record = asRecord(value);
109
+ if (!record) return undefined;
110
+ return extractHistoryText(record.content ?? record.text ?? record.message);
111
+ }
112
+
73
113
  function guessCwdFromProjectDir(projectDirName: string, fallbackCwd: string): string {
74
114
  const trimmed = projectDirName.replace(/^-+/, "");
75
115
  if (!trimmed) return resolve(fallbackCwd);
@@ -104,6 +144,32 @@ function readSample(filePath: string, size: number): string {
104
144
  }
105
145
  }
106
146
 
147
+ function readHistorySample(filePath: string, size: number): string {
148
+ try {
149
+ if (size <= HISTORY_BYTES) return readFileSync(filePath, "utf8");
150
+ } catch {
151
+ return "";
152
+ }
153
+
154
+ let fd: number | undefined;
155
+ try {
156
+ fd = openSync(filePath, "r");
157
+ const buffer = Buffer.alloc(HISTORY_BYTES);
158
+ const bytesRead = readSync(fd, buffer, 0, HISTORY_BYTES, Math.max(0, size - HISTORY_BYTES));
159
+ return buffer.subarray(0, bytesRead).toString("utf8");
160
+ } catch {
161
+ return "";
162
+ } finally {
163
+ if (typeof fd === "number") {
164
+ try {
165
+ closeSync(fd);
166
+ } catch {
167
+ // Ignore close failures while reading best-effort local history.
168
+ }
169
+ }
170
+ }
171
+ }
172
+
107
173
  function readClaudeSessionMetadata(filePath: string, fallbackCwd: string): Omit<ClaudeStoredSession, "id" | "lastModified"> & {
108
174
  lastModified?: number;
109
175
  } {
@@ -185,3 +251,96 @@ export function listClaudeStoredSessions(inputCwd: string): { sessions: ClaudeSt
185
251
  sessions.sort((a, b) => b.lastModified - a.lastModified);
186
252
  return { sessions: sessions.slice(0, MAX_SESSIONS) };
187
253
  }
254
+
255
+ function findClaudeSessionFile(sessionId: string): string | undefined {
256
+ const root = join(homedir(), ".claude", "projects");
257
+ if (!existsSync(root)) return undefined;
258
+ try {
259
+ for (const projectEntry of readdirSync(root, { withFileTypes: true })) {
260
+ if (!projectEntry.isDirectory()) continue;
261
+ const projectDir = join(root, projectEntry.name);
262
+ for (const sessionEntry of readdirSync(projectDir, { withFileTypes: true })) {
263
+ if (!sessionEntry.isFile() || !sessionEntry.name.endsWith(".jsonl")) continue;
264
+ if (basename(sessionEntry.name, ".jsonl") === sessionId) {
265
+ return join(projectDir, sessionEntry.name);
266
+ }
267
+ }
268
+ }
269
+ } catch {
270
+ return undefined;
271
+ }
272
+ return undefined;
273
+ }
274
+
275
+ function historyMessage(
276
+ conversationId: string,
277
+ index: number,
278
+ role: "user" | "assistant" | "system",
279
+ text: string,
280
+ createdAt: number,
281
+ source: string,
282
+ ): StoredAgentTimelineItem {
283
+ return {
284
+ id: `history:${source}:${index}`,
285
+ conversationId,
286
+ type: "message",
287
+ role,
288
+ content: [{ type: "text", text }],
289
+ text,
290
+ createdAt,
291
+ metadata: { source: "device-history", provider: "claude" },
292
+ };
293
+ }
294
+
295
+ export function loadClaudeStoredTimeline(
296
+ sessionId: string,
297
+ conversationId: string,
298
+ ): { items: StoredAgentTimelineItem[] } {
299
+ const filePath = findClaudeSessionFile(sessionId);
300
+ if (!filePath) return { items: [] };
301
+ let statMtime = Date.now();
302
+ let statSize = 0;
303
+ try {
304
+ const stat = statSync(filePath);
305
+ statMtime = stat.mtimeMs;
306
+ statSize = stat.size;
307
+ } catch {
308
+ return { items: [] };
309
+ }
310
+
311
+ const items: StoredAgentTimelineItem[] = [];
312
+ let index = 0;
313
+ for (const line of readHistorySample(filePath, statSize).split(/\r?\n/)) {
314
+ const trimmed = line.trim();
315
+ if (!trimmed.startsWith("{")) continue;
316
+ try {
317
+ const record = asRecord(JSON.parse(trimmed));
318
+ if (!record) continue;
319
+ const message = asRecord(record.message);
320
+ const rawRole = typeof record.role === "string"
321
+ ? record.role
322
+ : typeof message?.role === "string"
323
+ ? message.role
324
+ : typeof record.type === "string"
325
+ ? record.type
326
+ : undefined;
327
+ const role = rawRole === "assistant" ? "assistant" : rawRole === "user" ? "user" : undefined;
328
+ if (!role) continue;
329
+ const text = extractHistoryText(message?.content ?? record.content ?? record.text);
330
+ if (!text) continue;
331
+ const createdAt =
332
+ parseTimestamp(record.timestamp ?? record.createdAt ?? record.created_at) ??
333
+ statMtime + index;
334
+ items.push(historyMessage(conversationId, index, role, text, createdAt, sessionId));
335
+ index += 1;
336
+ } catch {
337
+ // Ignore malformed or partial JSONL lines in the history window.
338
+ }
339
+ }
340
+
341
+ return {
342
+ items: items
343
+ .sort((a, b) => a.createdAt - b.createdAt)
344
+ .slice(-MAX_HISTORY_ITEMS),
345
+ };
346
+ }
@@ -1,9 +1,11 @@
1
- import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from "node:fs";
1
+ import { closeSync, existsSync, openSync, readFileSync, readdirSync, readSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, join, resolve } from "node:path";
4
4
 
5
- const SAMPLE_BYTES = 64 * 1024;
5
+ const SAMPLE_BYTES = 512 * 1024;
6
+ const HISTORY_BYTES = 2 * 1024 * 1024;
6
7
  const MAX_SESSIONS = 200;
8
+ const MAX_HISTORY_ITEMS = 200;
7
9
 
8
10
  export interface CodexStoredSession {
9
11
  id: string;
@@ -14,6 +16,18 @@ export interface CodexStoredSession {
14
16
  archived?: boolean;
15
17
  }
16
18
 
19
+ export interface StoredAgentTimelineItem {
20
+ id: string;
21
+ conversationId: string;
22
+ type: "message";
23
+ role: "user" | "assistant" | "system";
24
+ content: Array<{ type: "text"; text: string }>;
25
+ text: string;
26
+ createdAt: number;
27
+ updatedAt?: number;
28
+ metadata?: Record<string, unknown>;
29
+ }
30
+
17
31
  interface CodexIndexEntry {
18
32
  id: string;
19
33
  title?: string;
@@ -77,6 +91,32 @@ function readSample(filePath: string, size: number): string {
77
91
  }
78
92
  }
79
93
 
94
+ function readHistorySample(filePath: string, size: number): string {
95
+ try {
96
+ if (size <= HISTORY_BYTES) return readFileSync(filePath, "utf8");
97
+ } catch {
98
+ return "";
99
+ }
100
+
101
+ let fd: number | undefined;
102
+ try {
103
+ fd = openSync(filePath, "r");
104
+ const buffer = Buffer.alloc(HISTORY_BYTES);
105
+ const bytesRead = readSync(fd, buffer, 0, HISTORY_BYTES, Math.max(0, size - HISTORY_BYTES));
106
+ return buffer.subarray(0, bytesRead).toString("utf8");
107
+ } catch {
108
+ return "";
109
+ } finally {
110
+ if (typeof fd === "number") {
111
+ try {
112
+ closeSync(fd);
113
+ } catch {
114
+ // Best-effort local history scan.
115
+ }
116
+ }
117
+ }
118
+ }
119
+
80
120
  function loadSessionIndex(root: string): Map<string, CodexIndexEntry> {
81
121
  const indexPath = join(root, "session_index.jsonl");
82
122
  const entries = new Map<string, CodexIndexEntry>();
@@ -129,6 +169,7 @@ function readCodexSessionFile(filePath: string, fallbackCwd: string): Omit<Codex
129
169
 
130
170
  let id = sessionIdFromRolloutFile(filePath);
131
171
  let cwd: string | undefined;
172
+ let title: string | undefined;
132
173
  let createdAt: number | undefined;
133
174
  let lastActivityAt: number | undefined;
134
175
  for (const line of readSample(filePath, statSize).split(/\r?\n/)) {
@@ -142,6 +183,9 @@ function readCodexSessionFile(filePath: string, fallbackCwd: string): Omit<Codex
142
183
  if (typeof payload.cwd === "string" && payload.cwd.trim()) cwd = payload.cwd;
143
184
  createdAt ??= parseTimestamp(payload.timestamp);
144
185
  }
186
+ if (entry?.type === "event_msg" && payload?.type === "user_message") {
187
+ title ??= normalizeTitle(normalizeHistoryText(payload.message));
188
+ }
145
189
  const timestamp = parseTimestamp(entry?.timestamp);
146
190
  createdAt ??= timestamp;
147
191
  if (timestamp) lastActivityAt = timestamp;
@@ -154,6 +198,7 @@ function readCodexSessionFile(filePath: string, fallbackCwd: string): Omit<Codex
154
198
  return {
155
199
  id,
156
200
  cwd: cwd ?? resolve(fallbackCwd),
201
+ title,
157
202
  createdAt,
158
203
  lastModified: lastActivityAt ?? statMtime,
159
204
  };
@@ -175,6 +220,83 @@ function collectJsonlFiles(dir: string, result: Array<{ path: string; archived:
175
220
  }
176
221
  }
177
222
 
223
+ function findCodexSessionFile(sessionId: string, inputCwd: string): { path: string; archived: boolean } | undefined {
224
+ const root = join(homedir(), ".codex");
225
+ if (!existsSync(root)) return undefined;
226
+ const files: Array<{ path: string; archived: boolean }> = [];
227
+ collectJsonlFiles(join(root, "sessions"), files, false);
228
+ collectJsonlFiles(join(root, "archived_sessions"), files, true);
229
+
230
+ let best: { path: string; archived: boolean; lastModified: number } | undefined;
231
+ for (const file of files) {
232
+ const fileId = sessionIdFromRolloutFile(file.path);
233
+ let metadata: ReturnType<typeof readCodexSessionFile> | undefined;
234
+ if (fileId !== sessionId) {
235
+ metadata = readCodexSessionFile(file.path, inputCwd);
236
+ if (metadata?.id !== sessionId) continue;
237
+ }
238
+ let lastModified = metadata?.lastModified;
239
+ if (!lastModified) {
240
+ try {
241
+ lastModified = statSync(file.path).mtimeMs;
242
+ } catch {
243
+ lastModified = 0;
244
+ }
245
+ }
246
+ if (!best || file.archived || lastModified > best.lastModified) {
247
+ best = { ...file, lastModified };
248
+ }
249
+ }
250
+ return best;
251
+ }
252
+
253
+ function normalizeHistoryText(value: unknown): string | undefined {
254
+ if (typeof value === "string") {
255
+ const text = value.replace(/\r\n/g, "\n").trim();
256
+ return text || undefined;
257
+ }
258
+ if (Array.isArray(value)) {
259
+ const text = value
260
+ .map((part) => {
261
+ if (typeof part === "string") return part;
262
+ const record = asRecord(part);
263
+ return typeof record?.text === "string"
264
+ ? record.text
265
+ : typeof record?.content === "string"
266
+ ? record.content
267
+ : "";
268
+ })
269
+ .filter(Boolean)
270
+ .join("\n")
271
+ .trim();
272
+ return text || undefined;
273
+ }
274
+ const record = asRecord(value);
275
+ if (!record) return undefined;
276
+ return normalizeHistoryText(record.content ?? record.text ?? record.message);
277
+ }
278
+
279
+ function historyMessage(
280
+ conversationId: string,
281
+ index: number,
282
+ role: "user" | "assistant" | "system",
283
+ text: string,
284
+ createdAt: number,
285
+ source: string,
286
+ ): StoredAgentTimelineItem {
287
+ const id = `history:${source}:${index}`;
288
+ return {
289
+ id,
290
+ conversationId,
291
+ type: "message",
292
+ role,
293
+ content: [{ type: "text", text }],
294
+ text,
295
+ createdAt,
296
+ metadata: { source: "device-history", provider: "codex" },
297
+ };
298
+ }
299
+
178
300
  export function listCodexStoredSessions(inputCwd: string): { sessions: CodexStoredSession[] } {
179
301
  const root = join(homedir(), ".codex");
180
302
  if (!existsSync(root)) return { sessions: [] };
@@ -192,7 +314,7 @@ export function listCodexStoredSessions(inputCwd: string): { sessions: CodexStor
192
314
  const session: CodexStoredSession = {
193
315
  id: metadata.id,
194
316
  cwd: metadata.cwd,
195
- title: indexed?.title,
317
+ title: indexed?.title ?? metadata.title,
196
318
  createdAt: metadata.createdAt,
197
319
  lastModified: indexed?.updatedAt ?? metadata.lastModified ?? Date.now(),
198
320
  archived: file.archived,
@@ -220,3 +342,56 @@ export function listCodexStoredSessions(inputCwd: string): { sessions: CodexStor
220
342
  .slice(0, MAX_SESSIONS),
221
343
  };
222
344
  }
345
+
346
+ export function loadCodexStoredTimeline(
347
+ sessionId: string,
348
+ conversationId: string,
349
+ inputCwd: string,
350
+ ): { items: StoredAgentTimelineItem[] } {
351
+ const file = findCodexSessionFile(sessionId, inputCwd);
352
+ if (!file) return { items: [] };
353
+ let statMtime = Date.now();
354
+ let statSize = 0;
355
+ try {
356
+ const stat = statSync(file.path);
357
+ statMtime = stat.mtimeMs;
358
+ statSize = stat.size;
359
+ } catch {
360
+ return { items: [] };
361
+ }
362
+
363
+ const items: StoredAgentTimelineItem[] = [];
364
+ let index = 0;
365
+ for (const line of readHistorySample(file.path, statSize).split(/\r?\n/)) {
366
+ const trimmed = line.trim();
367
+ if (!trimmed.startsWith("{")) continue;
368
+ try {
369
+ const entry = asRecord(JSON.parse(trimmed));
370
+ const payload = asRecord(entry?.payload);
371
+ if (entry?.type !== "event_msg" || !payload) continue;
372
+ const eventType = typeof payload.type === "string" ? payload.type : undefined;
373
+ const createdAt =
374
+ parseTimestamp(entry.timestamp ?? payload.created_at ?? payload.started_at ?? payload.completed_at) ??
375
+ statMtime + index;
376
+ if (eventType === "user_message") {
377
+ const text = normalizeHistoryText(payload.message);
378
+ if (!text || text.startsWith("<turn_aborted>")) continue;
379
+ items.push(historyMessage(conversationId, index, "user", text, createdAt, sessionId));
380
+ index += 1;
381
+ } else if (eventType === "agent_message") {
382
+ const text = normalizeHistoryText(payload.message);
383
+ if (!text) continue;
384
+ items.push(historyMessage(conversationId, index, "assistant", text, createdAt, sessionId));
385
+ index += 1;
386
+ }
387
+ } catch {
388
+ // Ignore malformed or partial JSONL lines in the history window.
389
+ }
390
+ }
391
+
392
+ return {
393
+ items: items
394
+ .sort((a, b) => a.createdAt - b.createdAt)
395
+ .slice(-MAX_HISTORY_ITEMS),
396
+ };
397
+ }