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.
Files changed (201) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +162 -0
  4. package/README.md +161 -0
  5. package/dist/binder.d.ts +57 -0
  6. package/dist/binder.js +105 -0
  7. package/dist/bridge/index.d.ts +338 -0
  8. package/dist/bridge/index.js +1281 -0
  9. package/dist/bridge/queue-manager.d.ts +277 -0
  10. package/dist/bridge/queue-manager.js +729 -0
  11. package/dist/card/bash-card-session.d.ts +78 -0
  12. package/dist/card/bash-card-session.js +174 -0
  13. package/dist/card/bash-renderer.d.ts +24 -0
  14. package/dist/card/bash-renderer.js +187 -0
  15. package/dist/card/card-budget.d.ts +31 -0
  16. package/dist/card/card-budget.js +280 -0
  17. package/dist/card/card-session.d.ts +83 -0
  18. package/dist/card/card-session.js +185 -0
  19. package/dist/card/card-shared.d.ts +28 -0
  20. package/dist/card/card-shared.js +99 -0
  21. package/dist/card/collapsible.d.ts +26 -0
  22. package/dist/card/collapsible.js +87 -0
  23. package/dist/card/run-card-session.d.ts +78 -0
  24. package/dist/card/run-card-session.js +165 -0
  25. package/dist/card/run-renderer.d.ts +66 -0
  26. package/dist/card/run-renderer.js +841 -0
  27. package/dist/card/run-state.d.ts +103 -0
  28. package/dist/card/run-state.js +313 -0
  29. package/dist/card/text-truncate.d.ts +56 -0
  30. package/dist/card/text-truncate.js +184 -0
  31. package/dist/card/time.d.ts +1 -0
  32. package/dist/card/time.js +14 -0
  33. package/dist/card/tool-render.d.ts +25 -0
  34. package/dist/card/tool-render.js +176 -0
  35. package/dist/cli.d.ts +28 -0
  36. package/dist/cli.js +89 -0
  37. package/dist/common/guards.d.ts +37 -0
  38. package/dist/common/guards.js +65 -0
  39. package/dist/config/codex-config.d.ts +113 -0
  40. package/dist/config/codex-config.js +428 -0
  41. package/dist/config/dir.d.ts +17 -0
  42. package/dist/config/dir.js +81 -0
  43. package/dist/config/index.d.ts +219 -0
  44. package/dist/config/index.js +388 -0
  45. package/dist/config/kimi-config.d.ts +33 -0
  46. package/dist/config/kimi-config.js +111 -0
  47. package/dist/config/opencode-config.d.ts +20 -0
  48. package/dist/config/opencode-config.js +124 -0
  49. package/dist/config/pi-config.d.ts +49 -0
  50. package/dist/config/pi-config.js +151 -0
  51. package/dist/config/wizard.d.ts +12 -0
  52. package/dist/config/wizard.js +84 -0
  53. package/dist/connector/file-limits.d.ts +7 -0
  54. package/dist/connector/file-limits.js +8 -0
  55. package/dist/connector/index.d.ts +86 -0
  56. package/dist/connector/index.js +340 -0
  57. package/dist/error-classification.d.ts +15 -0
  58. package/dist/error-classification.js +24 -0
  59. package/dist/index.d.ts +1 -0
  60. package/dist/index.js +580 -0
  61. package/dist/instance-lock.d.ts +30 -0
  62. package/dist/instance-lock.js +184 -0
  63. package/dist/logger/index.d.ts +57 -0
  64. package/dist/logger/index.js +178 -0
  65. package/dist/order/index.d.ts +27 -0
  66. package/dist/order/index.js +90 -0
  67. package/dist/persistence/atomic-write.d.ts +10 -0
  68. package/dist/persistence/atomic-write.js +65 -0
  69. package/dist/persistence/load-json-file.d.ts +9 -0
  70. package/dist/persistence/load-json-file.js +23 -0
  71. package/dist/restart.d.ts +24 -0
  72. package/dist/restart.js +73 -0
  73. package/dist/router/card-helpers.d.ts +28 -0
  74. package/dist/router/card-helpers.js +65 -0
  75. package/dist/router/config/claude.d.ts +14 -0
  76. package/dist/router/config/claude.js +72 -0
  77. package/dist/router/config/codex.d.ts +22 -0
  78. package/dist/router/config/codex.js +100 -0
  79. package/dist/router/config/common/render.d.ts +16 -0
  80. package/dist/router/config/common/render.js +158 -0
  81. package/dist/router/config/index.d.ts +18 -0
  82. package/dist/router/config/index.js +38 -0
  83. package/dist/router/config/kimi.d.ts +21 -0
  84. package/dist/router/config/kimi.js +63 -0
  85. package/dist/router/config/opencode.d.ts +12 -0
  86. package/dist/router/config/opencode.js +44 -0
  87. package/dist/router/config/pi.d.ts +12 -0
  88. package/dist/router/config/pi.js +50 -0
  89. package/dist/router/config/types.d.ts +43 -0
  90. package/dist/router/config/types.js +8 -0
  91. package/dist/router/index.d.ts +318 -0
  92. package/dist/router/index.js +2715 -0
  93. package/dist/router/order-exec-dispatch.d.ts +30 -0
  94. package/dist/router/order-exec-dispatch.js +48 -0
  95. package/dist/router/utils.d.ts +44 -0
  96. package/dist/router/utils.js +116 -0
  97. package/dist/runner/bash/index.d.ts +4 -0
  98. package/dist/runner/bash/index.js +5 -0
  99. package/dist/runner/bash/runner.d.ts +55 -0
  100. package/dist/runner/bash/runner.js +213 -0
  101. package/dist/runner/claude/index.d.ts +4 -0
  102. package/dist/runner/claude/index.js +5 -0
  103. package/dist/runner/claude/runner.d.ts +29 -0
  104. package/dist/runner/claude/runner.js +83 -0
  105. package/dist/runner/codex/argv.d.ts +33 -0
  106. package/dist/runner/codex/argv.js +40 -0
  107. package/dist/runner/codex/index.d.ts +1 -0
  108. package/dist/runner/codex/index.js +2 -0
  109. package/dist/runner/codex/jsonl.d.ts +37 -0
  110. package/dist/runner/codex/jsonl.js +221 -0
  111. package/dist/runner/codex/runner.d.ts +65 -0
  112. package/dist/runner/codex/runner.js +124 -0
  113. package/dist/runner/common/exec-translator.d.ts +73 -0
  114. package/dist/runner/common/exec-translator.js +105 -0
  115. package/dist/runner/common/jsonl-stream.d.ts +39 -0
  116. package/dist/runner/common/jsonl-stream.js +203 -0
  117. package/dist/runner/common/process-stopper.d.ts +28 -0
  118. package/dist/runner/common/process-stopper.js +79 -0
  119. package/dist/runner/common/runner-utils.d.ts +21 -0
  120. package/dist/runner/common/runner-utils.js +65 -0
  121. package/dist/runner/common/spawn-heartbeat.d.ts +33 -0
  122. package/dist/runner/common/spawn-heartbeat.js +51 -0
  123. package/dist/runner/common/spawning-runner.d.ts +321 -0
  124. package/dist/runner/common/spawning-runner.js +634 -0
  125. package/dist/runner/common/usage.d.ts +31 -0
  126. package/dist/runner/common/usage.js +29 -0
  127. package/dist/runner/index.d.ts +19 -0
  128. package/dist/runner/index.js +24 -0
  129. package/dist/runner/kimi/index.d.ts +1 -0
  130. package/dist/runner/kimi/index.js +2 -0
  131. package/dist/runner/kimi/jsonl.d.ts +28 -0
  132. package/dist/runner/kimi/jsonl.js +143 -0
  133. package/dist/runner/kimi/runner.d.ts +50 -0
  134. package/dist/runner/kimi/runner.js +105 -0
  135. package/dist/runner/opencode/argv.d.ts +32 -0
  136. package/dist/runner/opencode/argv.js +35 -0
  137. package/dist/runner/opencode/index.d.ts +1 -0
  138. package/dist/runner/opencode/index.js +2 -0
  139. package/dist/runner/opencode/jsonl.d.ts +68 -0
  140. package/dist/runner/opencode/jsonl.js +295 -0
  141. package/dist/runner/opencode/runner.d.ts +63 -0
  142. package/dist/runner/opencode/runner.js +93 -0
  143. package/dist/runner/pi/index.d.ts +1 -0
  144. package/dist/runner/pi/index.js +2 -0
  145. package/dist/runner/pi/jsonl.d.ts +128 -0
  146. package/dist/runner/pi/jsonl.js +249 -0
  147. package/dist/runner/pi/runner.d.ts +37 -0
  148. package/dist/runner/pi/runner.js +76 -0
  149. package/dist/runner/registry.d.ts +54 -0
  150. package/dist/runner/registry.js +75 -0
  151. package/dist/runner/resolve-agent-choices.d.ts +7 -0
  152. package/dist/runner/resolve-agent-choices.js +76 -0
  153. package/dist/runner/sync-agent-choices.d.ts +6 -0
  154. package/dist/runner/sync-agent-choices.js +73 -0
  155. package/dist/runner/types.d.ts +250 -0
  156. package/dist/runner/types.js +8 -0
  157. package/dist/session/claude/index.d.ts +1 -0
  158. package/dist/session/claude/index.js +2 -0
  159. package/dist/session/claude/session-reader.d.ts +34 -0
  160. package/dist/session/claude/session-reader.js +46 -0
  161. package/dist/session/claude/sessions.d.ts +53 -0
  162. package/dist/session/claude/sessions.js +528 -0
  163. package/dist/session/codex/index.d.ts +1 -0
  164. package/dist/session/codex/index.js +2 -0
  165. package/dist/session/codex/rollout-reader.d.ts +88 -0
  166. package/dist/session/codex/rollout-reader.js +473 -0
  167. package/dist/session/codex/sessions.d.ts +21 -0
  168. package/dist/session/codex/sessions.js +40 -0
  169. package/dist/session/common/catch-up.d.ts +12 -0
  170. package/dist/session/common/catch-up.js +31 -0
  171. package/dist/session/common/constants.d.ts +11 -0
  172. package/dist/session/common/constants.js +12 -0
  173. package/dist/session/common/content-blocks.d.ts +37 -0
  174. package/dist/session/common/content-blocks.js +68 -0
  175. package/dist/session/common/jsonl.d.ts +77 -0
  176. package/dist/session/common/jsonl.js +351 -0
  177. package/dist/session/common/usage-accumulator.d.ts +58 -0
  178. package/dist/session/common/usage-accumulator.js +72 -0
  179. package/dist/session/index.d.ts +7 -0
  180. package/dist/session/index.js +11 -0
  181. package/dist/session/kimi/index.d.ts +1 -0
  182. package/dist/session/kimi/index.js +2 -0
  183. package/dist/session/kimi/sessions.d.ts +81 -0
  184. package/dist/session/kimi/sessions.js +527 -0
  185. package/dist/session/opencode/index.d.ts +1 -0
  186. package/dist/session/opencode/index.js +2 -0
  187. package/dist/session/opencode/sessions.d.ts +81 -0
  188. package/dist/session/opencode/sessions.js +388 -0
  189. package/dist/session/pi/index.d.ts +1 -0
  190. package/dist/session/pi/index.js +2 -0
  191. package/dist/session/pi/sessions.d.ts +27 -0
  192. package/dist/session/pi/sessions.js +399 -0
  193. package/dist/session/registry.d.ts +12 -0
  194. package/dist/session/registry.js +20 -0
  195. package/dist/session/session-store.d.ts +86 -0
  196. package/dist/session/session-store.js +450 -0
  197. package/dist/startup-contact.d.ts +18 -0
  198. package/dist/startup-contact.js +51 -0
  199. package/dist/workspace/index.d.ts +19 -0
  200. package/dist/workspace/index.js +53 -0
  201. 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,2 @@
1
+ export { CodexSessionReader } from './sessions.js';
2
+ //# sourceMappingURL=index.js.map
@@ -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 {};