lark-remote 0.1.0
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 +52 -0
- package/LICENSE +21 -0
- package/README.en.md +162 -0
- package/README.md +161 -0
- package/dist/binder.d.ts +57 -0
- package/dist/binder.js +105 -0
- package/dist/bridge/index.d.ts +338 -0
- package/dist/bridge/index.js +1281 -0
- package/dist/bridge/queue-manager.d.ts +277 -0
- package/dist/bridge/queue-manager.js +729 -0
- package/dist/card/bash-card-session.d.ts +78 -0
- package/dist/card/bash-card-session.js +174 -0
- package/dist/card/bash-renderer.d.ts +24 -0
- package/dist/card/bash-renderer.js +187 -0
- package/dist/card/card-budget.d.ts +31 -0
- package/dist/card/card-budget.js +280 -0
- package/dist/card/card-session.d.ts +83 -0
- package/dist/card/card-session.js +185 -0
- package/dist/card/card-shared.d.ts +28 -0
- package/dist/card/card-shared.js +99 -0
- package/dist/card/collapsible.d.ts +26 -0
- package/dist/card/collapsible.js +87 -0
- package/dist/card/run-card-session.d.ts +78 -0
- package/dist/card/run-card-session.js +165 -0
- package/dist/card/run-renderer.d.ts +66 -0
- package/dist/card/run-renderer.js +841 -0
- package/dist/card/run-state.d.ts +103 -0
- package/dist/card/run-state.js +313 -0
- package/dist/card/text-truncate.d.ts +56 -0
- package/dist/card/text-truncate.js +184 -0
- package/dist/card/time.d.ts +1 -0
- package/dist/card/time.js +14 -0
- package/dist/card/tool-render.d.ts +25 -0
- package/dist/card/tool-render.js +176 -0
- package/dist/cli.d.ts +28 -0
- package/dist/cli.js +89 -0
- package/dist/common/guards.d.ts +37 -0
- package/dist/common/guards.js +65 -0
- package/dist/config/codex-config.d.ts +113 -0
- package/dist/config/codex-config.js +428 -0
- package/dist/config/dir.d.ts +17 -0
- package/dist/config/dir.js +81 -0
- package/dist/config/index.d.ts +219 -0
- package/dist/config/index.js +388 -0
- package/dist/config/kimi-config.d.ts +33 -0
- package/dist/config/kimi-config.js +111 -0
- package/dist/config/opencode-config.d.ts +20 -0
- package/dist/config/opencode-config.js +124 -0
- package/dist/config/pi-config.d.ts +49 -0
- package/dist/config/pi-config.js +151 -0
- package/dist/config/wizard.d.ts +12 -0
- package/dist/config/wizard.js +84 -0
- package/dist/connector/file-limits.d.ts +7 -0
- package/dist/connector/file-limits.js +8 -0
- package/dist/connector/index.d.ts +86 -0
- package/dist/connector/index.js +340 -0
- package/dist/error-classification.d.ts +15 -0
- package/dist/error-classification.js +24 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +580 -0
- package/dist/instance-lock.d.ts +30 -0
- package/dist/instance-lock.js +184 -0
- package/dist/logger/index.d.ts +57 -0
- package/dist/logger/index.js +178 -0
- package/dist/order/index.d.ts +27 -0
- package/dist/order/index.js +90 -0
- package/dist/persistence/atomic-write.d.ts +10 -0
- package/dist/persistence/atomic-write.js +65 -0
- package/dist/persistence/load-json-file.d.ts +9 -0
- package/dist/persistence/load-json-file.js +23 -0
- package/dist/restart.d.ts +24 -0
- package/dist/restart.js +73 -0
- package/dist/router/card-helpers.d.ts +28 -0
- package/dist/router/card-helpers.js +65 -0
- package/dist/router/config/claude.d.ts +14 -0
- package/dist/router/config/claude.js +72 -0
- package/dist/router/config/codex.d.ts +22 -0
- package/dist/router/config/codex.js +100 -0
- package/dist/router/config/common/render.d.ts +16 -0
- package/dist/router/config/common/render.js +158 -0
- package/dist/router/config/index.d.ts +18 -0
- package/dist/router/config/index.js +38 -0
- package/dist/router/config/kimi.d.ts +21 -0
- package/dist/router/config/kimi.js +63 -0
- package/dist/router/config/opencode.d.ts +12 -0
- package/dist/router/config/opencode.js +44 -0
- package/dist/router/config/pi.d.ts +12 -0
- package/dist/router/config/pi.js +50 -0
- package/dist/router/config/types.d.ts +43 -0
- package/dist/router/config/types.js +8 -0
- package/dist/router/index.d.ts +318 -0
- package/dist/router/index.js +2715 -0
- package/dist/router/order-exec-dispatch.d.ts +30 -0
- package/dist/router/order-exec-dispatch.js +48 -0
- package/dist/router/utils.d.ts +44 -0
- package/dist/router/utils.js +116 -0
- package/dist/runner/bash/index.d.ts +4 -0
- package/dist/runner/bash/index.js +5 -0
- package/dist/runner/bash/runner.d.ts +55 -0
- package/dist/runner/bash/runner.js +213 -0
- package/dist/runner/claude/index.d.ts +4 -0
- package/dist/runner/claude/index.js +5 -0
- package/dist/runner/claude/runner.d.ts +29 -0
- package/dist/runner/claude/runner.js +83 -0
- package/dist/runner/codex/argv.d.ts +33 -0
- package/dist/runner/codex/argv.js +40 -0
- package/dist/runner/codex/index.d.ts +1 -0
- package/dist/runner/codex/index.js +2 -0
- package/dist/runner/codex/jsonl.d.ts +37 -0
- package/dist/runner/codex/jsonl.js +221 -0
- package/dist/runner/codex/runner.d.ts +65 -0
- package/dist/runner/codex/runner.js +124 -0
- package/dist/runner/common/exec-translator.d.ts +73 -0
- package/dist/runner/common/exec-translator.js +105 -0
- package/dist/runner/common/jsonl-stream.d.ts +39 -0
- package/dist/runner/common/jsonl-stream.js +203 -0
- package/dist/runner/common/process-stopper.d.ts +28 -0
- package/dist/runner/common/process-stopper.js +79 -0
- package/dist/runner/common/runner-utils.d.ts +21 -0
- package/dist/runner/common/runner-utils.js +65 -0
- package/dist/runner/common/spawn-heartbeat.d.ts +33 -0
- package/dist/runner/common/spawn-heartbeat.js +51 -0
- package/dist/runner/common/spawning-runner.d.ts +321 -0
- package/dist/runner/common/spawning-runner.js +634 -0
- package/dist/runner/common/usage.d.ts +31 -0
- package/dist/runner/common/usage.js +29 -0
- package/dist/runner/index.d.ts +19 -0
- package/dist/runner/index.js +24 -0
- package/dist/runner/kimi/index.d.ts +1 -0
- package/dist/runner/kimi/index.js +2 -0
- package/dist/runner/kimi/jsonl.d.ts +28 -0
- package/dist/runner/kimi/jsonl.js +143 -0
- package/dist/runner/kimi/runner.d.ts +50 -0
- package/dist/runner/kimi/runner.js +105 -0
- package/dist/runner/opencode/argv.d.ts +32 -0
- package/dist/runner/opencode/argv.js +35 -0
- package/dist/runner/opencode/index.d.ts +1 -0
- package/dist/runner/opencode/index.js +2 -0
- package/dist/runner/opencode/jsonl.d.ts +68 -0
- package/dist/runner/opencode/jsonl.js +295 -0
- package/dist/runner/opencode/runner.d.ts +63 -0
- package/dist/runner/opencode/runner.js +93 -0
- package/dist/runner/pi/index.d.ts +1 -0
- package/dist/runner/pi/index.js +2 -0
- package/dist/runner/pi/jsonl.d.ts +128 -0
- package/dist/runner/pi/jsonl.js +249 -0
- package/dist/runner/pi/runner.d.ts +37 -0
- package/dist/runner/pi/runner.js +76 -0
- package/dist/runner/registry.d.ts +54 -0
- package/dist/runner/registry.js +75 -0
- package/dist/runner/resolve-agent-choices.d.ts +7 -0
- package/dist/runner/resolve-agent-choices.js +76 -0
- package/dist/runner/sync-agent-choices.d.ts +6 -0
- package/dist/runner/sync-agent-choices.js +73 -0
- package/dist/runner/types.d.ts +250 -0
- package/dist/runner/types.js +8 -0
- package/dist/session/claude/index.d.ts +1 -0
- package/dist/session/claude/index.js +2 -0
- package/dist/session/claude/session-reader.d.ts +34 -0
- package/dist/session/claude/session-reader.js +46 -0
- package/dist/session/claude/sessions.d.ts +53 -0
- package/dist/session/claude/sessions.js +528 -0
- package/dist/session/codex/index.d.ts +1 -0
- package/dist/session/codex/index.js +2 -0
- package/dist/session/codex/rollout-reader.d.ts +88 -0
- package/dist/session/codex/rollout-reader.js +473 -0
- package/dist/session/codex/sessions.d.ts +21 -0
- package/dist/session/codex/sessions.js +40 -0
- package/dist/session/common/catch-up.d.ts +12 -0
- package/dist/session/common/catch-up.js +31 -0
- package/dist/session/common/constants.d.ts +11 -0
- package/dist/session/common/constants.js +12 -0
- package/dist/session/common/content-blocks.d.ts +37 -0
- package/dist/session/common/content-blocks.js +68 -0
- package/dist/session/common/jsonl.d.ts +77 -0
- package/dist/session/common/jsonl.js +351 -0
- package/dist/session/common/usage-accumulator.d.ts +58 -0
- package/dist/session/common/usage-accumulator.js +72 -0
- package/dist/session/index.d.ts +7 -0
- package/dist/session/index.js +11 -0
- package/dist/session/kimi/index.d.ts +1 -0
- package/dist/session/kimi/index.js +2 -0
- package/dist/session/kimi/sessions.d.ts +81 -0
- package/dist/session/kimi/sessions.js +527 -0
- package/dist/session/opencode/index.d.ts +1 -0
- package/dist/session/opencode/index.js +2 -0
- package/dist/session/opencode/sessions.d.ts +81 -0
- package/dist/session/opencode/sessions.js +388 -0
- package/dist/session/pi/index.d.ts +1 -0
- package/dist/session/pi/index.js +2 -0
- package/dist/session/pi/sessions.d.ts +27 -0
- package/dist/session/pi/sessions.js +399 -0
- package/dist/session/registry.d.ts +12 -0
- package/dist/session/registry.js +20 -0
- package/dist/session/session-store.d.ts +86 -0
- package/dist/session/session-store.js +450 -0
- package/dist/startup-contact.d.ts +18 -0
- package/dist/startup-contact.js +51 -0
- package/dist/workspace/index.d.ts +19 -0
- package/dist/workspace/index.js +53 -0
- package/package.json +93 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { findJsonlLine, readLastJsonlLine, scanJsonlLines, readJsonlLinesFromOffset, } from '../common/jsonl.js';
|
|
5
|
+
import { STALE_MS } from '../common/constants.js';
|
|
6
|
+
import { extractContentBlocks } from '../common/content-blocks.js';
|
|
7
|
+
import { UsageAccumulator } from '../common/usage-accumulator.js';
|
|
8
|
+
import { getLogger } from '../../logger/index.js';
|
|
9
|
+
import { truncate } from '../../card/card-shared.js';
|
|
10
|
+
/**
|
|
11
|
+
* Directory Claude Code uses to store session transcripts for a given cwd.
|
|
12
|
+
* Claude encodes the cwd by replacing `/` with `-` (e.g.
|
|
13
|
+
* `/Users/x/proj` -> `-Users-x-proj`). Underscores are also replaced with `-`.
|
|
14
|
+
*
|
|
15
|
+
* Note: this encoding is LOSSY (`my_disk/foo` and `my-disk/foo` both encode to
|
|
16
|
+
* `my-disk-foo`). It is only safe to use the result to **locate** files; never
|
|
17
|
+
* to **decode** a directory name back to a cwd. Use `readCwdFromJsonl` for
|
|
18
|
+
* cwd (regression 2026-06-21 /resume & /cd 路径错乱).
|
|
19
|
+
*/
|
|
20
|
+
function projectDirForCwd(cwd, projectsDir) {
|
|
21
|
+
const encoded = cwd.replace(/\//g, '-').replace(/_/g, '-');
|
|
22
|
+
return path.join(projectsDir, encoded);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Read cwd from a Claude session JSONL by scanning events for the first
|
|
26
|
+
* `cwd` field. Claude CLI writes the absolute (symlink-resolved) cwd on
|
|
27
|
+
* every event that produces one; older versions wrote it once on init.
|
|
28
|
+
* Returns the first non-empty value, or undefined if absent.
|
|
29
|
+
*
|
|
30
|
+
* This is the SOLE source of truth for cwd. The directory name under
|
|
31
|
+
* ~/.claude/projects/ is lossy (encodes `/` AND `_` as `-`), so it cannot
|
|
32
|
+
* be decoded back to a real path. See regression 2026-06-21 /resume & /cd 路径错乱.
|
|
33
|
+
*/
|
|
34
|
+
export function readCwdFromJsonl(filePath) {
|
|
35
|
+
const line = findJsonlLine(filePath, (l) => {
|
|
36
|
+
try {
|
|
37
|
+
const obj = JSON.parse(l);
|
|
38
|
+
return typeof obj.cwd === 'string' && !!obj.cwd;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false; // skip malformed line
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
if (!line)
|
|
45
|
+
return undefined;
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(line).cwd;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* cwd guard for readSessionContent: the project-dir encoding is lossy
|
|
55
|
+
* (N-to-N), so a file located by directory must be verified to actually
|
|
56
|
+
* belong to the requested cwd. A relocated session contains MULTIPLE cwd
|
|
57
|
+
* values (pre- and post-relocate) — accept when ANY `cwd` field matches
|
|
58
|
+
* (was: first-cwd-only via readCwdFromJsonl, which rejected relocated
|
|
59
|
+
* sessions whose first cwd is the pre-relocate path). Regression
|
|
60
|
+
* 2026-08-04 EnterWorktree relocate.
|
|
61
|
+
*/
|
|
62
|
+
function jsonlContainsCwd(filePath, cwd) {
|
|
63
|
+
return (findJsonlLine(filePath, (l) => {
|
|
64
|
+
try {
|
|
65
|
+
const obj = JSON.parse(l);
|
|
66
|
+
return typeof obj.cwd === 'string' && obj.cwd === cwd;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false; // skip malformed line
|
|
70
|
+
}
|
|
71
|
+
}) !== null);
|
|
72
|
+
}
|
|
73
|
+
function defaultProjectsDir() {
|
|
74
|
+
return path.join(os.homedir(), '.claude', 'projects');
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* EnterWorktree relocate support (2026-08-04): Claude Code MOVES the
|
|
78
|
+
* transcript file to the new cwd's project dir mid-session, so the file may
|
|
79
|
+
* not exist under the requested cwd's encoded dir. Fall back to locating
|
|
80
|
+
* `<sessionId>.jsonl` across all project dirs. Deterministic: entries are
|
|
81
|
+
* sorted, first match wins; the cwd guard in `readSessionContent` still
|
|
82
|
+
* validates the file afterwards.
|
|
83
|
+
*/
|
|
84
|
+
function findSessionFileInProjects(sessionId, projectsDir) {
|
|
85
|
+
let entries;
|
|
86
|
+
try {
|
|
87
|
+
entries = fs.readdirSync(projectsDir);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
const name = `${sessionId}.jsonl`;
|
|
93
|
+
for (const entry of entries.sort()) {
|
|
94
|
+
const candidate = path.join(projectsDir, entry, name);
|
|
95
|
+
if (fs.existsSync(candidate))
|
|
96
|
+
return candidate;
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* P1-19: TTL cache for full project listings (mirror codex getSessionIndex's
|
|
102
|
+
* 5s TTL). Repeated /resume pages re-scan the whole project dir per call
|
|
103
|
+
* (readdir + 2 opens/file + full reads for files without a user message);
|
|
104
|
+
* caching the full list within the TTL removes the redundant scans.
|
|
105
|
+
*/
|
|
106
|
+
const LIST_CACHE_TTL_MS = 5000;
|
|
107
|
+
/** Upper bound on cached project listings; FIFO eviction past this. */
|
|
108
|
+
const LIST_CACHE_MAX_ENTRIES = 32;
|
|
109
|
+
const claudeListCache = new Map();
|
|
110
|
+
/**
|
|
111
|
+
* Extract a one-line summary from a session JSONL by finding the first
|
|
112
|
+
* `type: "user"` record and returning its prompt text. Uses streaming
|
|
113
|
+
* early-stop via `findJsonlLine` so only the lines up to the first user
|
|
114
|
+
* message are read (not the entire file). Returns a fallback when the
|
|
115
|
+
* file is unreadable or contains no user message.
|
|
116
|
+
*
|
|
117
|
+
* P2-3 optimization: previously used `readJsonlLines` (full-file slurp)
|
|
118
|
+
* just to get the first user message. Now uses `findJsonlLine` which
|
|
119
|
+
* stops reading as soon as the predicate matches — typically only the
|
|
120
|
+
* first few lines of the file, saving ~99% I/O on large sessions.
|
|
121
|
+
*/
|
|
122
|
+
function summarizeSession(filePath) {
|
|
123
|
+
const line = findJsonlLine(filePath, (l) => {
|
|
124
|
+
try {
|
|
125
|
+
const obj = JSON.parse(l);
|
|
126
|
+
return obj.type === 'user' && !!obj.message?.content && !!extractText(obj.message.content);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
if (!line)
|
|
133
|
+
return '(无摘要)';
|
|
134
|
+
try {
|
|
135
|
+
const obj = JSON.parse(line);
|
|
136
|
+
return extractText(obj.message?.content) ?? '(无摘要)';
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return '(无摘要)';
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Claude-specific field-name mapping for content block extraction. */
|
|
143
|
+
const CLAUDE_MAPPING = {
|
|
144
|
+
toolUseType: 'tool_use',
|
|
145
|
+
toolResultType: 'tool_result',
|
|
146
|
+
toolInputField: 'input',
|
|
147
|
+
toolErrorField: 'is_error',
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* Extract first text from a user message.
|
|
151
|
+
* Used by readFirstUserMessage.
|
|
152
|
+
*/
|
|
153
|
+
function extractText(content) {
|
|
154
|
+
// Handle string content directly (for simple user messages)
|
|
155
|
+
if (typeof content === 'string' && content.trim()) {
|
|
156
|
+
return content.trim();
|
|
157
|
+
}
|
|
158
|
+
const blocks = extractContentBlocks(content, CLAUDE_MAPPING);
|
|
159
|
+
// Prefer text block, skip if all blocks are tool_result (user just ran code)
|
|
160
|
+
const textBlock = blocks.find((b) => b.type === 'text');
|
|
161
|
+
if (textBlock)
|
|
162
|
+
return textBlock.content;
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* List Claude Code sessions for a given cwd, newest first. Each entry has
|
|
167
|
+
* the sessionId (= jsonl filename stem), a one-line summary extracted from
|
|
168
|
+
* the first user message, and the file mtime. Returns an empty array when
|
|
169
|
+
* the project directory doesn't exist or no jsonl has a matching cwd field.
|
|
170
|
+
*
|
|
171
|
+
* We locate the directory via `projectDirForCwd` (lossy but OK for locating
|
|
172
|
+
* a known cwd) and then **verify** each jsonl's cwd field matches. This
|
|
173
|
+
* prevents collision: `my_disk/foo` and `my-disk/foo` both encode to the same
|
|
174
|
+
* directory name, but only the matching cwd field is kept.
|
|
175
|
+
*/
|
|
176
|
+
export function listClaudeSessions(cwd, opts = {}) {
|
|
177
|
+
// 不传 limit = 返回全集不切片;仅显式传值才 slice(契约迁移 plan §2.1,
|
|
178
|
+
// reader 拿全集算 total 后再自己按 offset/limit 切片)。
|
|
179
|
+
const limit = opts.limit;
|
|
180
|
+
const dir = projectDirForCwd(cwd, opts.projectsDir ?? defaultProjectsDir());
|
|
181
|
+
const cacheKey = `${dir}\u0000${cwd}`;
|
|
182
|
+
const cached = claudeListCache.get(cacheKey);
|
|
183
|
+
let sessions;
|
|
184
|
+
if (cached && Date.now() - cached.builtAt < LIST_CACHE_TTL_MS) {
|
|
185
|
+
sessions = cached.sessions;
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
sessions = scanClaudeSessions(cwd, dir);
|
|
189
|
+
claudeListCache.set(cacheKey, { builtAt: Date.now(), sessions });
|
|
190
|
+
// Bound the cache (map iteration order = insertion order).
|
|
191
|
+
if (claudeListCache.size > LIST_CACHE_MAX_ENTRIES) {
|
|
192
|
+
const oldest = claudeListCache.keys().next().value;
|
|
193
|
+
if (oldest !== undefined)
|
|
194
|
+
claudeListCache.delete(oldest);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return limit === undefined ? sessions : sessions.slice(0, limit);
|
|
198
|
+
}
|
|
199
|
+
/** Uncached scan of a claude project dir (the expensive part being cached). */
|
|
200
|
+
function scanClaudeSessions(cwd, dir) {
|
|
201
|
+
let files;
|
|
202
|
+
try {
|
|
203
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
const sessions = [];
|
|
209
|
+
for (const f of files) {
|
|
210
|
+
const full = path.join(dir, f);
|
|
211
|
+
try {
|
|
212
|
+
const st = fs.statSync(full);
|
|
213
|
+
// Verify the jsonl's cwd field actually matches. Defends against
|
|
214
|
+
// directory-name collisions where a different cwd encoded to the same
|
|
215
|
+
// directory name.
|
|
216
|
+
const fileCwd = readCwdFromJsonl(full);
|
|
217
|
+
if (fileCwd !== cwd)
|
|
218
|
+
continue;
|
|
219
|
+
const sessionId = f.slice(0, -'.jsonl'.length);
|
|
220
|
+
sessions.push({
|
|
221
|
+
sessionId,
|
|
222
|
+
summary: truncate(summarizeSession(full), 60, { normalizeWhitespace: true }),
|
|
223
|
+
mtime: st.mtimeMs,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
getLogger().warn(`[session] skip ${f}: ${err.message}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Same-mtime ties: secondary key keeps the full order deterministic.
|
|
231
|
+
sessions.sort((a, b) => b.mtime - a.mtime || a.sessionId.localeCompare(b.sessionId));
|
|
232
|
+
return sessions;
|
|
233
|
+
}
|
|
234
|
+
/** Get the newest session for a given cwd. Returns undefined if none exist. */
|
|
235
|
+
export function getNewestSession(cwd, opts = {}) {
|
|
236
|
+
const sessions = listClaudeSessions(cwd, { ...opts, limit: 1 });
|
|
237
|
+
return sessions[0];
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Check if a session is still active. Two signals combined:
|
|
241
|
+
*
|
|
242
|
+
* 1. **mtime freshness** (authoritative): an active claude process flushes
|
|
243
|
+
* JSONL output continuously. A file whose mtime is older than
|
|
244
|
+
* `STALE_MS` cannot be from a running process. This catches the
|
|
245
|
+
* common Claude CLI 2.x case where the tail is `last-prompt` / `mode`
|
|
246
|
+
* (no `type:"result"`) and the process exited long ago (regression 2026-06-21).
|
|
247
|
+
*
|
|
248
|
+
* 2. **last-line terminal marker**: if the last JSON line is `result`
|
|
249
|
+
* (older CLI) or any explicit terminal event, the session is done
|
|
250
|
+
* regardless of mtime.
|
|
251
|
+
*
|
|
252
|
+
* Override `now` for tests.
|
|
253
|
+
*/
|
|
254
|
+
function isSessionActive(filePath, mtimeMs, now = Date.now()) {
|
|
255
|
+
// Hard signal: file untouched for over an hour → can't be from a live process
|
|
256
|
+
if (now - mtimeMs > STALE_MS)
|
|
257
|
+
return false;
|
|
258
|
+
const lastLine = readLastJsonlLine(filePath);
|
|
259
|
+
if (!lastLine)
|
|
260
|
+
return false;
|
|
261
|
+
try {
|
|
262
|
+
const obj = JSON.parse(lastLine);
|
|
263
|
+
// Explicit terminal markers — regardless of mtime. Claude CLI 2.x uses
|
|
264
|
+
// `last-prompt` / `mode` (no `result` event) to mark turn end, so a
|
|
265
|
+
// 24-minute-old session with a last-prompt tail is already done
|
|
266
|
+
// (regression 2026-06-21: c5e929a7 falsely reported as active).
|
|
267
|
+
const TERMINAL_TYPES = new Set(['result', 'last-prompt', 'mode', 'permission-mode']);
|
|
268
|
+
if (typeof obj.type === 'string' && TERMINAL_TYPES.has(obj.type))
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// ignore parse errors
|
|
273
|
+
}
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Check if a Claude session (by sessionId + cwd) is still active.
|
|
278
|
+
* Public wrapper around the internal `isSessionActive(filePath, mtimeMs, now)`
|
|
279
|
+
* for use by `ClaudeSessionReader` and the multi-agent adapter layer.
|
|
280
|
+
*
|
|
281
|
+
* Resolves the jsonl path via `projectDirForCwd`, stats it for mtime, then
|
|
282
|
+
* applies the same mtime-freshness + terminal-marker heuristic
|
|
283
|
+
* used for session-active detection. Returns false if the file doesn't exist.
|
|
284
|
+
*/
|
|
285
|
+
export function isClaudeSessionActive(sessionId, cwd, opts = {}) {
|
|
286
|
+
const dir = projectDirForCwd(cwd, opts.projectsDir ?? defaultProjectsDir());
|
|
287
|
+
const filePath = path.join(dir, `${sessionId}.jsonl`);
|
|
288
|
+
if (!fs.existsSync(filePath))
|
|
289
|
+
return false;
|
|
290
|
+
try {
|
|
291
|
+
const st = fs.statSync(filePath);
|
|
292
|
+
return isSessionActive(filePath, st.mtimeMs);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* P2-2 + P2-5 first-pass scan: stream the file once via `scanJsonlLines`
|
|
300
|
+
* (no `string[]` materialized), parse each line exactly once, and collect
|
|
301
|
+
* all metadata needed by readSessionContent (usage, tailOffset, aiTitle,
|
|
302
|
+
* recap, lastUserMessage). Does NOT retain parsed objects — the events tail
|
|
303
|
+
* is re-parsed separately by `extractEventsFromTail` so memory for both raw
|
|
304
|
+
* line strings and parsed objects is O(tail), not O(whole file).
|
|
305
|
+
*/
|
|
306
|
+
function scalarScan(filePath) {
|
|
307
|
+
const acc = new UsageAccumulator();
|
|
308
|
+
const seenMessageIds = new Set();
|
|
309
|
+
let lastPostTokens = 0;
|
|
310
|
+
let tailOffset = -1;
|
|
311
|
+
let lastAiTitle;
|
|
312
|
+
let lastRecap;
|
|
313
|
+
let lastUserMessage;
|
|
314
|
+
scanJsonlLines(filePath, (line, offset) => {
|
|
315
|
+
let obj;
|
|
316
|
+
try {
|
|
317
|
+
obj = JSON.parse(line);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
// --- usage aggregation (was aggregateSessionUsage) ---
|
|
323
|
+
if (obj.type === 'assistant') {
|
|
324
|
+
const msg = obj.message;
|
|
325
|
+
const usage = msg?.usage;
|
|
326
|
+
if (usage) {
|
|
327
|
+
const msgId = msg?.id;
|
|
328
|
+
if (msgId && !seenMessageIds.has(msgId)) {
|
|
329
|
+
seenMessageIds.add(msgId);
|
|
330
|
+
acc.add({
|
|
331
|
+
input: usage.input_tokens ?? 0,
|
|
332
|
+
output: usage.output_tokens ?? 0,
|
|
333
|
+
cacheRead: usage.cache_read_input_tokens ?? 0,
|
|
334
|
+
cacheCreation: usage.cache_creation_input_tokens ?? 0,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (obj.type === 'system' && obj.subtype === 'compact_boundary') {
|
|
340
|
+
acc.bumpCompact();
|
|
341
|
+
const cm = obj.compactMetadata;
|
|
342
|
+
if (cm?.postTokens) {
|
|
343
|
+
lastPostTokens = cm.postTokens;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// --- last user message offset (was findLastUserIndex) ---
|
|
347
|
+
// Record the byte offset where the tail should start: the beginning of
|
|
348
|
+
// the NEXT line after this user message. We compute it as this line's
|
|
349
|
+
// start offset + its byte length + 1 (the trailing '\n').
|
|
350
|
+
if (obj.type === 'user') {
|
|
351
|
+
const msg = obj.message;
|
|
352
|
+
if (msg?.role === 'user') {
|
|
353
|
+
tailOffset = offset + Buffer.byteLength(line, 'utf-8') + 1;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// --- ai-title extraction ---
|
|
357
|
+
if (obj.type === 'ai-title' && typeof obj.aiTitle === 'string') {
|
|
358
|
+
lastAiTitle = obj.aiTitle;
|
|
359
|
+
}
|
|
360
|
+
// --- recap extraction (last isCompactSummary wins) ---
|
|
361
|
+
if (obj.type === 'user' && obj.isCompactSummary === true) {
|
|
362
|
+
const msg = obj.message;
|
|
363
|
+
if (msg?.content) {
|
|
364
|
+
const recapContent = extractText(msg.content);
|
|
365
|
+
if (recapContent) {
|
|
366
|
+
lastRecap = recapContent;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// --- last user message for displayTitle ---
|
|
371
|
+
// Skip: 1) tool_result content, 2) isCompactSummary (recap),
|
|
372
|
+
// 3) isMeta (skill injection / command echo / CLAUDE.md)
|
|
373
|
+
if (obj.type === 'user' && !obj.isCompactSummary && !obj.isMeta) {
|
|
374
|
+
const msg = obj.message;
|
|
375
|
+
if (msg?.role === 'user' && msg.content) {
|
|
376
|
+
const content = msg.content;
|
|
377
|
+
const isToolResult = Array.isArray(content) &&
|
|
378
|
+
content.length > 0 &&
|
|
379
|
+
content[0]?.type === 'tool_result';
|
|
380
|
+
if (!isToolResult) {
|
|
381
|
+
const userContent = extractText(content);
|
|
382
|
+
if (userContent) {
|
|
383
|
+
lastUserMessage = userContent;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
// Build usage result (same logic as old aggregateSessionUsage)
|
|
390
|
+
const t = acc.totals;
|
|
391
|
+
const l = acc.last;
|
|
392
|
+
// contextLength = current context-window occupancy. Prefer compact_boundary
|
|
393
|
+
// postTokens (the precise post-compact size); otherwise fall back to the
|
|
394
|
+
// last turn's input + cacheRead + cacheCreation (review P2-8 unified
|
|
395
|
+
// contract — excludes output/reasoning, which are generated, not part of
|
|
396
|
+
// the input window). Take the max so a pre-compact turn larger than the
|
|
397
|
+
// post-compact size is not under-reported.
|
|
398
|
+
const lastWindow = l ? l.input + l.cacheRead + l.cacheCreation : 0;
|
|
399
|
+
let contextLength = lastPostTokens;
|
|
400
|
+
if (contextLength === 0 || lastWindow > contextLength) {
|
|
401
|
+
contextLength = lastWindow;
|
|
402
|
+
}
|
|
403
|
+
const usage = {
|
|
404
|
+
inputTokens: t.input,
|
|
405
|
+
outputTokens: t.output,
|
|
406
|
+
contextLength,
|
|
407
|
+
compactCount: acc.compactCount,
|
|
408
|
+
cacheReadTokens: t.cacheRead > 0 ? t.cacheRead : undefined,
|
|
409
|
+
cacheCreationTokens: t.cacheCreation > 0 ? t.cacheCreation : undefined,
|
|
410
|
+
totalTokens: t.input + t.output + t.cacheRead + t.cacheCreation,
|
|
411
|
+
cumulativeTotalTokens: t.input + t.output + t.cacheRead + t.cacheCreation,
|
|
412
|
+
cumulativeInputTokens: t.input,
|
|
413
|
+
cumulativeOutputTokens: t.output,
|
|
414
|
+
cumulativeCacheReadTokens: t.cacheRead,
|
|
415
|
+
cumulativeCacheCreationTokens: t.cacheCreation,
|
|
416
|
+
};
|
|
417
|
+
return {
|
|
418
|
+
tailOffset,
|
|
419
|
+
usage,
|
|
420
|
+
aiTitle: lastAiTitle,
|
|
421
|
+
recap: lastRecap,
|
|
422
|
+
lastUserMessage,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* P2-2 + P2-5 second-pass: extract content-block events from the tail
|
|
427
|
+
* (lines after the last user message), re-parsing only those lines.
|
|
428
|
+
* `tailLines` is already the tail-only slice (read via
|
|
429
|
+
* `readJsonlLinesFromOffset`), so both raw line-string memory and parsed-
|
|
430
|
+
* object memory are O(tail), not O(whole file) — P3-1 + P2-5.
|
|
431
|
+
*
|
|
432
|
+
* `maxEvents` (optional): when set, only the LAST `maxEvents` events are kept
|
|
433
|
+
* (review P2-7 — unified "last N events" contract across all five readers,
|
|
434
|
+
* matching codex/opencode/pi's `slice(-maxEvents)`). The whole tail is parsed
|
|
435
|
+
* first (usage/title extraction above already scanned the file; this pass only
|
|
436
|
+
* re-parses the short tail), then `slice(-maxEvents)` keeps the most recent
|
|
437
|
+
* events. `maxEvents <= 0` returns `[]` (guards the `slice(-0) === slice(0)`
|
|
438
|
+
* full-array trap).
|
|
439
|
+
*/
|
|
440
|
+
function extractEventsFromTail(tailLines, maxEvents) {
|
|
441
|
+
const events = [];
|
|
442
|
+
for (const line of tailLines) {
|
|
443
|
+
if (!line.trim())
|
|
444
|
+
continue;
|
|
445
|
+
let obj;
|
|
446
|
+
try {
|
|
447
|
+
obj = JSON.parse(line);
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
const message = obj.message;
|
|
453
|
+
// Expand all content blocks from this message into separate events.
|
|
454
|
+
const blocks = extractContentBlocks(message?.content, CLAUDE_MAPPING);
|
|
455
|
+
const role = message?.role ?? obj.type ?? 'unknown';
|
|
456
|
+
const timestamp = obj.timestamp;
|
|
457
|
+
for (const block of blocks) {
|
|
458
|
+
events.push({ type: block.type, content: block.content, timestamp });
|
|
459
|
+
}
|
|
460
|
+
// If message has no content blocks (e.g., metadata-only lines like last-prompt),
|
|
461
|
+
// still emit an event with the raw type so users see what happened
|
|
462
|
+
if (blocks.length === 0 && message?.role) {
|
|
463
|
+
events.push({ type: role, content: `(${role} event)`, timestamp });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (maxEvents !== undefined) {
|
|
467
|
+
if (maxEvents <= 0)
|
|
468
|
+
return [];
|
|
469
|
+
return events.slice(-maxEvents);
|
|
470
|
+
}
|
|
471
|
+
return events;
|
|
472
|
+
}
|
|
473
|
+
export function readSessionContent(sessionId, cwd, opts = {}) {
|
|
474
|
+
const { maxEvents } = opts;
|
|
475
|
+
const projectsDir = opts.projectsDir ?? defaultProjectsDir();
|
|
476
|
+
const dir = projectDirForCwd(cwd, projectsDir);
|
|
477
|
+
let filePath = path.join(dir, `${sessionId}.jsonl`);
|
|
478
|
+
if (!fs.existsSync(filePath)) {
|
|
479
|
+
// EnterWorktree relocate: transcript moved to the new cwd's project dir.
|
|
480
|
+
const relocated = findSessionFileInProjects(sessionId, projectsDir);
|
|
481
|
+
if (!relocated)
|
|
482
|
+
return { events: [] };
|
|
483
|
+
filePath = relocated;
|
|
484
|
+
}
|
|
485
|
+
// Verify the jsonl actually belongs to the requested cwd (dir encoding is
|
|
486
|
+
// lossy). ANY cwd field may match: relocated sessions contain multiple.
|
|
487
|
+
if (!jsonlContainsCwd(filePath, cwd)) {
|
|
488
|
+
return { events: [] };
|
|
489
|
+
}
|
|
490
|
+
// P2-2 + P3-1 + P2-5: First pass STREAMS the file once via
|
|
491
|
+
// `scanJsonlLines` (no `string[]` materialized), parsing each line once to
|
|
492
|
+
// collect scalars (usage, tailOffset, aiTitle, recap, lastUserMessage)
|
|
493
|
+
// while retaining NO parsed objects and NO whole-file line array. The
|
|
494
|
+
// tailOffset is the byte offset where the tail begins (start of the line
|
|
495
|
+
// after the last user message). A second pass re-reads ONLY the tail from
|
|
496
|
+
// that offset via `readJsonlLinesFromOffset` and re-parses it for events —
|
|
497
|
+
// O(tail) memory for both raw line strings and parsed objects, instead of
|
|
498
|
+
// O(whole file).
|
|
499
|
+
//
|
|
500
|
+
// Parse ratio: ≈1.0–1.5× line count when a user message exists (streaming
|
|
501
|
+
// full-file scan + tail re-parse of the usually-short post-user lines).
|
|
502
|
+
// When the session has NO user message, tailOffset stays -1 and the tail
|
|
503
|
+
// IS the whole file → scan(N) + tail(N) = 2.0× — the known asymptotic
|
|
504
|
+
// upper bound of the body's two-phase design (still better than pre-P2-2's
|
|
505
|
+
// ~3×). Total parse count adds readCwdFromJsonl's fixed overhead (~2
|
|
506
|
+
// parses, independent of line count).
|
|
507
|
+
const scan = scalarScan(filePath);
|
|
508
|
+
const usage = scan.usage.inputTokens + scan.usage.outputTokens > 0 ? scan.usage : undefined;
|
|
509
|
+
// Extract events AFTER the last user message by re-reading + re-parsing
|
|
510
|
+
// only the tail. If no user message was found (tailOffset === -1), fall
|
|
511
|
+
// back to re-reading the whole file so the card still has something to
|
|
512
|
+
// show.
|
|
513
|
+
const tailLines = readJsonlLinesFromOffset(filePath, scan.tailOffset >= 0 ? scan.tailOffset : 0);
|
|
514
|
+
const events = extractEventsFromTail(tailLines, maxEvents);
|
|
515
|
+
// Use aiTitle if available, otherwise fallback to last user message (truncated to 200 chars)
|
|
516
|
+
const displayTitle = scan.aiTitle ??
|
|
517
|
+
(scan.lastUserMessage
|
|
518
|
+
? truncate(scan.lastUserMessage, 200, { normalizeWhitespace: true })
|
|
519
|
+
: undefined);
|
|
520
|
+
return {
|
|
521
|
+
events,
|
|
522
|
+
usage,
|
|
523
|
+
aiTitle: scan.aiTitle,
|
|
524
|
+
recap: scan.recap,
|
|
525
|
+
displayTitle,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
//# sourceMappingURL=sessions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CodexSessionReader } from './sessions.js';
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex Rollout File Reader.
|
|
3
|
+
*
|
|
4
|
+
* Reads Codex session history from rollout JSONL files in `~/.codex/sessions/`.
|
|
5
|
+
*
|
|
6
|
+
* Rollout file format (appendix C in design doc):
|
|
7
|
+
* ```
|
|
8
|
+
* {"timestamp":"...","type":"session_meta","payload":{"session_id":"...","cwd":"...","originator":"lark-remote",...}}
|
|
9
|
+
* {"timestamp":"...","type":"event_msg","payload":{"type":"task_started","turn_id":"..."}}
|
|
10
|
+
* {"timestamp":"...","type":"response_item","payload":{"type":"message","role":"user|developer","content":[...]}}
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Note: This handles the **rollout file format** (historical logging), NOT the
|
|
14
|
+
* `codex exec --json` stdout format. Those are two different JSONL formats
|
|
15
|
+
* (see design doc appendix B vs C).
|
|
16
|
+
*/
|
|
17
|
+
import type { AgentSessionContentEvent, SessionContent, AgentSessionUsage } from '../../runner/index.js';
|
|
18
|
+
interface CodexRolloutEntry {
|
|
19
|
+
threadId: string;
|
|
20
|
+
cwd: string;
|
|
21
|
+
/** First real (human-typed) user message, for session summary/title. */
|
|
22
|
+
firstUserMessage: string;
|
|
23
|
+
/** Last real (human-typed) user message, for the "最近输入" card label. */
|
|
24
|
+
lastRealUserMessage: string;
|
|
25
|
+
createdAtMs: number;
|
|
26
|
+
updatedAtMs: number;
|
|
27
|
+
events: AgentSessionContentEvent[];
|
|
28
|
+
/** Last-turn usage extracted from token_count events (ccusage-aligned). */
|
|
29
|
+
usage?: AgentSessionUsage;
|
|
30
|
+
}
|
|
31
|
+
interface ListCodexRolloutsOptions {
|
|
32
|
+
/** Override CODEX_HOME. Default: ~/.codex */
|
|
33
|
+
codexHome?: string;
|
|
34
|
+
/** Filter by working directory. */
|
|
35
|
+
cwd?: string;
|
|
36
|
+
/** Max entries to return. Default: 20 */
|
|
37
|
+
limit?: number;
|
|
38
|
+
/** Page offset into the mtime-desc full set. Default: 0 */
|
|
39
|
+
offset?: number;
|
|
40
|
+
}
|
|
41
|
+
export declare function readCodexRollout(filePath: string): CodexRolloutEntry | null;
|
|
42
|
+
/**
|
|
43
|
+
* List Codex sessions from rollout files.
|
|
44
|
+
*
|
|
45
|
+
* Returns `{ entries, total }`: `total` is the size of the full cwd-matched
|
|
46
|
+
* set before pagination; `entries` are the newest `limit` entries by mtime.
|
|
47
|
+
*
|
|
48
|
+
* A global order over the full set is established first (plan §1.4: no early
|
|
49
|
+
* termination before ordering) using the lightweight session index — full
|
|
50
|
+
* walk + first-line session_meta parse + stat mtime, 5s TTL cache. Only the
|
|
51
|
+
* returned page is fully parsed for summary/usage (plan §2.2).
|
|
52
|
+
*/
|
|
53
|
+
export declare function listCodexRollouts(opts?: ListCodexRolloutsOptions): {
|
|
54
|
+
entries: CodexRolloutEntry[];
|
|
55
|
+
total: number;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Read the full content of a specific session by its threadId.
|
|
59
|
+
*
|
|
60
|
+
* P2-4: Uses session index for O(1) file lookup instead of walking the
|
|
61
|
+
* entire directory tree and fully parsing every file. Only the matching
|
|
62
|
+
* file is fully parsed.
|
|
63
|
+
*
|
|
64
|
+
* `maxEvents` (optional): when set, only the LAST `maxEvents` events are
|
|
65
|
+
* returned (most recent activity). This matches the auto-resume use case
|
|
66
|
+
* ("show recent history") and keeps the card payload small. Unlike claude,
|
|
67
|
+
* codex events are already at message granularity (no multi-block split),
|
|
68
|
+
* so a hard `slice(-maxEvents)` is sufficient — no soft-cap complication.
|
|
69
|
+
*/
|
|
70
|
+
export declare function readCodexSessionContent(sessionId: string, opts?: {
|
|
71
|
+
codexHome?: string;
|
|
72
|
+
maxEvents?: number;
|
|
73
|
+
}): SessionContent;
|
|
74
|
+
/**
|
|
75
|
+
* Check if a session is still active (recently updated).
|
|
76
|
+
*
|
|
77
|
+
* P2-4: Uses session index for direct file lookup, then checks mtime
|
|
78
|
+
* via statSync — no full rollout parse required. This reduces the
|
|
79
|
+
* cost from "walk entire directory + full-parse every file" to a single
|
|
80
|
+
* statSync call for the matching file.
|
|
81
|
+
*/
|
|
82
|
+
export declare function isCodexSessionActive(sessionId: string, opts?: {
|
|
83
|
+
codexHome?: string;
|
|
84
|
+
activeThresholdMs?: number;
|
|
85
|
+
}): boolean;
|
|
86
|
+
/** Clear the session index cache. Exposed for tests. */
|
|
87
|
+
export declare function clearSessionIndexCache(): void;
|
|
88
|
+
export {};
|