lark-remote 0.1.4 → 0.1.6
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/CHANGELOG.md +38 -0
- package/README.en.md +0 -1
- package/README.md +0 -1
- package/dist/bridge/approval-coordinator.d.ts +108 -0
- package/dist/bridge/approval-coordinator.js +258 -0
- package/dist/bridge/index.d.ts +62 -0
- package/dist/bridge/index.js +423 -20
- package/dist/bridge/queue-manager.d.ts +9 -0
- package/dist/bridge/queue-manager.js +19 -11
- package/dist/card/approval-render.d.ts +16 -0
- package/dist/card/approval-render.js +247 -0
- package/dist/card/card-shared.d.ts +14 -0
- package/dist/card/card-shared.js +28 -0
- package/dist/card/run-renderer.js +78 -7
- package/dist/card/run-state.d.ts +25 -3
- package/dist/card/run-state.js +210 -2
- package/dist/config/dir.d.ts +1 -0
- package/dist/config/dir.js +4 -0
- package/dist/config/index.d.ts +43 -0
- package/dist/config/index.js +25 -1
- package/dist/connector/index.d.ts +5 -0
- package/dist/connector/index.js +32 -0
- package/dist/index.js +95 -23
- package/dist/router/config/codex.d.ts +1 -1
- package/dist/router/config/codex.js +49 -0
- package/dist/router/config/common/render.js +4 -0
- package/dist/router/config/index.d.ts +0 -10
- package/dist/router/config/index.js +0 -17
- package/dist/router/config/types.d.ts +1 -1
- package/dist/router/index.d.ts +61 -1
- package/dist/router/index.js +323 -48
- package/dist/router/utils.d.ts +2 -0
- package/dist/router/utils.js +5 -2
- package/dist/runner/codex/app-server/client.d.ts +82 -0
- package/dist/runner/codex/app-server/client.js +234 -0
- package/dist/runner/codex/app-server/connection-manager.d.ts +56 -0
- package/dist/runner/codex/app-server/connection-manager.js +170 -0
- package/dist/runner/codex/app-server/protocol-types.d.ts +527 -0
- package/dist/runner/codex/app-server/protocol-types.js +67 -0
- package/dist/runner/codex/app-server/runner.d.ts +121 -0
- package/dist/runner/codex/app-server/runner.js +554 -0
- package/dist/runner/codex/app-server/translator.d.ts +109 -0
- package/dist/runner/codex/app-server/translator.js +734 -0
- package/dist/runner/codex/app-server/transport.d.ts +49 -0
- package/dist/runner/codex/app-server/transport.js +231 -0
- package/dist/runner/codex/argv.d.ts +1 -1
- package/dist/runner/codex/argv.js +1 -1
- package/dist/runner/codex/factory.d.ts +29 -0
- package/dist/runner/codex/factory.js +39 -0
- package/dist/runner/codex/index.d.ts +2 -0
- package/dist/runner/codex/index.js +2 -0
- package/dist/runner/codex/runner.d.ts +3 -0
- package/dist/runner/codex/runner.js +5 -0
- package/dist/runner/common/runner-utils.d.ts +12 -0
- package/dist/runner/common/runner-utils.js +21 -0
- package/dist/runner/common/spawning-runner.js +9 -1
- package/dist/runner/common/usage.d.ts +2 -0
- package/dist/runner/common/usage.js +2 -0
- package/dist/runner/index.d.ts +2 -2
- package/dist/runner/index.js +1 -1
- package/dist/runner/types.d.ts +123 -2
- package/dist/session/claude/session-index.d.ts +93 -0
- package/dist/session/claude/session-index.js +374 -0
- package/dist/session/claude/sessions.d.ts +0 -18
- package/dist/session/claude/sessions.js +102 -319
- package/dist/session/codex/rollout-reader.js +46 -3
- package/dist/update/index.d.ts +10 -0
- package/dist/update/index.js +11 -0
- package/dist/update/install.d.ts +29 -0
- package/dist/update/install.js +93 -0
- package/dist/update/startup-hint.d.ts +5 -0
- package/dist/update/startup-hint.js +11 -0
- package/dist/update/version-check.d.ts +31 -0
- package/dist/update/version-check.js +149 -0
- package/package.json +1 -14
package/dist/runner/types.d.ts
CHANGED
|
@@ -21,6 +21,75 @@ interface SystemCompactEvent {
|
|
|
21
21
|
preTokens: number;
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
|
+
export interface ApprovalRequestedEvent {
|
|
25
|
+
type: 'approval_requested';
|
|
26
|
+
/** JSON-RPC id of the server request. Wire type is string | integer — keep raw. */
|
|
27
|
+
requestId: number | string;
|
|
28
|
+
kind: 'command' | 'file' | 'permissions';
|
|
29
|
+
threadId: string;
|
|
30
|
+
turnId: string;
|
|
31
|
+
itemId: string;
|
|
32
|
+
view: ApprovalView;
|
|
33
|
+
timestamp?: string;
|
|
34
|
+
}
|
|
35
|
+
export interface ApprovalResolvedEvent {
|
|
36
|
+
type: 'approval_resolved';
|
|
37
|
+
requestId: number | string;
|
|
38
|
+
outcome: 'resolved' | 'expired';
|
|
39
|
+
timestamp?: string;
|
|
40
|
+
}
|
|
41
|
+
/** Approval card content update (out-of-order item/started arrives late). */
|
|
42
|
+
export interface ApprovalViewUpdatedEvent {
|
|
43
|
+
type: 'approval_view_updated';
|
|
44
|
+
requestId: number | string;
|
|
45
|
+
view: ApprovalView;
|
|
46
|
+
timestamp?: string;
|
|
47
|
+
}
|
|
48
|
+
/** Approval expired locally (bridge-side timeout) — card should show expired UI. */
|
|
49
|
+
export interface ApprovalExpiredEvent {
|
|
50
|
+
type: 'approval_expired';
|
|
51
|
+
requestId: number | string;
|
|
52
|
+
timestamp?: string;
|
|
53
|
+
}
|
|
54
|
+
export interface ApprovalView {
|
|
55
|
+
requestId: number | string;
|
|
56
|
+
kind: 'command' | 'file' | 'permissions';
|
|
57
|
+
reason?: string;
|
|
58
|
+
threadShort: string;
|
|
59
|
+
turnShort: string;
|
|
60
|
+
workspace: string;
|
|
61
|
+
command?: string;
|
|
62
|
+
commandCwd?: string;
|
|
63
|
+
fileChanges?: Array<{
|
|
64
|
+
path: string;
|
|
65
|
+
kind: 'add' | 'update' | 'delete';
|
|
66
|
+
diff?: string;
|
|
67
|
+
}>;
|
|
68
|
+
network?: {
|
|
69
|
+
host: string;
|
|
70
|
+
protocol: string;
|
|
71
|
+
};
|
|
72
|
+
permissions?: {
|
|
73
|
+
networkEnabled?: boolean;
|
|
74
|
+
fileSystemRead?: string[];
|
|
75
|
+
fileSystemWrite?: string[];
|
|
76
|
+
items: Array<{
|
|
77
|
+
id: string;
|
|
78
|
+
label: string;
|
|
79
|
+
target: {
|
|
80
|
+
kind: 'network';
|
|
81
|
+
} | {
|
|
82
|
+
kind: 'fsRead' | 'fsWrite';
|
|
83
|
+
path: string;
|
|
84
|
+
};
|
|
85
|
+
selected: boolean;
|
|
86
|
+
}>;
|
|
87
|
+
};
|
|
88
|
+
availableDecisions: string[];
|
|
89
|
+
/** Raw payloads for structured decisions (e.g. acceptWithExecpolicyAmendment). */
|
|
90
|
+
decisionPayloads?: Record<string, unknown>;
|
|
91
|
+
pendingTotal: number;
|
|
92
|
+
}
|
|
24
93
|
interface ThinkingContent {
|
|
25
94
|
type: 'thinking';
|
|
26
95
|
thinking: string;
|
|
@@ -58,7 +127,7 @@ export interface UserEvent {
|
|
|
58
127
|
}
|
|
59
128
|
export interface ResultEvent {
|
|
60
129
|
type: 'result';
|
|
61
|
-
subtype: 'success' | 'error';
|
|
130
|
+
subtype: 'success' | 'error' | 'interrupted';
|
|
62
131
|
session_id: string;
|
|
63
132
|
timestamp?: string;
|
|
64
133
|
usage?: {
|
|
@@ -75,6 +144,9 @@ export interface ResultEvent {
|
|
|
75
144
|
cache_creation_input_tokens?: number;
|
|
76
145
|
/** Agent-declared total; when present the display uses max(total, sum of parts). */
|
|
77
146
|
total_tokens?: number;
|
|
147
|
+
/** 当前模型 context window 上限(codex app-server tokenUsage.modelContextWindow
|
|
148
|
+
* 透传)。仅 app-server 提供;缺省时卡片只显示绝对量、不显示百分比。 */
|
|
149
|
+
context_limit?: number;
|
|
78
150
|
};
|
|
79
151
|
total_cost_usd?: number;
|
|
80
152
|
errorMessage?: string;
|
|
@@ -99,7 +171,50 @@ export interface FileChangeEvent {
|
|
|
99
171
|
operation: 'create' | 'edit' | 'delete' | 'read';
|
|
100
172
|
timestamp?: string;
|
|
101
173
|
}
|
|
102
|
-
export
|
|
174
|
+
export interface TurnStartedEvent {
|
|
175
|
+
type: 'turn_started';
|
|
176
|
+
threadId: string;
|
|
177
|
+
turnId: string;
|
|
178
|
+
operationKind: 'turn' | 'compaction';
|
|
179
|
+
timestamp?: string;
|
|
180
|
+
}
|
|
181
|
+
export interface TurnDiffEvent {
|
|
182
|
+
type: 'turn_diff';
|
|
183
|
+
/**
|
|
184
|
+
* Thread item id this snapshot belongs to (app-server item-scoped stream).
|
|
185
|
+
* Each content item (reasoning / agentMessage / commandExecution / plan /
|
|
186
|
+
* fileChange) is tracked independently, so interleaved items keep their
|
|
187
|
+
* real chronology and update their own block instead of a shared one.
|
|
188
|
+
*/
|
|
189
|
+
itemId: string;
|
|
190
|
+
/** Full accumulated assistant text snapshot for this item (replaces previous). */
|
|
191
|
+
text?: string;
|
|
192
|
+
/** Full accumulated reasoning snapshot for this item (replaces previous). */
|
|
193
|
+
reasoning?: string;
|
|
194
|
+
/** Full accumulated tool output snapshot for this item (replaces previous). */
|
|
195
|
+
toolOutput?: string;
|
|
196
|
+
/** Full accumulated plan text snapshot for this item (replaces previous). */
|
|
197
|
+
plan?: string;
|
|
198
|
+
/** Current set of file changes for this item (replaces previous). */
|
|
199
|
+
fileChanges?: Array<{
|
|
200
|
+
path: string;
|
|
201
|
+
kind: 'add' | 'update' | 'delete';
|
|
202
|
+
diff?: string;
|
|
203
|
+
}>;
|
|
204
|
+
/**
|
|
205
|
+
* Authoritative completion snapshot (item/completed or turn/completed).
|
|
206
|
+
* The reducer finalizes the block in place: thinking/plan active=false,
|
|
207
|
+
* tool status ok, completedAt stamped. Never reorders blocks.
|
|
208
|
+
*/
|
|
209
|
+
complete?: boolean;
|
|
210
|
+
/** Authoritative tool status at completion (commandExecution item). */
|
|
211
|
+
toolStatus?: 'ok' | 'error';
|
|
212
|
+
threadId: string;
|
|
213
|
+
turnId: string;
|
|
214
|
+
timestamp?: string;
|
|
215
|
+
}
|
|
216
|
+
export type RunnerLifetime = 'turn' | 'workspace';
|
|
217
|
+
export type AgentEvent = SystemInitEvent | SystemCompactEvent | ApprovalRequestedEvent | ApprovalResolvedEvent | ApprovalViewUpdatedEvent | ApprovalExpiredEvent | AssistantEvent | UserEvent | ResultEvent | PlanEvent | FileChangeEvent | TurnStartedEvent | TurnDiffEvent;
|
|
103
218
|
export interface SpawnOptions {
|
|
104
219
|
cwd: string;
|
|
105
220
|
sessionId?: string;
|
|
@@ -117,6 +232,8 @@ export interface SpawnOptions {
|
|
|
117
232
|
*/
|
|
118
233
|
export interface Runner {
|
|
119
234
|
readonly isRunning: boolean;
|
|
235
|
+
readonly lifetime?: RunnerLifetime;
|
|
236
|
+
dispose?(): Promise<void>;
|
|
120
237
|
run(message: string, opts: SpawnOptions): AsyncGenerator<AgentEvent>;
|
|
121
238
|
/**
|
|
122
239
|
* Stop the running process. SIGTERM → grace → SIGKILL on the per-workspace
|
|
@@ -158,6 +275,8 @@ export interface AgentSessionUsage {
|
|
|
158
275
|
contextLimit?: number;
|
|
159
276
|
/** Claude auto-compact 次数(数 compact_boundary 事件);codex/opencode 无此概念,留 undefined。 */
|
|
160
277
|
compactCount?: number;
|
|
278
|
+
/** 压缩前上下文水位(codex compact 后从会话 jsonl 读取,仅会话以压缩收尾时有值)。 */
|
|
279
|
+
compactPreContextLength?: number;
|
|
161
280
|
/** 从缓存读取的 token 数(节省的费用)。 */
|
|
162
281
|
cacheReadTokens?: number;
|
|
163
282
|
/** 新建缓存的 token 数。 */
|
|
@@ -236,6 +355,8 @@ export interface AgentRunner extends Runner {
|
|
|
236
355
|
readonly sessionReader: AgentSessionReader;
|
|
237
356
|
/** Return current status info for /status display. */
|
|
238
357
|
getStatusInfo(): AgentStatusInfo;
|
|
358
|
+
/** Whether this runner provides live usage data (vs. file-based). */
|
|
359
|
+
getUsageAuthority?(): 'live' | 'jsonl';
|
|
239
360
|
}
|
|
240
361
|
/** Agent self-describing status info for /status display. */
|
|
241
362
|
export interface AgentStatusInfo {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { type ContentBlockMapping } from '../common/content-blocks.js';
|
|
2
|
+
export interface SessionIndexEntry {
|
|
3
|
+
sessionId: string;
|
|
4
|
+
path: string;
|
|
5
|
+
fingerprint: string;
|
|
6
|
+
mtimeMs: number;
|
|
7
|
+
summary: string;
|
|
8
|
+
cwdSet: Set<string>;
|
|
9
|
+
}
|
|
10
|
+
export interface SessionIndexOptions {
|
|
11
|
+
/** Minimum interval between refresh scans (ms). Default 5000. */
|
|
12
|
+
refreshIntervalMs?: number;
|
|
13
|
+
}
|
|
14
|
+
/** Claude-specific field-name mapping for content block extraction. */
|
|
15
|
+
export declare const CLAUDE_MAPPING: ContentBlockMapping;
|
|
16
|
+
/**
|
|
17
|
+
* Detect Claude CLI sub-agent completion notification text.
|
|
18
|
+
* Claude CLI injects a `type:"user"` record with string content starting
|
|
19
|
+
* with `<task-notification>` when a sub-agent (Agent tool) finishes.
|
|
20
|
+
* These must be excluded from displayTitle/summary — they are machine
|
|
21
|
+
* injections, not real user input. Primary criterion: extracted text
|
|
22
|
+
* starts with `<task-notification` (works for both string and array
|
|
23
|
+
* content since `extractText` is called first). Secondary (optional):
|
|
24
|
+
* `origin.kind === 'task-notification'` (not all CLI versions write it).
|
|
25
|
+
*/
|
|
26
|
+
export declare function isTaskNotificationText(text: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Extract first text from a user message.
|
|
29
|
+
* Used by parseSessionJsonl and sessions.ts readSessionContent.
|
|
30
|
+
*/
|
|
31
|
+
export declare function extractText(content: unknown): string | null;
|
|
32
|
+
export interface ParsedSession {
|
|
33
|
+
cwdSet: Set<string>;
|
|
34
|
+
summary: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Unified parser: single streaming scan of a JSONL file producing both
|
|
38
|
+
* the complete set of cwd values AND the first real user message summary.
|
|
39
|
+
* Replaces the split readCwdFromJsonl (first-cwd) + jsonlContainsCwd (any-cwd).
|
|
40
|
+
*/
|
|
41
|
+
export declare function parseSessionJsonl(filePath: string): ParsedSession;
|
|
42
|
+
/**
|
|
43
|
+
* Build a fingerprint string from file stat metadata.
|
|
44
|
+
* Uses `size + mtimeNs + ctimeNs + dev + ino` (bigint stat).
|
|
45
|
+
* `size`/`mtime` are required; the rest are best-effort.
|
|
46
|
+
*/
|
|
47
|
+
export declare function buildFingerprint(filePath: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* In-memory session index with three query views:
|
|
50
|
+
* byPath: path → entry
|
|
51
|
+
* bySessionId: sessionId → Set<path>
|
|
52
|
+
* byCwd: cwd → Set<path>
|
|
53
|
+
*
|
|
54
|
+
* Build once, refresh on demand (throttled). Single-file parse failure
|
|
55
|
+
* doesn't affect other files; readdir failure preserves old index.
|
|
56
|
+
*/
|
|
57
|
+
export declare class SessionIndex {
|
|
58
|
+
private readonly projectsDir;
|
|
59
|
+
private readonly refreshIntervalMs;
|
|
60
|
+
private readonly byPath;
|
|
61
|
+
private readonly bySessionId;
|
|
62
|
+
private readonly byCwd;
|
|
63
|
+
private lastRefreshAt;
|
|
64
|
+
private built;
|
|
65
|
+
/** Test-only: count of parseSessionJsonl calls since creation */
|
|
66
|
+
parseCount: number;
|
|
67
|
+
/** Test-only: count of directory scan operations */
|
|
68
|
+
scanCount: number;
|
|
69
|
+
constructor(projectsDir: string, opts?: SessionIndexOptions);
|
|
70
|
+
/** Whether a full scan has ever completed successfully (readdir succeeded). */
|
|
71
|
+
get isBuilt(): boolean;
|
|
72
|
+
build(): void;
|
|
73
|
+
refresh(): void;
|
|
74
|
+
listByCwd(cwd: string, opts?: {
|
|
75
|
+
limit?: number;
|
|
76
|
+
offset?: number;
|
|
77
|
+
}): SessionIndexEntry[];
|
|
78
|
+
/**
|
|
79
|
+
* Find a session entry by sessionId and cwd.
|
|
80
|
+
* Re-stats the file to verify fingerprint before returning (拍板 3):
|
|
81
|
+
* - fingerprint unchanged → trust index cwdSet
|
|
82
|
+
* - fingerprint changed → re-parse that one file, update index, re-check
|
|
83
|
+
* Returns undefined if not found or cwd doesn't match.
|
|
84
|
+
*
|
|
85
|
+
* Note: we re-stat ALL paths for the sessionId regardless of the old cwdSet,
|
|
86
|
+
* because a fingerprint change may have added the target cwd to the set.
|
|
87
|
+
*/
|
|
88
|
+
findBySessionIdAndCwd(sessionId: string, cwd: string): SessionIndexEntry | undefined;
|
|
89
|
+
private fullScan;
|
|
90
|
+
private updateEntry;
|
|
91
|
+
private removeEntry;
|
|
92
|
+
private removeCrossRefs;
|
|
93
|
+
}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { scanJsonlLines } from '../common/jsonl.js';
|
|
4
|
+
import { extractContentBlocks } from '../common/content-blocks.js';
|
|
5
|
+
import { truncate } from '../../card/card-shared.js';
|
|
6
|
+
import { getLogger } from '../../logger/index.js';
|
|
7
|
+
// ─── Claude content block mapping (shared with sessions.ts) ─
|
|
8
|
+
/** Claude-specific field-name mapping for content block extraction. */
|
|
9
|
+
export const CLAUDE_MAPPING = {
|
|
10
|
+
toolUseType: 'tool_use',
|
|
11
|
+
toolResultType: 'tool_result',
|
|
12
|
+
toolInputField: 'input',
|
|
13
|
+
toolErrorField: 'is_error',
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Detect Claude CLI sub-agent completion notification text.
|
|
17
|
+
* Claude CLI injects a `type:"user"` record with string content starting
|
|
18
|
+
* with `<task-notification>` when a sub-agent (Agent tool) finishes.
|
|
19
|
+
* These must be excluded from displayTitle/summary — they are machine
|
|
20
|
+
* injections, not real user input. Primary criterion: extracted text
|
|
21
|
+
* starts with `<task-notification` (works for both string and array
|
|
22
|
+
* content since `extractText` is called first). Secondary (optional):
|
|
23
|
+
* `origin.kind === 'task-notification'` (not all CLI versions write it).
|
|
24
|
+
*/
|
|
25
|
+
export function isTaskNotificationText(text) {
|
|
26
|
+
return text.trimStart().startsWith('<task-notification');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Extract first text from a user message.
|
|
30
|
+
* Used by parseSessionJsonl and sessions.ts readSessionContent.
|
|
31
|
+
*/
|
|
32
|
+
export function extractText(content) {
|
|
33
|
+
if (typeof content === 'string' && content.trim()) {
|
|
34
|
+
return content.trim();
|
|
35
|
+
}
|
|
36
|
+
const blocks = extractContentBlocks(content, CLAUDE_MAPPING);
|
|
37
|
+
const textBlock = blocks.find((b) => b.type === 'text');
|
|
38
|
+
if (textBlock)
|
|
39
|
+
return textBlock.content;
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Unified parser: single streaming scan of a JSONL file producing both
|
|
44
|
+
* the complete set of cwd values AND the first real user message summary.
|
|
45
|
+
* Replaces the split readCwdFromJsonl (first-cwd) + jsonlContainsCwd (any-cwd).
|
|
46
|
+
*/
|
|
47
|
+
export function parseSessionJsonl(filePath) {
|
|
48
|
+
const cwdSet = new Set();
|
|
49
|
+
let summary = '(无摘要)';
|
|
50
|
+
let foundSummary = false;
|
|
51
|
+
const ok = scanJsonlLines(filePath, (line) => {
|
|
52
|
+
let obj;
|
|
53
|
+
try {
|
|
54
|
+
obj = JSON.parse(line);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// Collect cwd values
|
|
60
|
+
if (typeof obj.cwd === 'string' && obj.cwd) {
|
|
61
|
+
cwdSet.add(obj.cwd);
|
|
62
|
+
}
|
|
63
|
+
// Find first real user message for summary (same logic as summarizeSession)
|
|
64
|
+
if (!foundSummary && obj.type === 'user' && !!obj.message) {
|
|
65
|
+
const msg = obj.message;
|
|
66
|
+
if (msg.content) {
|
|
67
|
+
const text = extractText(msg.content);
|
|
68
|
+
if (text && !isTaskNotificationText(text)) {
|
|
69
|
+
summary = text;
|
|
70
|
+
foundSummary = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
// If file couldn't be opened, return empty result
|
|
76
|
+
if (!ok) {
|
|
77
|
+
return { cwdSet: new Set(), summary: '(无摘要)' };
|
|
78
|
+
}
|
|
79
|
+
return { cwdSet, summary };
|
|
80
|
+
}
|
|
81
|
+
// ─── buildFingerprint ───────────────────────────────────────
|
|
82
|
+
/**
|
|
83
|
+
* Build a fingerprint string from file stat metadata.
|
|
84
|
+
* Uses `size + mtimeNs + ctimeNs + dev + ino` (bigint stat).
|
|
85
|
+
* `size`/`mtime` are required; the rest are best-effort.
|
|
86
|
+
*/
|
|
87
|
+
export function buildFingerprint(filePath) {
|
|
88
|
+
let st;
|
|
89
|
+
try {
|
|
90
|
+
st = fs.statSync(filePath, { bigint: true });
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return '';
|
|
94
|
+
}
|
|
95
|
+
const parts = [String(st.size), String(st.mtimeNs)];
|
|
96
|
+
// ctimeNs, dev, ino: include if available
|
|
97
|
+
try {
|
|
98
|
+
parts.push(String(st.ctimeNs));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
/* skip */
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
parts.push(String(st.dev));
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
/* skip */
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
parts.push(String(st.ino));
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
/* skip */
|
|
114
|
+
}
|
|
115
|
+
return parts.join('|');
|
|
116
|
+
}
|
|
117
|
+
// ─── SessionIndex ───────────────────────────────────────────
|
|
118
|
+
/**
|
|
119
|
+
* In-memory session index with three query views:
|
|
120
|
+
* byPath: path → entry
|
|
121
|
+
* bySessionId: sessionId → Set<path>
|
|
122
|
+
* byCwd: cwd → Set<path>
|
|
123
|
+
*
|
|
124
|
+
* Build once, refresh on demand (throttled). Single-file parse failure
|
|
125
|
+
* doesn't affect other files; readdir failure preserves old index.
|
|
126
|
+
*/
|
|
127
|
+
export class SessionIndex {
|
|
128
|
+
projectsDir;
|
|
129
|
+
refreshIntervalMs;
|
|
130
|
+
byPath = new Map();
|
|
131
|
+
bySessionId = new Map();
|
|
132
|
+
byCwd = new Map();
|
|
133
|
+
lastRefreshAt = 0;
|
|
134
|
+
built = false;
|
|
135
|
+
/** Test-only: count of parseSessionJsonl calls since creation */
|
|
136
|
+
parseCount = 0;
|
|
137
|
+
/** Test-only: count of directory scan operations */
|
|
138
|
+
scanCount = 0;
|
|
139
|
+
constructor(projectsDir, opts = {}) {
|
|
140
|
+
this.projectsDir = projectsDir;
|
|
141
|
+
this.refreshIntervalMs = opts.refreshIntervalMs ?? 5000;
|
|
142
|
+
}
|
|
143
|
+
/** Whether a full scan has ever completed successfully (readdir succeeded). */
|
|
144
|
+
get isBuilt() {
|
|
145
|
+
return this.built;
|
|
146
|
+
}
|
|
147
|
+
// ── Build (initial full scan) ───────────────────────────
|
|
148
|
+
build() {
|
|
149
|
+
this.scanCount++;
|
|
150
|
+
this.fullScan();
|
|
151
|
+
this.lastRefreshAt = Date.now();
|
|
152
|
+
}
|
|
153
|
+
// ── Refresh (throttled incremental) ─────────────────────
|
|
154
|
+
refresh() {
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
if (now - this.lastRefreshAt < this.refreshIntervalMs) {
|
|
157
|
+
return; // throttled
|
|
158
|
+
}
|
|
159
|
+
this.scanCount++;
|
|
160
|
+
this.fullScan();
|
|
161
|
+
this.lastRefreshAt = now;
|
|
162
|
+
}
|
|
163
|
+
// ── Query: listByCwd ────────────────────────────────────
|
|
164
|
+
listByCwd(cwd, opts = {}) {
|
|
165
|
+
const paths = this.byCwd.get(cwd);
|
|
166
|
+
if (!paths || paths.size === 0)
|
|
167
|
+
return [];
|
|
168
|
+
// Gather entries, deduplicate by sessionId (keep mtime newest)
|
|
169
|
+
const byId = new Map();
|
|
170
|
+
for (const p of paths) {
|
|
171
|
+
const entry = this.byPath.get(p);
|
|
172
|
+
if (!entry)
|
|
173
|
+
continue;
|
|
174
|
+
const existing = byId.get(entry.sessionId);
|
|
175
|
+
if (!existing || entry.mtimeMs > existing.mtimeMs) {
|
|
176
|
+
byId.set(entry.sessionId, entry);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const sorted = [...byId.values()].sort((a, b) => b.mtimeMs - a.mtimeMs || a.sessionId.localeCompare(b.sessionId));
|
|
180
|
+
const offset = opts.offset ?? 0;
|
|
181
|
+
const limit = opts.limit;
|
|
182
|
+
const sliced = limit === undefined ? sorted.slice(offset) : sorted.slice(offset, offset + limit);
|
|
183
|
+
return sliced;
|
|
184
|
+
}
|
|
185
|
+
// ── Query: findBySessionIdAndCwd ────────────────────────
|
|
186
|
+
/**
|
|
187
|
+
* Find a session entry by sessionId and cwd.
|
|
188
|
+
* Re-stats the file to verify fingerprint before returning (拍板 3):
|
|
189
|
+
* - fingerprint unchanged → trust index cwdSet
|
|
190
|
+
* - fingerprint changed → re-parse that one file, update index, re-check
|
|
191
|
+
* Returns undefined if not found or cwd doesn't match.
|
|
192
|
+
*
|
|
193
|
+
* Note: we re-stat ALL paths for the sessionId regardless of the old cwdSet,
|
|
194
|
+
* because a fingerprint change may have added the target cwd to the set.
|
|
195
|
+
*/
|
|
196
|
+
findBySessionIdAndCwd(sessionId, cwd) {
|
|
197
|
+
const paths = this.bySessionId.get(sessionId);
|
|
198
|
+
if (!paths || paths.size === 0)
|
|
199
|
+
return undefined;
|
|
200
|
+
for (const p of paths) {
|
|
201
|
+
const entry = this.byPath.get(p);
|
|
202
|
+
if (!entry)
|
|
203
|
+
continue;
|
|
204
|
+
// Re-stat to verify fingerprint (拍板 3)
|
|
205
|
+
const currentFp = buildFingerprint(p);
|
|
206
|
+
if (currentFp === '') {
|
|
207
|
+
// File disappeared — remove from index
|
|
208
|
+
this.removeEntry(p);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (currentFp === entry.fingerprint) {
|
|
212
|
+
// Fingerprint matches → trust index
|
|
213
|
+
if (entry.cwdSet.has(cwd))
|
|
214
|
+
return entry;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
// Fingerprint changed → re-parse this single file
|
|
218
|
+
const parsed = parseSessionJsonl(p);
|
|
219
|
+
this.parseCount++;
|
|
220
|
+
try {
|
|
221
|
+
const st = fs.statSync(p, { bigint: true });
|
|
222
|
+
const updated = {
|
|
223
|
+
sessionId: entry.sessionId,
|
|
224
|
+
path: p,
|
|
225
|
+
fingerprint: currentFp,
|
|
226
|
+
mtimeMs: Number(st.mtimeMs),
|
|
227
|
+
summary: truncate(parsed.summary, 60, { normalizeWhitespace: true }),
|
|
228
|
+
cwdSet: parsed.cwdSet,
|
|
229
|
+
};
|
|
230
|
+
this.updateEntry(p, updated);
|
|
231
|
+
// Re-check cwd after update
|
|
232
|
+
if (updated.cwdSet.has(cwd))
|
|
233
|
+
return updated;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// File gone
|
|
237
|
+
this.removeEntry(p);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
// ── Internal: full scan ─────────────────────────────────
|
|
243
|
+
fullScan() {
|
|
244
|
+
let entries;
|
|
245
|
+
try {
|
|
246
|
+
entries = fs.readdirSync(this.projectsDir);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// readdir failure → preserve old index, degrade to fallback
|
|
250
|
+
getLogger().warn('[session-index] readdir failed, preserving old index');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// readdir succeeded → the snapshot is authoritative for what we could read.
|
|
254
|
+
this.built = true;
|
|
255
|
+
const currentPaths = new Set();
|
|
256
|
+
for (const entry of entries) {
|
|
257
|
+
const subdir = path.join(this.projectsDir, entry);
|
|
258
|
+
let st;
|
|
259
|
+
try {
|
|
260
|
+
st = fs.statSync(subdir);
|
|
261
|
+
if (!st.isDirectory())
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
let files;
|
|
268
|
+
try {
|
|
269
|
+
files = fs.readdirSync(subdir).filter((f) => f.endsWith('.jsonl'));
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
for (const f of files) {
|
|
275
|
+
const fullPath = path.join(subdir, f);
|
|
276
|
+
currentPaths.add(fullPath);
|
|
277
|
+
// Check fingerprint
|
|
278
|
+
const fp = buildFingerprint(fullPath);
|
|
279
|
+
if (fp === '')
|
|
280
|
+
continue; // can't stat, skip
|
|
281
|
+
const existing = this.byPath.get(fullPath);
|
|
282
|
+
if (existing && existing.fingerprint === fp) {
|
|
283
|
+
// Unchanged, keep as-is
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
// New file or fingerprint changed → parse
|
|
287
|
+
// Concurrent-write guard: stat before and after parse, compare fingerprints
|
|
288
|
+
const fpBefore = fp;
|
|
289
|
+
const parsed = parseSessionJsonl(fullPath);
|
|
290
|
+
this.parseCount++;
|
|
291
|
+
const fpAfter = buildFingerprint(fullPath);
|
|
292
|
+
if (fpBefore !== fpAfter) {
|
|
293
|
+
// File was modified during parse → skip this round, retry next refresh
|
|
294
|
+
getLogger().info(`[session-index] concurrent write detected for ${f}, skipping`);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
const fileStat = fs.statSync(fullPath, { bigint: true });
|
|
299
|
+
const sessionId = f.slice(0, -'.jsonl'.length);
|
|
300
|
+
const newEntry = {
|
|
301
|
+
sessionId,
|
|
302
|
+
path: fullPath,
|
|
303
|
+
fingerprint: fpAfter,
|
|
304
|
+
mtimeMs: Number(fileStat.mtimeMs),
|
|
305
|
+
summary: truncate(parsed.summary, 60, { normalizeWhitespace: true }),
|
|
306
|
+
cwdSet: parsed.cwdSet,
|
|
307
|
+
};
|
|
308
|
+
this.updateEntry(fullPath, newEntry);
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
getLogger().warn(`[session-index] skip ${f}: ${err.message}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// Remove entries for files that no longer exist
|
|
316
|
+
for (const p of [...this.byPath.keys()]) {
|
|
317
|
+
if (!currentPaths.has(p)) {
|
|
318
|
+
this.removeEntry(p);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// ── Internal: index mutation ────────────────────────────
|
|
323
|
+
updateEntry(filePath, entry) {
|
|
324
|
+
const old = this.byPath.get(filePath);
|
|
325
|
+
// Remove old entry's cross-references if it exists
|
|
326
|
+
if (old) {
|
|
327
|
+
this.removeCrossRefs(filePath, old);
|
|
328
|
+
}
|
|
329
|
+
// Set new entry
|
|
330
|
+
this.byPath.set(filePath, entry);
|
|
331
|
+
// bySessionId
|
|
332
|
+
let idPaths = this.bySessionId.get(entry.sessionId);
|
|
333
|
+
if (!idPaths) {
|
|
334
|
+
idPaths = new Set();
|
|
335
|
+
this.bySessionId.set(entry.sessionId, idPaths);
|
|
336
|
+
}
|
|
337
|
+
idPaths.add(filePath);
|
|
338
|
+
// byCwd
|
|
339
|
+
for (const cwd of entry.cwdSet) {
|
|
340
|
+
let cwdPaths = this.byCwd.get(cwd);
|
|
341
|
+
if (!cwdPaths) {
|
|
342
|
+
cwdPaths = new Set();
|
|
343
|
+
this.byCwd.set(cwd, cwdPaths);
|
|
344
|
+
}
|
|
345
|
+
cwdPaths.add(filePath);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
removeEntry(filePath) {
|
|
349
|
+
const entry = this.byPath.get(filePath);
|
|
350
|
+
if (!entry)
|
|
351
|
+
return;
|
|
352
|
+
this.removeCrossRefs(filePath, entry);
|
|
353
|
+
this.byPath.delete(filePath);
|
|
354
|
+
}
|
|
355
|
+
removeCrossRefs(filePath, entry) {
|
|
356
|
+
// bySessionId
|
|
357
|
+
const idPaths = this.bySessionId.get(entry.sessionId);
|
|
358
|
+
if (idPaths) {
|
|
359
|
+
idPaths.delete(filePath);
|
|
360
|
+
if (idPaths.size === 0)
|
|
361
|
+
this.bySessionId.delete(entry.sessionId);
|
|
362
|
+
}
|
|
363
|
+
// byCwd
|
|
364
|
+
for (const cwd of entry.cwdSet) {
|
|
365
|
+
const cwdPaths = this.byCwd.get(cwd);
|
|
366
|
+
if (cwdPaths) {
|
|
367
|
+
cwdPaths.delete(filePath);
|
|
368
|
+
if (cwdPaths.size === 0)
|
|
369
|
+
this.byCwd.delete(cwd);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
//# sourceMappingURL=session-index.js.map
|
|
@@ -1,27 +1,9 @@
|
|
|
1
1
|
import type { AgentSession, AgentSessionContentEvent, AgentSessionUsage } from '../../runner/index.js';
|
|
2
|
-
/**
|
|
3
|
-
* Read the FIRST cwd from a Claude session JSONL. Used for display and
|
|
4
|
-
* diagnostics. Claude CLI writes the absolute (symlink-resolved) cwd on
|
|
5
|
-
* every event that produces one; older versions wrote it once on init.
|
|
6
|
-
* Returns the first non-empty value, or undefined if absent.
|
|
7
|
-
*
|
|
8
|
-
* For cwd GUARD checks (list/read), use `jsonlContainsCwd` which accepts
|
|
9
|
-
* any matching cwd field (required for relocated sessions whose first cwd
|
|
10
|
-
* is the pre-relocate path). The directory name under ~/.claude/projects/
|
|
11
|
-
* is lossy (encodes `/` AND `_` as `-`), so it cannot be decoded back to
|
|
12
|
-
* a real path. See regression 2026-06-21 /resume & /cd 路径错乱.
|
|
13
|
-
*/
|
|
14
|
-
export declare function readCwdFromJsonl(filePath: string): string | undefined;
|
|
15
2
|
/**
|
|
16
3
|
* List Claude Code sessions for a given cwd, newest first. Each entry has
|
|
17
4
|
* the sessionId (= jsonl filename stem), a one-line summary extracted from
|
|
18
5
|
* the first user message, and the file mtime. Returns an empty array when
|
|
19
6
|
* the project directory doesn't exist or no jsonl has a matching cwd field.
|
|
20
|
-
*
|
|
21
|
-
* We locate the directory via `projectDirForCwd` (lossy but OK for locating
|
|
22
|
-
* a known cwd) and then **verify** each jsonl's cwd field matches. This
|
|
23
|
-
* prevents collision: `disk_d/foo` and `disk-d/foo` both encode to the same
|
|
24
|
-
* directory name, but only the matching cwd field is kept.
|
|
25
7
|
*/
|
|
26
8
|
export declare function listClaudeSessions(cwd: string, opts?: {
|
|
27
9
|
limit?: number;
|