linkshell-cli 0.3.6 → 0.3.8

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
 
@@ -1163,6 +1163,7 @@ function parseRemoteSessions(value: unknown): Array<{
1163
1163
  model?: string;
1164
1164
  createdAt?: number;
1165
1165
  lastActivityAt?: number;
1166
+ archived?: boolean;
1166
1167
  }> {
1167
1168
  const raw = asRecord(value);
1168
1169
  const sessionsValue =
@@ -1178,6 +1179,7 @@ function parseRemoteSessions(value: unknown): Array<{
1178
1179
  model?: string;
1179
1180
  createdAt?: number;
1180
1181
  lastActivityAt?: number;
1182
+ archived?: boolean;
1181
1183
  }> = [];
1182
1184
  for (const entry of sessionsValue) {
1183
1185
  const session = asRecord(entry);
@@ -1196,6 +1198,7 @@ function parseRemoteSessions(value: unknown): Array<{
1196
1198
  model: firstString(source, ["model", "modelId"]),
1197
1199
  createdAt: parseTimestamp(source.createdAt ?? source.created_at),
1198
1200
  lastActivityAt: parseTimestamp(source.lastActivityAt ?? source.updatedAt ?? source.modifiedAt ?? source.lastModified ?? source.updated_at),
1201
+ archived: typeof source.archived === "boolean" ? source.archived : undefined,
1199
1202
  });
1200
1203
  }
1201
1204
  return result;
@@ -1472,7 +1475,7 @@ export class AgentWorkspaceProxy {
1472
1475
  permissionMode: existing?.permissionMode,
1473
1476
  collaborationMode: existing?.collaborationMode,
1474
1477
  status: existing?.status ?? "idle",
1475
- archived: existing?.archived ?? false,
1478
+ archived: remote.archived ?? existing?.archived ?? false,
1476
1479
  lastMessagePreview: existing?.lastMessagePreview,
1477
1480
  lastActivityAt: remote.lastActivityAt ?? existing?.lastActivityAt ?? now,
1478
1481
  createdAt: remote.createdAt ?? existing?.createdAt ?? now,
@@ -1561,24 +1564,6 @@ export class AgentWorkspaceProxy {
1561
1564
  title?: string;
1562
1565
  }): Promise<AgentConversation | undefined> {
1563
1566
  const provider = payload.provider ?? this.input.availableProviders[0];
1564
- if (!provider) {
1565
- return this.openFailure(payload, "没有可用的 Agent provider。");
1566
- }
1567
- if (!this.input.availableProviders.includes(provider)) {
1568
- return this.openFailure(
1569
- payload,
1570
- `${providerLabel(provider)} 未安装或不可用。`,
1571
- );
1572
- }
1573
-
1574
- const client = await this.ensureProviderClient(provider);
1575
- if (!client) {
1576
- return this.openFailure(
1577
- payload,
1578
- `${providerLabel(provider)} 启动失败。请确认 CLI 已安装并可用。`,
1579
- );
1580
- }
1581
-
1582
1567
  const cwd = payload.cwd ?? this.input.cwd;
1583
1568
  let agentSessionId = payload.agentSessionId;
1584
1569
  let existingConversation =
@@ -1589,6 +1574,7 @@ export class AgentWorkspaceProxy {
1589
1574
  if (payload.conversationId && existingConversation.id !== payload.conversationId) {
1590
1575
  existingConversation = this.adoptConversationId(existingConversation.id, payload.conversationId);
1591
1576
  }
1577
+ this.hydrateStoredTimeline(existingConversation);
1592
1578
  this.activeConversationId = existingConversation.id;
1593
1579
  this.input.send(createEnvelope({
1594
1580
  type: "agent.v2.conversation.opened",
@@ -1601,6 +1587,24 @@ export class AgentWorkspaceProxy {
1601
1587
  return existingConversation;
1602
1588
  }
1603
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
+
1604
1608
  try {
1605
1609
  const result = agentSessionId
1606
1610
  ? await client.loadSession({ sessionId: agentSessionId, cwd })
@@ -1629,6 +1633,7 @@ export class AgentWorkspaceProxy {
1629
1633
  this.conversationByAgentSessionId.set(agentSessionId, conversation.id);
1630
1634
  this.activeConversationId = conversation.id;
1631
1635
  this.timelines.set(conversation.id, this.timelines.get(conversation.id) ?? []);
1636
+ this.hydrateStoredTimeline(conversation);
1632
1637
  this.input.send(createEnvelope({
1633
1638
  type: "agent.v2.conversation.opened",
1634
1639
  hostDeviceId: this.input.hostDeviceId,
@@ -2970,6 +2975,13 @@ export class AgentWorkspaceProxy {
2970
2975
  }
2971
2976
 
2972
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
+ }
2973
2985
  const conversations = [...this.conversations.values()];
2974
2986
  const items = conversationId
2975
2987
  ? this.timelines.get(conversationId) ?? []
@@ -2985,6 +2997,31 @@ export class AgentWorkspaceProxy {
2985
2997
  }));
2986
2998
  }
2987
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
+
2988
3025
  private conversationIdFromParams(params: unknown): string | undefined {
2989
3026
  const raw = asRecord(params);
2990
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
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 CodexStoredSession {
9
11
  id: string;
@@ -11,6 +13,19 @@ export interface CodexStoredSession {
11
13
  title?: string;
12
14
  createdAt?: number;
13
15
  lastModified: number;
16
+ archived?: boolean;
17
+ }
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>;
14
29
  }
15
30
 
16
31
  interface CodexIndexEntry {
@@ -76,6 +91,32 @@ function readSample(filePath: string, size: number): string {
76
91
  }
77
92
  }
78
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
+
79
120
  function loadSessionIndex(root: string): Map<string, CodexIndexEntry> {
80
121
  const indexPath = join(root, "session_index.jsonl");
81
122
  const entries = new Map<string, CodexIndexEntry>();
@@ -158,15 +199,15 @@ function readCodexSessionFile(filePath: string, fallbackCwd: string): Omit<Codex
158
199
  };
159
200
  }
160
201
 
161
- function collectJsonlFiles(dir: string, result: string[]): void {
202
+ function collectJsonlFiles(dir: string, result: Array<{ path: string; archived: boolean }>, archived: boolean): void {
162
203
  if (!existsSync(dir)) return;
163
204
  try {
164
205
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
165
206
  const path = join(dir, entry.name);
166
207
  if (entry.isDirectory()) {
167
- collectJsonlFiles(path, result);
208
+ collectJsonlFiles(path, result, archived);
168
209
  } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
169
- result.push(path);
210
+ result.push({ path, archived });
170
211
  }
171
212
  }
172
213
  } catch {
@@ -174,18 +215,95 @@ function collectJsonlFiles(dir: string, result: string[]): void {
174
215
  }
175
216
  }
176
217
 
218
+ function findCodexSessionFile(sessionId: string, inputCwd: string): { path: string; archived: boolean } | undefined {
219
+ const root = join(homedir(), ".codex");
220
+ if (!existsSync(root)) return undefined;
221
+ const files: Array<{ path: string; archived: boolean }> = [];
222
+ collectJsonlFiles(join(root, "sessions"), files, false);
223
+ collectJsonlFiles(join(root, "archived_sessions"), files, true);
224
+
225
+ let best: { path: string; archived: boolean; lastModified: number } | undefined;
226
+ for (const file of files) {
227
+ const fileId = sessionIdFromRolloutFile(file.path);
228
+ let metadata: ReturnType<typeof readCodexSessionFile> | undefined;
229
+ if (fileId !== sessionId) {
230
+ metadata = readCodexSessionFile(file.path, inputCwd);
231
+ if (metadata?.id !== sessionId) continue;
232
+ }
233
+ let lastModified = metadata?.lastModified;
234
+ if (!lastModified) {
235
+ try {
236
+ lastModified = statSync(file.path).mtimeMs;
237
+ } catch {
238
+ lastModified = 0;
239
+ }
240
+ }
241
+ if (!best || file.archived || lastModified > best.lastModified) {
242
+ best = { ...file, lastModified };
243
+ }
244
+ }
245
+ return best;
246
+ }
247
+
248
+ function normalizeHistoryText(value: unknown): string | undefined {
249
+ if (typeof value === "string") {
250
+ const text = value.replace(/\r\n/g, "\n").trim();
251
+ return text || undefined;
252
+ }
253
+ if (Array.isArray(value)) {
254
+ const text = value
255
+ .map((part) => {
256
+ if (typeof part === "string") return part;
257
+ const record = asRecord(part);
258
+ return typeof record?.text === "string"
259
+ ? record.text
260
+ : typeof record?.content === "string"
261
+ ? record.content
262
+ : "";
263
+ })
264
+ .filter(Boolean)
265
+ .join("\n")
266
+ .trim();
267
+ return text || undefined;
268
+ }
269
+ const record = asRecord(value);
270
+ if (!record) return undefined;
271
+ return normalizeHistoryText(record.content ?? record.text ?? record.message);
272
+ }
273
+
274
+ function historyMessage(
275
+ conversationId: string,
276
+ index: number,
277
+ role: "user" | "assistant" | "system",
278
+ text: string,
279
+ createdAt: number,
280
+ source: string,
281
+ ): StoredAgentTimelineItem {
282
+ const id = `history:${source}:${index}`;
283
+ return {
284
+ id,
285
+ conversationId,
286
+ type: "message",
287
+ role,
288
+ content: [{ type: "text", text }],
289
+ text,
290
+ createdAt,
291
+ metadata: { source: "device-history", provider: "codex" },
292
+ };
293
+ }
294
+
177
295
  export function listCodexStoredSessions(inputCwd: string): { sessions: CodexStoredSession[] } {
178
296
  const root = join(homedir(), ".codex");
179
297
  if (!existsSync(root)) return { sessions: [] };
180
298
 
181
299
  const index = loadSessionIndex(root);
182
- const files: string[] = [];
183
- collectJsonlFiles(join(root, "sessions"), files);
184
- collectJsonlFiles(join(root, "archived_sessions"), files);
300
+ const files: Array<{ path: string; archived: boolean }> = [];
301
+ collectJsonlFiles(join(root, "sessions"), files, false);
302
+ collectJsonlFiles(join(root, "archived_sessions"), files, true);
185
303
 
186
304
  const sessionsById = new Map<string, CodexStoredSession>();
187
305
  for (const file of files) {
188
- const metadata = readCodexSessionFile(file, inputCwd);
306
+ const metadata = readCodexSessionFile(file.path, inputCwd);
189
307
  if (!metadata) continue;
190
308
  const indexed = index.get(metadata.id);
191
309
  const session: CodexStoredSession = {
@@ -194,9 +312,10 @@ export function listCodexStoredSessions(inputCwd: string): { sessions: CodexStor
194
312
  title: indexed?.title,
195
313
  createdAt: metadata.createdAt,
196
314
  lastModified: indexed?.updatedAt ?? metadata.lastModified ?? Date.now(),
315
+ archived: file.archived,
197
316
  };
198
317
  const existing = sessionsById.get(session.id);
199
- if (!existing || session.lastModified > existing.lastModified) {
318
+ if (!existing || session.lastModified > existing.lastModified || session.archived) {
200
319
  sessionsById.set(session.id, session);
201
320
  }
202
321
  }
@@ -208,6 +327,7 @@ export function listCodexStoredSessions(inputCwd: string): { sessions: CodexStor
208
327
  cwd: resolve(inputCwd),
209
328
  title: indexed.title,
210
329
  lastModified: indexed.updatedAt ?? Date.now(),
330
+ archived: false,
211
331
  });
212
332
  }
213
333
 
@@ -217,3 +337,56 @@ export function listCodexStoredSessions(inputCwd: string): { sessions: CodexStor
217
337
  .slice(0, MAX_SESSIONS),
218
338
  };
219
339
  }
340
+
341
+ export function loadCodexStoredTimeline(
342
+ sessionId: string,
343
+ conversationId: string,
344
+ inputCwd: string,
345
+ ): { items: StoredAgentTimelineItem[] } {
346
+ const file = findCodexSessionFile(sessionId, inputCwd);
347
+ if (!file) return { items: [] };
348
+ let statMtime = Date.now();
349
+ let statSize = 0;
350
+ try {
351
+ const stat = statSync(file.path);
352
+ statMtime = stat.mtimeMs;
353
+ statSize = stat.size;
354
+ } catch {
355
+ return { items: [] };
356
+ }
357
+
358
+ const items: StoredAgentTimelineItem[] = [];
359
+ let index = 0;
360
+ for (const line of readHistorySample(file.path, statSize).split(/\r?\n/)) {
361
+ const trimmed = line.trim();
362
+ if (!trimmed.startsWith("{")) continue;
363
+ try {
364
+ const entry = asRecord(JSON.parse(trimmed));
365
+ const payload = asRecord(entry?.payload);
366
+ if (entry?.type !== "event_msg" || !payload) continue;
367
+ const eventType = typeof payload.type === "string" ? payload.type : undefined;
368
+ const createdAt =
369
+ parseTimestamp(entry.timestamp ?? payload.created_at ?? payload.started_at ?? payload.completed_at) ??
370
+ statMtime + index;
371
+ if (eventType === "user_message") {
372
+ const text = normalizeHistoryText(payload.message);
373
+ if (!text || text.startsWith("<turn_aborted>")) continue;
374
+ items.push(historyMessage(conversationId, index, "user", text, createdAt, sessionId));
375
+ index += 1;
376
+ } else if (eventType === "agent_message") {
377
+ const text = normalizeHistoryText(payload.message);
378
+ if (!text) continue;
379
+ items.push(historyMessage(conversationId, index, "assistant", text, createdAt, sessionId));
380
+ index += 1;
381
+ }
382
+ } catch {
383
+ // Ignore malformed or partial JSONL lines in the history window.
384
+ }
385
+ }
386
+
387
+ return {
388
+ items: items
389
+ .sort((a, b) => a.createdAt - b.createdAt)
390
+ .slice(-MAX_HISTORY_ITEMS),
391
+ };
392
+ }