vibe-usage-cli 0.0.0-stage → 0.1.1

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 (114) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +275 -2
  3. package/build/cli.js +197 -0
  4. package/build/core/claude-processes.d.ts +51 -0
  5. package/build/core/claude-processes.js +288 -0
  6. package/build/core/claude-profile.d.ts +2 -0
  7. package/build/core/claude-profile.js +146 -0
  8. package/build/core/claude-renew.d.ts +12 -0
  9. package/build/core/claude-renew.js +79 -0
  10. package/build/core/continuity.d.ts +90 -0
  11. package/build/core/continuity.js +1244 -0
  12. package/build/core/diagnostics.d.ts +25 -0
  13. package/build/core/diagnostics.js +229 -0
  14. package/build/core/environment.d.ts +12 -0
  15. package/build/core/environment.js +25 -0
  16. package/build/core/errors.d.ts +23 -0
  17. package/build/core/errors.js +48 -0
  18. package/build/core/files.d.ts +20 -0
  19. package/build/core/files.js +136 -0
  20. package/build/core/glance.d.ts +25 -0
  21. package/build/core/glance.js +107 -0
  22. package/build/core/handover.d.ts +61 -0
  23. package/build/core/handover.js +333 -0
  24. package/build/core/history.d.ts +19 -0
  25. package/build/core/history.js +134 -0
  26. package/build/core/launch.d.ts +78 -0
  27. package/build/core/launch.js +256 -0
  28. package/build/core/measurements.d.ts +11 -0
  29. package/build/core/measurements.js +75 -0
  30. package/build/core/model-families.d.ts +12 -0
  31. package/build/core/model-families.js +61 -0
  32. package/build/core/model.d.ts +32 -0
  33. package/build/core/model.js +86 -0
  34. package/build/core/models.d.ts +25 -0
  35. package/build/core/models.js +164 -0
  36. package/build/core/redact.d.ts +2 -0
  37. package/build/core/redact.js +64 -0
  38. package/build/core/selection.d.ts +65 -0
  39. package/build/core/selection.js +173 -0
  40. package/build/core/service.d.ts +35 -0
  41. package/build/core/service.js +484 -0
  42. package/build/core/sessions.d.ts +87 -0
  43. package/build/core/sessions.js +868 -0
  44. package/build/core/settings.d.ts +16 -0
  45. package/build/core/settings.js +377 -0
  46. package/build/core/tray-icon.d.ts +5 -0
  47. package/build/core/tray-icon.js +91 -0
  48. package/build/core/updater.d.ts +29 -0
  49. package/build/core/updater.js +88 -0
  50. package/build/core/updates.d.ts +5 -0
  51. package/build/core/updates.js +29 -0
  52. package/build/launchers/discover-antigravity.ps1 +68 -0
  53. package/build/launchers/terminal.ps1 +78 -0
  54. package/build/main/main.js +587 -0
  55. package/build/providers/antigravity.d.ts +48 -0
  56. package/build/providers/antigravity.js +463 -0
  57. package/build/providers/claude-keychain.d.ts +13 -0
  58. package/build/providers/claude-keychain.js +42 -0
  59. package/build/providers/claude.d.ts +33 -0
  60. package/build/providers/claude.js +218 -0
  61. package/build/providers/codex.d.ts +21 -0
  62. package/build/providers/codex.js +221 -0
  63. package/build/providers/common.d.ts +63 -0
  64. package/build/providers/common.js +294 -0
  65. package/build/providers/cursor.d.ts +14 -0
  66. package/build/providers/cursor.js +141 -0
  67. package/build/providers/profiles.d.ts +10 -0
  68. package/build/providers/profiles.js +69 -0
  69. package/build/providers/tls-trust.d.ts +7 -0
  70. package/build/providers/tls-trust.js +29 -0
  71. package/build/providers/types.d.ts +19 -0
  72. package/build/providers/types.js +1 -0
  73. package/build/shared/account-state.d.ts +19 -0
  74. package/build/shared/account-state.js +75 -0
  75. package/build/shared/catalog.d.ts +85 -0
  76. package/build/shared/catalog.js +125 -0
  77. package/build/shared/format.d.ts +40 -0
  78. package/build/shared/format.js +134 -0
  79. package/build/shared/i18n/index.d.ts +12 -0
  80. package/build/shared/i18n/index.js +40 -0
  81. package/build/shared/i18n/locales/de.d.ts +2 -0
  82. package/build/shared/i18n/locales/de.js +328 -0
  83. package/build/shared/i18n/locales/en.d.ts +328 -0
  84. package/build/shared/i18n/locales/en.js +330 -0
  85. package/build/shared/i18n/locales/es.d.ts +2 -0
  86. package/build/shared/i18n/locales/es.js +328 -0
  87. package/build/shared/i18n/locales/fr.d.ts +2 -0
  88. package/build/shared/i18n/locales/fr.js +328 -0
  89. package/build/shared/i18n/locales/it.d.ts +2 -0
  90. package/build/shared/i18n/locales/it.js +327 -0
  91. package/build/shared/i18n/locales/ja.d.ts +2 -0
  92. package/build/shared/i18n/locales/ja.js +322 -0
  93. package/build/shared/i18n/locales/ko.d.ts +2 -0
  94. package/build/shared/i18n/locales/ko.js +322 -0
  95. package/build/shared/i18n/locales/nl.d.ts +2 -0
  96. package/build/shared/i18n/locales/nl.js +327 -0
  97. package/build/shared/i18n/locales/pl.d.ts +2 -0
  98. package/build/shared/i18n/locales/pl.js +332 -0
  99. package/build/shared/i18n/locales/pt.d.ts +2 -0
  100. package/build/shared/i18n/locales/pt.js +328 -0
  101. package/build/shared/i18n/locales/ru.d.ts +2 -0
  102. package/build/shared/i18n/locales/ru.js +331 -0
  103. package/build/shared/i18n/locales/tr.d.ts +2 -0
  104. package/build/shared/i18n/locales/tr.js +322 -0
  105. package/build/shared/i18n/locales/zh.d.ts +2 -0
  106. package/build/shared/i18n/locales/zh.js +322 -0
  107. package/build/shared/i18n/messages.d.ts +10 -0
  108. package/build/shared/i18n/messages.js +1 -0
  109. package/build/shared/ipc.d.ts +138 -0
  110. package/build/shared/ipc.js +2 -0
  111. package/build/shared/tsconfig.tsbuildinfo +1 -0
  112. package/build/shared/types.d.ts +205 -0
  113. package/build/shared/types.js +1 -0
  114. package/package.json +144 -4
@@ -0,0 +1,868 @@
1
+ // Per-provider discovery and reading of local agent sessions for a project directory. Every
2
+ // provider is filtered by the session's recorded working directory, so a session only ever
3
+ // appears under the project it was started in. Formats were verified against real local
4
+ // data (Claude Code JSONL, Codex rollout JSONL, Cursor CLI store.db, Antigravity
5
+ // conversations/*.db + brain transcript); unknown fields are left empty instead of guessed.
6
+ import fs from 'node:fs';
7
+ import fsp from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { DatabaseSync } from 'node:sqlite';
10
+ import { pathToFileURL } from 'node:url';
11
+ import zlib from 'node:zlib';
12
+ import { CLAUDE_FILES, SYNTHETIC_MODEL } from "../providers/claude.js";
13
+ import { CODEX_SESSIONS_DIR } from "../providers/codex.js";
14
+ import { accountProfileDirs } from "../providers/profiles.js";
15
+ import { AUTO, CLAUDE_PERMISSION_MODES, isOneOf, PROVIDER_IDS } from "../shared/catalog.js";
16
+ import { attempt, attemptSync, field, isObject, JSON_LINES_EXTENSION, jsonLines, newestFiles, readHeadSync, readJsonSync, readTailSync, samePath } from "./files.js";
17
+ import { redactSecrets } from "./redact.js";
18
+ export function sessionSources(homeDir, accounts = []) {
19
+ return {
20
+ homeDir,
21
+ profiles: { claude: accountProfileDirs('claude', homeDir, accounts), codex: accountProfileDirs('codex', homeDir, accounts) }
22
+ };
23
+ }
24
+ // The recent conversation is bounded by turns, by turn length and in total.
25
+ export const RECENT_TURNS = { count: 12, turnChars: 1500, totalChars: 12000 };
26
+ const ELISION = '\n[…]\n';
27
+ const SYSTEM_REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/gi;
28
+ // Secrets are removed from the whole message, before any clipping could cut one in half.
29
+ function sanitizeText(text) {
30
+ return redactSecrets(text.replace(SYSTEM_REMINDER, '')).trim();
31
+ }
32
+ // Long turns keep their beginning (the request) and their end (the conclusion).
33
+ function elide(text, limit) {
34
+ if (text.length <= limit)
35
+ return text;
36
+ const half = Math.floor((limit - ELISION.length) / 2);
37
+ return `${text.slice(0, half)}${ELISION}${text.slice(-half)}`;
38
+ }
39
+ // A folder a record names, compared with `b`.
40
+ const namesPath = (a, b) => typeof a === 'string' && a !== '' && samePath(a, b);
41
+ const text = (value) => (typeof value === 'string' ? value : '');
42
+ const TITLE_MAX_LENGTH = 70;
43
+ const TITLE_MIN_LENGTH = 4;
44
+ const RECENT_FILES = 10;
45
+ // Lines that open a prompt but say nothing about it: markup, mentions, rules, code fences.
46
+ const NON_TITLE_PREFIXES = ['<', '@', '---', '```'];
47
+ // First meaningful line of the first prompt, without Markdown heading or list markers.
48
+ function titleFrom(prompt) {
49
+ const line = prompt
50
+ .split('\n')
51
+ .map(x => x.trim())
52
+ .find(x => x.length >= TITLE_MIN_LENGTH && !NON_TITLE_PREFIXES.some(prefix => x.startsWith(prefix)));
53
+ return line ? line.replace(/^[#\-*\s]+/, '').slice(0, TITLE_MAX_LENGTH) : '';
54
+ }
55
+ // A session list item, or null for sessions without a single user prompt. An empty title is
56
+ // worded by the UI ("untitled session").
57
+ function listEntry(transcript, { updatedAt, sizeBytes, title }) {
58
+ if (!transcript.turnCount)
59
+ return null;
60
+ return {
61
+ id: transcript.sessionId,
62
+ providerId: transcript.providerId,
63
+ title: title || transcript.sessionTitle,
64
+ updatedAt,
65
+ sizeBytes,
66
+ turnCount: transcript.turnCount,
67
+ model: transcript.sessionModel
68
+ };
69
+ }
70
+ const openDb = (file) => new DatabaseSync(file, { readOnly: true });
71
+ function withDb(file, task) {
72
+ const db = openDb(file);
73
+ try {
74
+ return task(db);
75
+ }
76
+ finally {
77
+ db.close();
78
+ }
79
+ }
80
+ const bytes = (value) => (value instanceof Uint8Array ? Buffer.from(value) : null);
81
+ function emptyTranscript(providerId, sessionId) {
82
+ return {
83
+ providerId,
84
+ sessionId,
85
+ sessionTitle: '',
86
+ sessionModel: '',
87
+ initialGoal: '',
88
+ lastUserMsg: '',
89
+ lastAssistantMsg: '',
90
+ turnCount: 0,
91
+ files: [],
92
+ recent: []
93
+ };
94
+ }
95
+ // Appends to the recent conversation, dropping the oldest turns beyond its bounds.
96
+ function remember({ recent }, role, message) {
97
+ const last = recent.at(-1);
98
+ if (last?.role === role)
99
+ last.text = elide(`${last.text}\n\n${message}`, RECENT_TURNS.turnChars);
100
+ else
101
+ recent.push({ role, text: elide(message, RECENT_TURNS.turnChars) });
102
+ let total = recent.reduce((sum, turn) => sum + turn.text.length, 0);
103
+ while (recent.length > RECENT_TURNS.count || (recent.length > 1 && total > RECENT_TURNS.totalChars))
104
+ total -= recent.shift()?.text.length ?? 0;
105
+ }
106
+ function addUser(transcript, message) {
107
+ const clean = sanitizeText(message);
108
+ if (!clean)
109
+ return;
110
+ transcript.turnCount++;
111
+ if (!transcript.initialGoal)
112
+ transcript.initialGoal = clean;
113
+ if (!transcript.sessionTitle)
114
+ transcript.sessionTitle = titleFrom(clean);
115
+ transcript.lastUserMsg = clean;
116
+ remember(transcript, 'user', clean);
117
+ }
118
+ function addAssistant(transcript, message) {
119
+ const clean = sanitizeText(message);
120
+ if (!clean)
121
+ return;
122
+ transcript.lastAssistantMsg = clean;
123
+ remember(transcript, 'assistant', clean);
124
+ }
125
+ // Text parts of a content array whose part types are listed; a plain string as is.
126
+ function contentText(content, types) {
127
+ if (typeof content === 'string')
128
+ return content;
129
+ if (!Array.isArray(content))
130
+ return '';
131
+ return content.map(part => (types.includes(String(field(part, 'type'))) ? text(field(part, 'text')) : '')).join('\n');
132
+ }
133
+ // ---------------------------------------------------------------- Claude Code
134
+ function claudeProjectDirs(projectDir, { profiles }) {
135
+ const resolved = path.resolve(projectDir);
136
+ const slugs = new Set([resolved.replace(/[/\\:]/g, '-'), resolved.replace(/[^a-zA-Z0-9]/g, '-')]);
137
+ return profiles.claude
138
+ .flatMap(root => [...slugs].map(slug => path.join(root, CLAUDE_FILES.projects, slug)))
139
+ .filter(dir => fs.existsSync(dir));
140
+ }
141
+ const CLAUDE_TEXT_PARTS = ['text'];
142
+ function claudeFilePath(record) {
143
+ const content = field(record.message, 'content');
144
+ const edited = Array.isArray(content) ? content.map(part => field(field(part, 'input'), 'file_path')).find(Boolean) : undefined;
145
+ return field(record.toolUseResult, 'filePath') ?? edited;
146
+ }
147
+ // Parsed results per file, reused until the file changes.
148
+ function mtimeMemo() {
149
+ const memo = new Map();
150
+ return async (file, compute) => {
151
+ const { mtimeMs } = await fsp.stat(file);
152
+ const hit = memo.get(file);
153
+ if (hit?.mtimeMs === mtimeMs)
154
+ return hit.value;
155
+ const value = await compute();
156
+ memo.set(file, { mtimeMs, value });
157
+ return value;
158
+ };
159
+ }
160
+ // Listing parses every transcript of the project, which can be hundreds of megabytes.
161
+ const claudeMemo = mtimeMemo();
162
+ const readClaudeFile = (file, id) => claudeMemo(file, () => parseClaudeFile(file, id));
163
+ async function parseClaudeFile(file, id) {
164
+ const transcript = emptyTranscript('claude', id);
165
+ const files = new Set();
166
+ let aiTitle = '';
167
+ for (const record of jsonLines(await fsp.readFile(file, 'utf8'))) {
168
+ const { message } = record;
169
+ const content = field(message, 'content');
170
+ const model = field(message, 'model');
171
+ if (record.type === 'ai-title' && typeof record.aiTitle === 'string')
172
+ aiTitle = record.aiTitle;
173
+ // Messages Claude Code generates itself (API errors, limit notices) are not the model's.
174
+ const synthetic = Boolean(record.isApiErrorMessage) || model === SYNTHETIC_MODEL;
175
+ if (typeof model === 'string' && !synthetic)
176
+ transcript.sessionModel = model;
177
+ // Tool results are user-role records too; only real prompts count.
178
+ const toolResults = Array.isArray(content) && content.every(part => field(part, 'type') === 'tool_result');
179
+ // Older versions kept subagent threads (sidechains) in the same file; they are not the
180
+ // conversation with the user.
181
+ const mainThread = content && !record.isSidechain;
182
+ if (record.type === 'user' && mainThread && !record.isMeta && !toolResults)
183
+ addUser(transcript, contentText(content, CLAUDE_TEXT_PARTS));
184
+ if (record.type === 'assistant' && mainThread && !synthetic)
185
+ addAssistant(transcript, contentText(content, CLAUDE_TEXT_PARTS));
186
+ const edited = claudeFilePath(record);
187
+ if (typeof edited === 'string')
188
+ files.add(edited);
189
+ }
190
+ if (aiTitle)
191
+ transcript.sessionTitle = aiTitle;
192
+ transcript.files = [...files].slice(-RECENT_FILES);
193
+ return transcript;
194
+ }
195
+ async function listClaude(projectDir, sources) {
196
+ const sessions = [];
197
+ for (const dir of claudeProjectDirs(projectDir, sources)) {
198
+ for (const name of await fsp.readdir(dir).catch(() => [])) {
199
+ if (!name.endsWith(JSON_LINES_EXTENSION))
200
+ continue;
201
+ const file = path.join(dir, name);
202
+ sessions.push(await attempt(async () => {
203
+ const stat = await fsp.stat(file);
204
+ return listEntry(await readClaudeFile(file, path.basename(name, JSON_LINES_EXTENSION)), {
205
+ updatedAt: stat.mtimeMs,
206
+ sizeBytes: stat.size
207
+ });
208
+ }));
209
+ }
210
+ }
211
+ return sessions;
212
+ }
213
+ async function readClaude(projectDir, sources, id) {
214
+ for (const dir of claudeProjectDirs(projectDir, sources)) {
215
+ const file = path.join(dir, `${id}${JSON_LINES_EXTENSION}`);
216
+ if (fs.existsSync(file))
217
+ return readClaudeFile(file, id);
218
+ }
219
+ return null;
220
+ }
221
+ // ---------------------------------------------------------------------- Codex
222
+ const ROLLOUT_FILE = /^rollout-.*\.jsonl(\.zst)?$/;
223
+ // A session's own rollout is named after its id; fork snapshots append `_<childId>`.
224
+ const OWN_ROLLOUT = /^rollout-.*-([0-9a-f-]{36})\.jsonl(\.zst)?$/i;
225
+ const isCompressed = (file) => file.endsWith('.zst');
226
+ // Codex stores recent rollouts as .jsonl and compresses older ones to .jsonl.zst.
227
+ function walkRollouts(dir) {
228
+ const entries = attemptSync(() => fs.readdirSync(dir, { withFileTypes: true })) ?? [];
229
+ return entries.flatMap(entry => {
230
+ const full = path.join(dir, entry.name);
231
+ if (entry.isDirectory())
232
+ return walkRollouts(full);
233
+ return entry.isFile() && ROLLOUT_FILE.test(entry.name) ? [full] : [];
234
+ });
235
+ }
236
+ const MAX_META_LINE_BYTES = 1024 * 1024;
237
+ // The first line of a rollout (its session_meta record), streamed so that a large or
238
+ // compressed file is never read whole. Resolves to '' when there is no complete line.
239
+ function readFirstLine(file) {
240
+ return new Promise(resolve => {
241
+ const input = fs.createReadStream(file);
242
+ const stream = isCompressed(file) ? input.pipe(zlib.createZstdDecompress()) : input;
243
+ const chunks = [];
244
+ let size = 0;
245
+ const finish = (line) => {
246
+ input.destroy();
247
+ stream.destroy();
248
+ resolve(line);
249
+ };
250
+ stream.on('data', (chunk) => {
251
+ const newline = chunk.indexOf(0x0a);
252
+ chunks.push(newline === -1 ? chunk : chunk.subarray(0, newline));
253
+ size += chunk.length;
254
+ if (newline !== -1)
255
+ finish(Buffer.concat(chunks).toString('utf8'));
256
+ else if (size > MAX_META_LINE_BYTES)
257
+ finish('');
258
+ });
259
+ stream.on('end', () => {
260
+ finish('');
261
+ });
262
+ stream.on('error', () => {
263
+ finish('');
264
+ });
265
+ input.on('error', () => {
266
+ finish('');
267
+ });
268
+ });
269
+ }
270
+ async function readRollout(file) {
271
+ const raw = await fsp.readFile(file);
272
+ return (isCompressed(file) ? zlib.zstdDecompressSync(raw) : raw).toString('utf8');
273
+ }
274
+ // The first record is session_meta. Subagent threads (Guardian reviews, spawned workers) have
275
+ // an object `source` and are skipped.
276
+ const codexMetaMemo = mtimeMemo();
277
+ function readCodexMeta(file) {
278
+ return codexMetaMemo(file, async () => {
279
+ const [record] = jsonLines(await readFirstLine(file));
280
+ const payload = record?.type === 'session_meta' ? record.payload : undefined;
281
+ const id = field(payload, 'id');
282
+ return typeof id === 'string'
283
+ ? {
284
+ id,
285
+ cwd: field(payload, 'cwd'),
286
+ subagent: Boolean(field(field(payload, 'source'), 'subagent')),
287
+ headless: field(payload, 'source') === 'exec'
288
+ }
289
+ : null;
290
+ });
291
+ }
292
+ const CODEX_TEXT_PARTS = ['input_text', 'output_text'];
293
+ // Injected context (AGENTS.md, environment, skills) is sent as user-role messages.
294
+ function isCodexInjected(message) {
295
+ const start = message.trimStart();
296
+ return start.startsWith('<') || start.startsWith('# AGENTS.md instructions');
297
+ }
298
+ async function readCodexFile(file, id) {
299
+ const transcript = emptyTranscript('codex', id);
300
+ for (const record of jsonLines(await readRollout(file))) {
301
+ const payload = isObject(record.payload) ? record.payload : {};
302
+ const settingsModel = field(payload.thread_settings, 'model');
303
+ if (record.type === 'turn_context' && typeof payload.model === 'string')
304
+ transcript.sessionModel = payload.model;
305
+ if (record.type === 'event_msg' && payload.type === 'thread_settings_applied' && typeof settingsModel === 'string')
306
+ transcript.sessionModel = settingsModel;
307
+ if (record.type === 'response_item' && payload.type === 'message') {
308
+ const message = Array.isArray(payload.content) ? contentText(payload.content, CODEX_TEXT_PARTS) : '';
309
+ if (payload.role === 'user' && !isCodexInjected(message))
310
+ addUser(transcript, message);
311
+ if (payload.role === 'assistant')
312
+ addAssistant(transcript, message);
313
+ }
314
+ }
315
+ return transcript;
316
+ }
317
+ function codexThreadNames({ profiles }) {
318
+ const names = new Map();
319
+ for (const root of profiles.codex) {
320
+ const index = attemptSync(() => fs.readFileSync(path.join(root, 'session_index.jsonl'), 'utf8')) ?? '';
321
+ for (const { id, thread_name: name } of jsonLines(index))
322
+ if (typeof id === 'string' && typeof name === 'string' && name)
323
+ names.set(id, name);
324
+ }
325
+ return names;
326
+ }
327
+ // Fork snapshots start with the parent's session_meta; a session's own rollout is preferred.
328
+ async function findCodexRollouts(projectDir, { profiles }) {
329
+ const byId = new Map();
330
+ for (const file of profiles.codex.flatMap(root => walkRollouts(path.join(root, CODEX_SESSIONS_DIR)))) {
331
+ const meta = await attempt(() => readCodexMeta(file));
332
+ if (!meta || meta.subagent || !namesPath(meta.cwd, projectDir))
333
+ continue;
334
+ const own = OWN_ROLLOUT.exec(path.basename(file))?.[1] === meta.id;
335
+ const previous = byId.get(meta.id);
336
+ if (!previous || (own && !previous.own))
337
+ byId.set(meta.id, { file, id: meta.id, own });
338
+ }
339
+ return [...byId.values()].map(({ file, id }) => ({ file, id }));
340
+ }
341
+ // Turn counts need a full parse; reuse it until the rollout changes.
342
+ const codexSummaryMemo = mtimeMemo();
343
+ async function listCodex(projectDir, sources) {
344
+ const names = codexThreadNames(sources);
345
+ const sessions = [];
346
+ for (const { file, id } of await findCodexRollouts(projectDir, sources)) {
347
+ sessions.push(await attempt(async () => {
348
+ const stat = await fsp.stat(file);
349
+ const transcript = await codexSummaryMemo(file, () => readCodexFile(file, id));
350
+ return listEntry(transcript, { updatedAt: stat.mtimeMs, sizeBytes: stat.size, title: names.get(id) });
351
+ }));
352
+ }
353
+ return sessions;
354
+ }
355
+ async function readCodex(projectDir, sources, id) {
356
+ const hit = (await findCodexRollouts(projectDir, sources)).find(rollout => rollout.id === id);
357
+ if (!hit)
358
+ return null;
359
+ const transcript = await readCodexFile(hit.file, id);
360
+ const name = codexThreadNames(sources).get(id);
361
+ if (name)
362
+ transcript.sessionTitle = name;
363
+ return transcript;
364
+ }
365
+ // ----------------------------------------------------------------- Cursor CLI
366
+ // ~/.cursor/chats/<hash>/<chatId>/{meta.json,store.db}. meta.json records cwd; store.db holds
367
+ // JSON message blobs ({"role":...}) in insertion (rowid) order.
368
+ const CURSOR_DEFAULT_CHAT_NAME = 'New Agent';
369
+ const JSON_OBJECT_START = 0x7b; // '{'; other blobs are internal tree nodes
370
+ const HEX = /^[0-9a-f]+$/i;
371
+ const USER_QUERY = /<user_query>\s*([\s\S]*?)\s*<\/user_query>/;
372
+ const CURSOR_TEXT_PARTS = ['text'];
373
+ const subdirectories = (dir) => (attemptSync(() => fs.readdirSync(dir, { withFileTypes: true })) ?? []).filter(entry => entry.isDirectory());
374
+ function cursorChatDirs(projectDir, homeDir) {
375
+ const root = path.join(homeDir, '.cursor', 'chats');
376
+ return subdirectories(root).flatMap(workspace => subdirectories(path.join(root, workspace.name)).flatMap(chat => {
377
+ const dir = path.join(root, workspace.name, chat.name);
378
+ const meta = readJsonSync(path.join(dir, 'meta.json'));
379
+ if (!isObject(meta) || meta.hasConversation === false || !namesPath(meta.cwd, projectDir))
380
+ return [];
381
+ return [{ id: chat.name, dir, updatedAtMs: meta.updatedAtMs }];
382
+ }));
383
+ }
384
+ function readCursorChat(dir, id) {
385
+ const transcript = emptyTranscript('cursor', id);
386
+ withDb(path.join(dir, 'store.db'), db => {
387
+ // The chat record is JSON, stored hex-encoded by current CLI versions.
388
+ const raw = String(db.prepare("SELECT value FROM meta WHERE key = '0'").get()?.value ?? '');
389
+ const chat = attemptSync(() => JSON.parse(HEX.test(raw) ? Buffer.from(raw, 'hex').toString('utf8') : raw));
390
+ const name = field(chat, 'name');
391
+ if (typeof name === 'string' && name && name !== CURSOR_DEFAULT_CHAT_NAME)
392
+ transcript.sessionTitle = name;
393
+ for (const row of db.prepare('SELECT data FROM blobs ORDER BY rowid').all()) {
394
+ const blob = bytes(row.data);
395
+ if (blob?.[0] !== JSON_OBJECT_START)
396
+ continue;
397
+ const message = attemptSync(() => JSON.parse(blob.toString('utf8')));
398
+ const content = contentText(field(message, 'content'), CURSOR_TEXT_PARTS);
399
+ const role = field(message, 'role');
400
+ const query = role === 'user' ? USER_QUERY.exec(content)?.[1] : undefined;
401
+ if (query !== undefined)
402
+ addUser(transcript, query);
403
+ if (role === 'assistant')
404
+ addAssistant(transcript, content);
405
+ }
406
+ });
407
+ return transcript;
408
+ }
409
+ function listCursor(projectDir, { homeDir }) {
410
+ return cursorChatDirs(projectDir, homeDir).map(({ id, dir, updatedAtMs }) => attemptSync(() => {
411
+ const stat = fs.statSync(path.join(dir, 'store.db'));
412
+ const updatedAt = typeof updatedAtMs === 'number' && updatedAtMs ? updatedAtMs : stat.mtimeMs;
413
+ return listEntry(readCursorChat(dir, id), { updatedAt, sizeBytes: stat.size });
414
+ }));
415
+ }
416
+ function readCursor(projectDir, { homeDir }, id) {
417
+ const hit = cursorChatDirs(projectDir, homeDir).find(chat => chat.id === id);
418
+ return hit ? readCursorChat(hit.dir, id) : null;
419
+ }
420
+ // ---------------------------------------------------------------- Antigravity
421
+ // conversations/<id>.db is SQLite; trajectory_metadata_blob carries the workspace as a
422
+ // file:// URI. The readable transcript lives in brain/<id>/.system_generated/logs.
423
+ const antigravityRoot = (homeDir) => path.join(homeDir, '.gemini', 'antigravity-ide');
424
+ const WINDOWS_DRIVE_URI = /^file:\/\/\/([A-Za-z]):(.*)$/;
425
+ const PROTOBUF_TAG_START = 0x20; // bytes below are control bytes: the next protobuf tag
426
+ const SLASH = 0x2f;
427
+ function workspaceUris(projectDir) {
428
+ const href = pathToFileURL(path.resolve(projectDir)).href.replace(/\/$/, '');
429
+ const uris = new Set([href]);
430
+ // VS Code-style URIs lower-case and percent-encode the Windows drive colon.
431
+ const [, drive, rest] = WINDOWS_DRIVE_URI.exec(href) ?? [];
432
+ if (drive) {
433
+ uris.add(`file:///${drive.toLowerCase()}%3A${rest}`);
434
+ uris.add(`file:///${drive.toLowerCase()}:${rest}`);
435
+ }
436
+ return [...uris].map(uri => Buffer.from(uri));
437
+ }
438
+ // The URI is protobuf-embedded; a match must not continue as a longer path segment.
439
+ function blobHasWorkspace(blob, uris) {
440
+ const endsAt = (at) => {
441
+ const next = blob[at];
442
+ return next === undefined || next < PROTOBUF_TAG_START;
443
+ };
444
+ return uris.some(uri => {
445
+ for (let at = blob.indexOf(uri); at !== -1; at = blob.indexOf(uri, at + 1)) {
446
+ const end = at + uri.length;
447
+ // The URI ends at a control byte, optionally after one '/'.
448
+ if (endsAt(end) || (blob[end] === SLASH && endsAt(end + 1)))
449
+ return true;
450
+ }
451
+ return false;
452
+ });
453
+ }
454
+ // Best effort: the latest generation record stores the model display name as a
455
+ // length-prefixed string under protobuf field 21 (tag 0xaa 0x01).
456
+ const MODEL_NAME_TAG = Buffer.from([0xaa, 0x01]);
457
+ const MODEL_NAME_MAX_LENGTH = 80;
458
+ const MODEL_NAME = /^(Gemini|Claude|GPT)[\w .()-]*$/;
459
+ function antigravityModel(db) {
460
+ const blob = bytes(attemptSync(() => db.prepare('SELECT data FROM gen_metadata ORDER BY idx DESC LIMIT 1').get())?.data);
461
+ if (!blob)
462
+ return '';
463
+ let found = '';
464
+ for (let i = blob.indexOf(MODEL_NAME_TAG); i !== -1; i = blob.indexOf(MODEL_NAME_TAG, i + 1)) {
465
+ const length = blob[i + MODEL_NAME_TAG.length];
466
+ if (!length || length > MODEL_NAME_MAX_LENGTH)
467
+ continue;
468
+ const start = i + MODEL_NAME_TAG.length + 1;
469
+ const name = blob.toString('utf8', start, start + length);
470
+ if (MODEL_NAME.test(name))
471
+ found = name;
472
+ }
473
+ return found;
474
+ }
475
+ function antigravityConversations(projectDir, homeDir) {
476
+ const dir = path.join(antigravityRoot(homeDir), 'conversations');
477
+ const uris = workspaceUris(projectDir);
478
+ return (attemptSync(() => fs.readdirSync(dir)) ?? [])
479
+ .filter(name => name.endsWith('.db'))
480
+ .flatMap(name => {
481
+ const file = path.join(dir, name);
482
+ const conversation = attemptSync(() => withDb(file, db => {
483
+ const rows = db.prepare('SELECT data FROM trajectory_metadata_blob').all();
484
+ const inProject = rows.some(row => {
485
+ const blob = bytes(row.data);
486
+ return blob !== null && blobHasWorkspace(blob, uris);
487
+ });
488
+ return inProject ? { id: path.basename(name, '.db'), file, model: antigravityModel(db) } : null;
489
+ }));
490
+ return conversation ? [conversation] : [];
491
+ });
492
+ }
493
+ const MARKUP = /<[^>]+>/g;
494
+ async function readAntigravityTranscript(homeDir, id, model) {
495
+ const transcript = emptyTranscript('antigravity', id);
496
+ transcript.sessionModel = model;
497
+ const file = path.join(antigravityRoot(homeDir), 'brain', id, '.system_generated', 'logs', 'transcript.jsonl');
498
+ for (const { type, content } of jsonLines(await fsp.readFile(file, 'utf8').catch(() => ''))) {
499
+ if (typeof content !== 'string')
500
+ continue;
501
+ if (type === 'USER_INPUT')
502
+ addUser(transcript, content.replace(MARKUP, ''));
503
+ if (type === 'PLANNER_RESPONSE')
504
+ addAssistant(transcript, content);
505
+ }
506
+ return transcript;
507
+ }
508
+ async function listAntigravity(projectDir, { homeDir }) {
509
+ const sessions = [];
510
+ for (const { id, file, model } of antigravityConversations(projectDir, homeDir)) {
511
+ sessions.push(await attempt(async () => {
512
+ const stat = await fsp.stat(file);
513
+ return listEntry(await readAntigravityTranscript(homeDir, id, model), { updatedAt: stat.mtimeMs, sizeBytes: stat.size });
514
+ }));
515
+ }
516
+ return sessions;
517
+ }
518
+ async function readAntigravity(projectDir, { homeDir }, id) {
519
+ const hit = antigravityConversations(projectDir, homeDir).find(conversation => conversation.id === id);
520
+ return hit ? readAntigravityTranscript(homeDir, id, hit.model) : null;
521
+ }
522
+ const matchesSession = (session, identity) => !identity || (samePath(session.projectDir, identity.projectDir) && session.sessionId === identity.sessionId);
523
+ // Claude Code writes the working directory into its records; it is near the top of the file.
524
+ const CWD_SCAN_BYTES = 256 * 1024;
525
+ const mtime = (file) => attemptSync(() => fs.statSync(file).mtimeMs) ?? 0;
526
+ const isFileSync = (file) => attemptSync(() => fs.statSync(file).isFile()) ?? false;
527
+ export async function recentSessionModel(session) {
528
+ // The model is in the last turns; a transcript can be hundreds of MB, so only its end is read.
529
+ if (session.providerId === 'claude')
530
+ return claudeSessionMode(session.file).model ?? '';
531
+ if (session.providerId === 'codex')
532
+ return (await readCodexFile(session.file, session.sessionId)).sessionModel;
533
+ return session.model ?? '';
534
+ }
535
+ // The folder a Claude Code session belongs to, as Claude Code itself attributes it: where it was
536
+ // relocated to (a worktree), else where it started. The `cwd` of later records follows the
537
+ // agent's shell and can point anywhere inside the project.
538
+ export function claudeProjectDir(file) {
539
+ const relocated = [...jsonLines(readTailSync(file, TAIL_SCAN_BYTES))]
540
+ .filter(record => record.type === 'relocated' && typeof record.relocatedCwd === 'string' && record.relocatedCwd)
541
+ .at(-1);
542
+ if (typeof relocated?.relocatedCwd === 'string')
543
+ return relocated.relocatedCwd;
544
+ const head = [...jsonLines(readHeadSync(file, CWD_SCAN_BYTES))].find(record => typeof record.cwd === 'string' && record.cwd);
545
+ return typeof head?.cwd === 'string' ? head.cwd : null;
546
+ }
547
+ export function latestClaudeSession(root, identity) {
548
+ const projects = path.join(root, CLAUDE_FILES.projects);
549
+ // A known session is found by its file name; otherwise each project's newest transcript is a
550
+ // candidate. Profiles can hold thousands of transcripts, so no others are read.
551
+ const files = subdirectories(projects)
552
+ .flatMap(dir => {
553
+ const folder = path.join(projects, dir.name);
554
+ if (!identity)
555
+ return newestFiles(folder, JSON_LINES_EXTENSION).slice(0, 1);
556
+ // An id is a file name, never a path.
557
+ if (path.basename(identity.sessionId) !== identity.sessionId)
558
+ return [];
559
+ return [path.join(folder, `${identity.sessionId}${JSON_LINES_EXTENSION}`)].filter(isFileSync);
560
+ })
561
+ .map(file => ({ file, updatedAt: mtime(file) }))
562
+ .sort((a, b) => b.updatedAt - a.updatedAt)
563
+ .map(entry => entry.file);
564
+ for (const file of files) {
565
+ const projectDir = claudeProjectDir(file);
566
+ if (!projectDir)
567
+ continue;
568
+ const session = {
569
+ providerId: 'claude',
570
+ sessionId: path.basename(file, JSON_LINES_EXTENSION),
571
+ projectDir,
572
+ updatedAt: mtime(file),
573
+ file
574
+ };
575
+ if (matchesSession(session, identity))
576
+ return session;
577
+ }
578
+ return null;
579
+ }
580
+ // A Claude Code session of the profile by its id alone, in whichever project folder it is kept
581
+ // (a worktree's sessions are kept under its repository's folder).
582
+ export function claudeSessionById(root, sessionId) {
583
+ // An id is a file name, never a path.
584
+ if (path.basename(sessionId) !== sessionId)
585
+ return null;
586
+ const projects = path.join(root, CLAUDE_FILES.projects);
587
+ const file = subdirectories(projects)
588
+ .map(dir => path.join(projects, dir.name, `${sessionId}${JSON_LINES_EXTENSION}`))
589
+ .filter(isFileSync)
590
+ .sort((a, b) => mtime(b) - mtime(a))[0];
591
+ const projectDir = file ? claudeProjectDir(file) : null;
592
+ return file && projectDir ? { providerId: 'claude', sessionId, projectDir, updatedAt: mtime(file), file } : null;
593
+ }
594
+ export async function latestCodexSession(root, identity) {
595
+ const files = walkRollouts(path.join(root, CODEX_SESSIONS_DIR))
596
+ .map(file => ({ file, updatedAt: mtime(file) }))
597
+ .sort((a, b) => b.updatedAt - a.updatedAt);
598
+ for (const { file, updatedAt } of files) {
599
+ // A rollout named `<id>_<other>` still names its session by its metadata.
600
+ const meta = await attempt(() => readCodexMeta(file));
601
+ if (!meta || meta.subagent || typeof meta.cwd !== 'string' || !meta.cwd)
602
+ continue;
603
+ const session = { providerId: 'codex', sessionId: meta.id, projectDir: meta.cwd, updatedAt, file };
604
+ if (matchesSession(session, identity))
605
+ return session;
606
+ }
607
+ return null;
608
+ }
609
+ export function latestCursorSession(homeDir, identity) {
610
+ const root = path.join(homeDir, '.cursor', 'chats');
611
+ const chats = subdirectories(root).flatMap(workspace => subdirectories(path.join(root, workspace.name)).flatMap(chat => {
612
+ const dir = path.join(root, workspace.name, chat.name);
613
+ const meta = readJsonSync(path.join(dir, 'meta.json'));
614
+ if (!isObject(meta) || meta.hasConversation === false || typeof meta.cwd !== 'string' || !meta.cwd)
615
+ return [];
616
+ const file = path.join(dir, 'store.db');
617
+ const updatedAt = typeof meta.updatedAtMs === 'number' && meta.updatedAtMs ? meta.updatedAtMs : mtime(file);
618
+ return [{ providerId: 'cursor', sessionId: chat.name, projectDir: meta.cwd, updatedAt, file }];
619
+ }));
620
+ return chats.filter(s => matchesSession(s, identity)).sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null;
621
+ }
622
+ const TAIL_SCAN_BYTES = 512 * 1024;
623
+ // Every usage-limit stop Claude Code writes carries this error (checked on 2026-10-19: all 73
624
+ // in this machine's transcripts, Claude Code 2.1.294 to 2.1.302); its wording is not read.
625
+ const CLAUDE_LIMIT_ERROR = 'usage_limit_reached';
626
+ const CODEX_LIMIT_ERROR = 'usage_limit_exceeded';
627
+ const timestamp = (value) => (typeof value === 'string' ? Date.parse(value) : NaN);
628
+ // Claude Code: the session's stop at the usage limit (a synthetic main-thread assistant record),
629
+ // standing until a reply came after it. A prompt alone is waiting, not going on: the user's,
630
+ // or a queued notice (a background task's) that Claude Code takes while it holds the session
631
+ // for the reset, hours before anything is answered. A reply that is another kind of error
632
+ // (overloaded, the network) clears the stop too: the session went on, and stopped on something
633
+ // else. `headless` marks a run started by a program (`claude -p`, the Agent SDK), not a person
634
+ // in a terminal.
635
+ export function claudeLimitStop(file) {
636
+ let stop = null;
637
+ for (const record of jsonLines(readTailSync(file, TAIL_SCAN_BYTES))) {
638
+ if (record.type !== 'assistant' || record.isSidechain || record.isMeta)
639
+ continue;
640
+ stop = record.isApiErrorMessage === true && record.apiError === CLAUDE_LIMIT_ERROR ? record : null;
641
+ }
642
+ const stoppedAt = timestamp(stop?.timestamp);
643
+ if (!stop || !Number.isFinite(stoppedAt))
644
+ return null;
645
+ const headless = typeof stop.entrypoint === 'string' && stop.entrypoint.startsWith('sdk-');
646
+ const cwd = claudeProjectDir(file);
647
+ return cwd ? { stoppedAt, cwd, headless } : null;
648
+ }
649
+ // How a Claude Code session was last running, so that it goes on the same way under another
650
+ // profile: the model it used and its permission mode. Only the end of the transcript is read.
651
+ export function claudeSessionMode(file) {
652
+ let model;
653
+ let permissionMode;
654
+ for (const record of jsonLines(readTailSync(file, TAIL_SCAN_BYTES))) {
655
+ if (record.isSidechain)
656
+ continue;
657
+ const used = field(record.message, 'model');
658
+ if (typeof used === 'string' && used !== SYNTHETIC_MODEL && !record.isApiErrorMessage)
659
+ model = used;
660
+ if (isOneOf(CLAUDE_PERMISSION_MODES, record.permissionMode))
661
+ permissionMode = record.permissionMode;
662
+ }
663
+ return { ...(model ? { model } : {}), ...(permissionMode ? { permissionMode } : {}) };
664
+ }
665
+ const TASK_OUTPUT = /Output is being written to: (.+?\.output)/;
666
+ const TASK_FINISHED = /<task-id>([^<]+)<\/task-id>/g;
667
+ // Tools that end a background command without a task notification, and the input naming it.
668
+ const TASK_STOPPERS = { TaskStop: 'task_id', KillShell: 'shell_id' };
669
+ // The message Claude Code adds when a background command finishes.
670
+ const TASK_NOTIFICATION = '<task-notification>';
671
+ // Only lines carrying one of these can start or end a background command, or name one that
672
+ // may be moved there; the rest of a transcript (most of it) is never parsed.
673
+ const TASK_MARKERS = ['"command":', 'backgroundTaskId', TASK_NOTIFICATION, ...Object.keys(TASK_STOPPERS).map(name => `"${name}"`)];
674
+ const MAX_TASK_SCANS = 50;
675
+ // Commands called but not yet answered, kept by tool use id; a result is not parsed unless it
676
+ // names a task, so the newest calls are kept and the rest let go.
677
+ const MAX_PENDING_COMMANDS = 64;
678
+ // Transcripts only grow, so each is read once and then from where it was left; one that
679
+ // shrank or was replaced is read again.
680
+ const taskScans = new Map();
681
+ function scanTaskLine(scan, line) {
682
+ if (!TASK_MARKERS.some(marker => line.includes(marker)))
683
+ return;
684
+ if (line.includes(TASK_NOTIFICATION))
685
+ for (const [, id] of line.matchAll(TASK_FINISHED))
686
+ if (id)
687
+ scan.finished.add(id);
688
+ const [record] = jsonLines(line);
689
+ if (!record || record.isSidechain)
690
+ return;
691
+ const content = field(record.message, 'content');
692
+ for (const part of Array.isArray(content) ? content : []) {
693
+ const type = field(part, 'type');
694
+ const input = field(part, 'input');
695
+ const stopper = TASK_STOPPERS[String(field(part, 'name'))];
696
+ const stopped = stopper ? field(input, stopper) : undefined;
697
+ if (type === 'tool_use' && typeof stopped === 'string')
698
+ scan.finished.add(stopped);
699
+ // Any command may end up in the background: asked for it, or moved there by Claude Code when
700
+ // it ran past its time. Its text is kept until its result says which.
701
+ const command = field(input, 'description') ?? field(input, 'command');
702
+ if (type === 'tool_use' && typeof field(input, 'command') === 'string' && typeof command === 'string') {
703
+ scan.commands.set(String(field(part, 'id')), command);
704
+ for (const oldest of [...scan.commands.keys()].slice(0, Math.max(0, scan.commands.size - MAX_PENDING_COMMANDS)))
705
+ scan.commands.delete(oldest);
706
+ }
707
+ const id = field(record.toolUseResult, 'backgroundTaskId');
708
+ if (type !== 'tool_result' || typeof id !== 'string')
709
+ continue;
710
+ const useId = String(field(part, 'tool_use_id'));
711
+ scan.started.set(id, {
712
+ id,
713
+ command: scan.commands.get(useId) ?? '',
714
+ outputFile: TASK_OUTPUT.exec(contentText(field(part, 'content'), CLAUDE_TEXT_PARTS))?.[1] ?? null,
715
+ startedAt: timestamp(record.timestamp)
716
+ });
717
+ scan.commands.delete(useId);
718
+ }
719
+ }
720
+ // Reads the bytes after `scan.size` up to the last complete line, a chunk at a time.
721
+ async function readNewLines(file, scan, size) {
722
+ let pending = [];
723
+ let consumed = scan.size;
724
+ // Without an encoding the stream yields buffers; its typings say `any`.
725
+ for await (const read of fs.createReadStream(file, { start: scan.size, end: size - 1 })) {
726
+ const chunk = read;
727
+ if (!Buffer.isBuffer(chunk))
728
+ continue;
729
+ const newline = chunk.lastIndexOf(0x0a);
730
+ if (newline < 0) {
731
+ pending.push(chunk);
732
+ continue;
733
+ }
734
+ const lines = Buffer.concat([...pending, chunk.subarray(0, newline + 1)]);
735
+ for (const line of lines.toString('utf8').split('\n'))
736
+ scanTaskLine(scan, line);
737
+ consumed += lines.length;
738
+ pending = [chunk.subarray(newline + 1)];
739
+ }
740
+ scan.size = consumed;
741
+ }
742
+ // The session's background commands still running as far as its transcript knows: started
743
+ // (the tool result names the task) and neither notified as finished nor stopped since. They
744
+ // belong to the process that started them, which alone is notified when they finish.
745
+ export async function claudeBackgroundTasks(file) {
746
+ const stat = await fsp.stat(file);
747
+ const identity = `${stat.ino}:${stat.birthtimeMs}`;
748
+ const known = taskScans.get(file);
749
+ const scan = known?.identity === identity && known.size <= stat.size
750
+ ? known
751
+ : { size: 0, identity, commands: new Map(), started: new Map(), finished: new Set() };
752
+ if (stat.size > scan.size)
753
+ await readNewLines(file, scan, stat.size);
754
+ taskScans.delete(file);
755
+ taskScans.set(file, scan);
756
+ for (const oldest of [...taskScans.keys()].slice(0, Math.max(0, taskScans.size - MAX_TASK_SCANS)))
757
+ taskScans.delete(oldest);
758
+ return [...scan.started.values()].filter(task => !scan.finished.has(task.id));
759
+ }
760
+ // When a Claude Code transcript was last written to, by its records' own times (a file's times
761
+ // can change without a write). NaN when it holds none.
762
+ export function claudeLastWrittenAt(file) {
763
+ let last = Number.NaN;
764
+ for (const record of jsonLines(readTailSync(file, TAIL_SCAN_BYTES))) {
765
+ const at = timestamp(record.timestamp);
766
+ if (Number.isFinite(at) && !(at <= last))
767
+ last = at;
768
+ }
769
+ return last;
770
+ }
771
+ const writeMemo = new Map();
772
+ export function claudeTranscriptWrite(file) {
773
+ const stat = attemptSync(() => fs.statSync(file));
774
+ if (!stat)
775
+ return null;
776
+ const hit = writeMemo.get(file);
777
+ if (hit?.size === stat.size && hit.mtimeMs === stat.mtimeMs)
778
+ return hit.value;
779
+ const value = { lastRecordAt: claudeLastWrittenAt(file), size: stat.size };
780
+ writeMemo.set(file, { size: stat.size, mtimeMs: stat.mtimeMs, value });
781
+ return value;
782
+ }
783
+ // Claude Code: the last main-thread message is the model's reply that ended its turn, so the
784
+ // session waits for the user.
785
+ export function claudeTurnEnded(file) {
786
+ const messages = [...jsonLines(readTailSync(file, TAIL_SCAN_BYTES))].filter(record => (record.type === 'user' || record.type === 'assistant') && !record.isSidechain && !record.isMeta);
787
+ const last = messages.at(-1);
788
+ return last?.type === 'assistant' && !last.isApiErrorMessage && field(last.message, 'stop_reason') === 'end_turn';
789
+ }
790
+ // Codex: the last turn completed with the usage-limit error, and no turn started since.
791
+ export function codexLimitStop(file) {
792
+ if (isCompressed(file))
793
+ return null;
794
+ let stoppedAt = null;
795
+ for (const record of jsonLines(readTailSync(file, TAIL_SCAN_BYTES))) {
796
+ const type = record.type === 'event_msg' ? field(record.payload, 'type') : undefined;
797
+ if (type === 'task_started')
798
+ stoppedAt = null;
799
+ if (type === 'task_complete')
800
+ stoppedAt = field(field(record.payload, 'error'), 'codex_error_info') === CODEX_LIMIT_ERROR ? timestamp(record.timestamp) : null;
801
+ }
802
+ return stoppedAt !== null && Number.isFinite(stoppedAt) ? { stoppedAt } : null;
803
+ }
804
+ // Sessions of a Claude Code profile written in the last `maxAgeMs` whose agent stopped at the
805
+ // usage limit, newest first. Headless runs are left to the program that started them: it sees
806
+ // the error itself, and opening each one in a terminal would bury the user's own sessions.
807
+ export function claudeLimitStops(root, now, maxAgeMs) {
808
+ const projects = path.join(root, CLAUDE_FILES.projects);
809
+ return subdirectories(projects)
810
+ .flatMap(dir => newestFiles(path.join(projects, dir.name), JSON_LINES_EXTENSION))
811
+ .map(file => ({ file, updatedAt: mtime(file) }))
812
+ .filter(({ updatedAt }) => now - updatedAt <= maxAgeMs)
813
+ .flatMap(({ file, updatedAt }) => {
814
+ const stop = claudeLimitStop(file);
815
+ return stop && !stop.headless
816
+ ? [
817
+ {
818
+ providerId: 'claude',
819
+ sessionId: path.basename(file, JSON_LINES_EXTENSION),
820
+ projectDir: stop.cwd,
821
+ updatedAt,
822
+ file,
823
+ stoppedAt: stop.stoppedAt
824
+ }
825
+ ]
826
+ : [];
827
+ })
828
+ .sort((a, b) => b.stoppedAt - a.stoppedAt);
829
+ }
830
+ export async function codexLimitStops(root, now, maxAgeMs) {
831
+ const stops = [];
832
+ for (const file of walkRollouts(path.join(root, CODEX_SESSIONS_DIR))) {
833
+ const updatedAt = mtime(file);
834
+ if (now - updatedAt > maxAgeMs)
835
+ continue;
836
+ const stop = codexLimitStop(file);
837
+ const meta = stop && (await attempt(() => readCodexMeta(file)));
838
+ if (!stop || !meta || meta.subagent || meta.headless || typeof meta.cwd !== 'string' || !meta.cwd)
839
+ continue;
840
+ stops.push({ providerId: 'codex', sessionId: meta.id, projectDir: meta.cwd, updatedAt, file, stoppedAt: stop.stoppedAt });
841
+ }
842
+ return stops.sort((a, b) => b.stoppedAt - a.stoppedAt);
843
+ }
844
+ const LISTERS = { claude: listClaude, codex: listCodex, cursor: listCursor, antigravity: listAntigravity };
845
+ const READERS = { claude: readClaude, codex: readCodex, cursor: readCursor, antigravity: readAntigravity };
846
+ const providersFor = (providerId) => (providerId === AUTO ? PROVIDER_IDS : [providerId]);
847
+ // Sessions of the project, newest first. A provider whose data cannot be read lists none.
848
+ export async function listProviderSessions(providerId, projectDir, sources) {
849
+ const results = await Promise.allSettled(providersFor(providerId).map(async (id) => LISTERS[id](projectDir, sources)));
850
+ return results
851
+ .flatMap(result => (result.status === 'fulfilled' ? result.value : []))
852
+ .filter(session => session !== null)
853
+ .sort((a, b) => b.updatedAt - a.updatedAt);
854
+ }
855
+ // One session; with sessionId 'auto' the newest session of the provider(s). Null when
856
+ // nothing matches: another provider's session is never substituted.
857
+ export async function readSession({ providerId, sessionId, projectDir, sources }) {
858
+ if (!sessionId || sessionId === AUTO) {
859
+ const [latest] = await listProviderSessions(providerId, projectDir, sources);
860
+ return latest ? readSession({ providerId: latest.providerId, sessionId: latest.id, projectDir, sources }) : null;
861
+ }
862
+ for (const id of providersFor(providerId)) {
863
+ const transcript = await attempt(async () => READERS[id](projectDir, sources, sessionId));
864
+ if (transcript)
865
+ return transcript;
866
+ }
867
+ return null;
868
+ }