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.
- package/dist/cli/src/runtime/acp/acp-client.js +1 -0
- package/dist/cli/src/runtime/acp/acp-client.js.map +1 -1
- package/dist/cli/src/runtime/acp/agent-workspace.d.ts +1 -0
- package/dist/cli/src/runtime/acp/agent-workspace.js +54 -13
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
- package/dist/cli/src/runtime/acp/claude-sessions.d.ts +17 -0
- package/dist/cli/src/runtime/acp/claude-sessions.js +147 -1
- package/dist/cli/src/runtime/acp/claude-sessions.js.map +1 -1
- package/dist/cli/src/runtime/acp/codex-sessions.d.ts +18 -0
- package/dist/cli/src/runtime/acp/codex-sessions.js +165 -8
- package/dist/cli/src/runtime/acp/codex-sessions.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/runtime/acp/acp-client.ts +1 -0
- package/src/runtime/acp/agent-workspace.ts +58 -21
- package/src/runtime/acp/claude-sessions.ts +160 -1
- package/src/runtime/acp/codex-sessions.ts +182 -9
|
@@ -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
|
|
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
|
+
}
|