@termfleet/core 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 (67) hide show
  1. package/dist/agent-launch.d.ts +78 -0
  2. package/dist/agent-launch.js +247 -0
  3. package/dist/agent-session-id.d.ts +10 -0
  4. package/dist/agent-session-id.js +36 -0
  5. package/dist/agent-session-index-client.d.ts +7 -0
  6. package/dist/agent-session-index-client.js +86 -0
  7. package/dist/agent-session-index-worker.d.ts +1 -0
  8. package/dist/agent-session-index-worker.js +20 -0
  9. package/dist/agent-session-index.d.ts +34 -0
  10. package/dist/agent-session-index.js +527 -0
  11. package/dist/agent-session-tail.d.ts +33 -0
  12. package/dist/agent-session-tail.js +184 -0
  13. package/dist/agent-session-watcher.d.ts +36 -0
  14. package/dist/agent-session-watcher.js +194 -0
  15. package/dist/agent-session.d.ts +380 -0
  16. package/dist/agent-session.js +1688 -0
  17. package/dist/background-runner.d.ts +3 -0
  18. package/dist/background-runner.js +55 -0
  19. package/dist/boot-queue.d.ts +35 -0
  20. package/dist/boot-queue.js +66 -0
  21. package/dist/build-info.d.ts +5 -0
  22. package/dist/build-info.js +38 -0
  23. package/dist/collab/canvas-doc.d.ts +47 -0
  24. package/dist/collab/canvas-doc.js +83 -0
  25. package/dist/contracts/auth.d.ts +77 -0
  26. package/dist/contracts/auth.js +1 -0
  27. package/dist/contracts/canvas.d.ts +34 -0
  28. package/dist/contracts/canvas.js +76 -0
  29. package/dist/contracts/console-layout.d.ts +39 -0
  30. package/dist/contracts/console-layout.js +135 -0
  31. package/dist/contracts/files.d.ts +38 -0
  32. package/dist/contracts/files.js +37 -0
  33. package/dist/contracts/provider-url.d.ts +3 -0
  34. package/dist/contracts/provider-url.js +49 -0
  35. package/dist/contracts/registry.d.ts +58 -0
  36. package/dist/contracts/registry.js +285 -0
  37. package/dist/launch-trace.d.ts +6 -0
  38. package/dist/launch-trace.js +33 -0
  39. package/dist/lib/errors.d.ts +1 -0
  40. package/dist/lib/errors.js +5 -0
  41. package/dist/lib/exec.d.ts +13 -0
  42. package/dist/lib/exec.js +134 -0
  43. package/dist/local-providers.d.ts +32 -0
  44. package/dist/local-providers.js +184 -0
  45. package/dist/local-tunnel.d.ts +6 -0
  46. package/dist/local-tunnel.js +258 -0
  47. package/dist/provider-access-token.d.ts +11 -0
  48. package/dist/provider-access-token.js +77 -0
  49. package/dist/provider-client.d.ts +152 -0
  50. package/dist/provider-client.js +666 -0
  51. package/dist/provider-url-resolver.d.ts +16 -0
  52. package/dist/provider-url-resolver.js +37 -0
  53. package/dist/registry-client.d.ts +93 -0
  54. package/dist/registry-client.js +170 -0
  55. package/dist/registry.d.ts +56 -0
  56. package/dist/registry.js +406 -0
  57. package/dist/session-attention.d.ts +24 -0
  58. package/dist/session-attention.js +54 -0
  59. package/dist/session-lifecycle.d.ts +83 -0
  60. package/dist/session-lifecycle.js +658 -0
  61. package/dist/session-window.d.ts +3 -0
  62. package/dist/session-window.js +20 -0
  63. package/dist/terminal-client.d.ts +49 -0
  64. package/dist/terminal-client.js +89 -0
  65. package/dist/types.d.ts +155 -0
  66. package/dist/types.js +21 -0
  67. package/package.json +26 -0
@@ -0,0 +1,527 @@
1
+ import { closeSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs";
2
+ import { basename, dirname, join, sep } from "node:path";
3
+ import { agentHome, geminiContentText, isCodexInjectedContext } from "./agent-session.js";
4
+ // Read enough of each end of a transcript to recover the first user message
5
+ // (title) and the last message (preview) without paging in multi-MB files.
6
+ const EDGE_BYTES = 64 * 1024;
7
+ // The directory walk + per-file stat is the bulk of the cost (thousands of
8
+ // files). Cache the sorted candidate list briefly so back-to-back list/paging
9
+ // requests don't re-walk — critical on a busy provider whose synchronous
10
+ // snapshot observation already saturates the event loop and would otherwise
11
+ // starve a from-scratch rebuild past the console proxy's timeout.
12
+ const CANDIDATE_TTL_MS = 20_000;
13
+ const indexCache = new Map();
14
+ const candidateCache = new Map();
15
+ export async function buildAgentSessionIndex(options = {}) {
16
+ const home = options.home ?? agentHome();
17
+ const limit = normalizeLimit(options.limit);
18
+ const offset = normalizeCursor(options.cursor);
19
+ const query = options.query?.trim().toLowerCase();
20
+ const candidates = candidatesFor(home, options.nowMs);
21
+ pruneCache(candidates);
22
+ // Search scans the full history (not just the page), matching parsed
23
+ // title/preview/cwd. It parses every candidate — bounded by the per-row
24
+ // mtime cache, so the first query is the only slow one — and runs in a
25
+ // worker thread, off the provider event loop.
26
+ if (query) {
27
+ const matched = [];
28
+ for (const candidate of candidates) {
29
+ const row = rowForCandidate(candidate);
30
+ if (row && rowMatchesQuery(row, query))
31
+ matched.push(row);
32
+ }
33
+ const page = matched.slice(offset, offset + limit);
34
+ const nextOffset = offset + page.length;
35
+ return {
36
+ nextCursor: nextOffset < matched.length ? String(nextOffset) : null,
37
+ rows: page,
38
+ total: matched.length
39
+ };
40
+ }
41
+ // Backfill from later candidates when one drops out (its transcript was reaped
42
+ // within the candidate cache's lifetime), so a page is never short while more
43
+ // rows exist — and advance the cursor by candidates CONSUMED, not by row count,
44
+ // so the next page resumes correctly past the skipped ones.
45
+ const rows = [];
46
+ let index = offset;
47
+ for (; index < candidates.length && rows.length < limit; index++) {
48
+ const candidate = candidates[index];
49
+ if (!candidate)
50
+ continue;
51
+ const row = rowForCandidate(candidate);
52
+ if (row)
53
+ rows.push(row);
54
+ }
55
+ return {
56
+ nextCursor: index < candidates.length ? String(index) : null,
57
+ rows,
58
+ total: candidates.length
59
+ };
60
+ }
61
+ function rowMatchesQuery(row, query) {
62
+ return row.title.toLowerCase().includes(query)
63
+ || (row.aiTitle ?? "").toLowerCase().includes(query)
64
+ || row.preview.toLowerCase().includes(query)
65
+ || (row.cwd ?? "").toLowerCase().includes(query);
66
+ }
67
+ // Warm the candidate + row caches off the request path (e.g. at provider
68
+ // startup), so the first browser request serves from cache instead of paying
69
+ // the full walk while the event loop is busy.
70
+ export async function warmAgentSessionIndex(options = {}) {
71
+ await buildAgentSessionIndex({ ...(options.home ? { home: options.home } : {}), limit: 1 });
72
+ }
73
+ function candidatesFor(home, nowMs) {
74
+ const now = nowMs ?? Date.now();
75
+ const cached = candidateCache.get(home);
76
+ if (cached && now - cached.builtAtMs < CANDIDATE_TTL_MS) {
77
+ return cached.candidates;
78
+ }
79
+ const candidates = collectCandidates(home);
80
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs || a.transcriptPath.localeCompare(b.transcriptPath));
81
+ candidateCache.set(home, { builtAtMs: now, candidates });
82
+ return candidates;
83
+ }
84
+ function collectCandidates(home) {
85
+ const candidates = [];
86
+ for (const transcriptPath of walkJsonl(join(home, ".claude", "projects"))) {
87
+ const mtimeMs = mtimeOf(transcriptPath);
88
+ if (mtimeMs !== undefined)
89
+ candidates.push({ mtimeMs, provider: "claude", transcriptPath });
90
+ }
91
+ for (const transcriptPath of walkJsonl(join(home, ".codex", "sessions"))) {
92
+ const mtimeMs = mtimeOf(transcriptPath);
93
+ if (mtimeMs !== undefined)
94
+ candidates.push({ mtimeMs, provider: "codex", transcriptPath });
95
+ }
96
+ // Gemini: one-file-per-session JSONL under ~/.gemini/tmp/<slug>/chats/. The
97
+ // per-project logs.json is a user-prompt log, not a transcript — walkJsonl skips
98
+ // it (only *.jsonl). See docs/architecture/gemini-sessions.md.
99
+ for (const transcriptPath of walkJsonl(join(home, ".gemini", "tmp"))) {
100
+ const mtimeMs = mtimeOf(transcriptPath);
101
+ if (mtimeMs !== undefined)
102
+ candidates.push({ mtimeMs, provider: "gemini", transcriptPath });
103
+ }
104
+ return candidates;
105
+ }
106
+ // mtime-keyed: a transcript is only re-read when its file changes, so steady
107
+ // state re-list calls just stat directories and reuse cached rows.
108
+ function rowForCandidate(candidate) {
109
+ const cached = indexCache.get(candidate.transcriptPath);
110
+ if (cached && cached.mtimeMs === candidate.mtimeMs) {
111
+ return cached.row;
112
+ }
113
+ const row = parseIndexRow(candidate);
114
+ if (row) {
115
+ indexCache.set(candidate.transcriptPath, { mtimeMs: candidate.mtimeMs, row });
116
+ }
117
+ return row;
118
+ }
119
+ function parseIndexRow(candidate) {
120
+ const edges = readEdges(candidate.transcriptPath);
121
+ if (!edges)
122
+ return undefined;
123
+ const { provider } = candidate;
124
+ const scan = provider === "codex"
125
+ ? scanCodexEdges(edges.head, edges.tail)
126
+ : provider === "gemini"
127
+ ? scanGeminiEdges(edges.head, edges.tail)
128
+ : scanClaudeEdges(edges.head, edges.tail);
129
+ // Claude transcript filenames are the session id, so recover it from the path
130
+ // when the head did not carry one. Codex/Gemini ids live inside the file (Gemini
131
+ // in its header). Gemini's cwd isn't in the transcript — resolve it from the
132
+ // tmp/<slug> path via projects.json.
133
+ const sessionId = scan.sessionId || (provider === "claude" ? basename(candidate.transcriptPath).replace(/\.jsonl$/, "") : "");
134
+ if (!sessionId)
135
+ return undefined;
136
+ const cwd = scan.cwd ?? (provider === "gemini" ? geminiCwdForPath(candidate.transcriptPath) : undefined);
137
+ const titleText = scan.title || scan.preview;
138
+ const titleKind = scan.title ? scan.titleKind : scan.previewKind;
139
+ const subagents = provider === "claude" ? countSubagents(candidate.transcriptPath, sessionId) : undefined;
140
+ return {
141
+ ...(scan.aiTitle ? { aiTitle: scan.aiTitle } : {}),
142
+ ...(cwd ? { cwd } : {}),
143
+ preview: scan.preview,
144
+ previewKind: scan.previewKind,
145
+ previewRole: scan.previewRole,
146
+ provider,
147
+ sessionId: `${provider}:${sessionId}`,
148
+ ...(subagents ? { subagents } : {}),
149
+ title: titleText,
150
+ titleKind,
151
+ transcriptPath: candidate.transcriptPath,
152
+ updatedAt: new Date(candidate.mtimeMs).toISOString()
153
+ };
154
+ }
155
+ // Claude stores each Task-tool subagent transcript at
156
+ // <project>/<sessionId>/subagents/agent-*.jsonl. Count those files (by name,
157
+ // no content read) so the list can show a session's fan-out. Re-counted only
158
+ // when the parent transcript's mtime changes (it bumps whenever a Task spawns),
159
+ // which keeps the count fresh without a content read or its own walk.
160
+ function countSubagents(transcriptPath, sessionId) {
161
+ const dir = join(dirname(transcriptPath), sessionId, "subagents");
162
+ let entries;
163
+ try {
164
+ entries = readdirSync(dir, { withFileTypes: true });
165
+ }
166
+ catch {
167
+ return undefined;
168
+ }
169
+ let count = 0;
170
+ for (const entry of entries) {
171
+ if (entry.isFile() && entry.name.endsWith(".jsonl"))
172
+ count += 1;
173
+ }
174
+ return count > 0 ? { count } : undefined;
175
+ }
176
+ function scanClaudeEdges(head, tail) {
177
+ let sessionId = "";
178
+ let cwd;
179
+ let title = "";
180
+ let titleKind = "message";
181
+ let aiTitle;
182
+ for (const entry of parseLines(head)) {
183
+ if (!sessionId && typeof entry.sessionId === "string")
184
+ sessionId = entry.sessionId;
185
+ if (!cwd && typeof entry.cwd === "string")
186
+ cwd = entry.cwd;
187
+ if (entry.type === "ai-title" && typeof entry.aiTitle === "string" && entry.aiTitle.trim()) {
188
+ aiTitle = entry.aiTitle.trim();
189
+ }
190
+ if (title || entry.type !== "user")
191
+ continue;
192
+ const message = entry.message;
193
+ if (!message || typeof message !== "object" || Array.isArray(message))
194
+ continue;
195
+ // The first turn is often a slash command or injected context. That is a
196
+ // real message — classify it (command / context / message) so the list can
197
+ // style it, never discard it as "not real".
198
+ const raw = claudeMessageText(message.content, true);
199
+ if (raw) {
200
+ const classified = classifyMessage(raw);
201
+ title = classified.text;
202
+ titleKind = classified.kind;
203
+ }
204
+ }
205
+ let preview = "";
206
+ let previewKind = "message";
207
+ let previewRole = "assistant";
208
+ for (const entry of parseLines(tail)) {
209
+ if (entry.type === "ai-title" && typeof entry.aiTitle === "string" && entry.aiTitle.trim()) {
210
+ aiTitle = entry.aiTitle.trim();
211
+ }
212
+ if (entry.type !== "assistant" && entry.type !== "user")
213
+ continue;
214
+ const role = entry.type;
215
+ const message = entry.message;
216
+ if (!message || typeof message !== "object" || Array.isArray(message))
217
+ continue;
218
+ const raw = claudeMessageText(message.content, role === "user");
219
+ if (!raw)
220
+ continue;
221
+ const classified = classifyMessage(raw);
222
+ preview = classified.text;
223
+ previewKind = classified.kind;
224
+ previewRole = role;
225
+ }
226
+ return { ...(aiTitle ? { aiTitle } : {}), ...(cwd ? { cwd } : {}), preview, previewKind, previewRole, sessionId, title, titleKind };
227
+ }
228
+ function scanCodexEdges(head, tail) {
229
+ let sessionId = "";
230
+ let cwd;
231
+ let title = "";
232
+ let titleKind = "message";
233
+ for (const entry of parseLines(head)) {
234
+ const payload = entry.payload && typeof entry.payload === "object" ? entry.payload : undefined;
235
+ if (entry.type === "session_meta" && payload) {
236
+ if (typeof payload.id === "string")
237
+ sessionId = payload.id;
238
+ if (typeof payload.cwd === "string")
239
+ cwd = payload.cwd;
240
+ continue;
241
+ }
242
+ if (title || !payload)
243
+ continue;
244
+ if (codexMessageRole(entry, payload) === "user") {
245
+ const raw = codexMessageText(entry, payload);
246
+ // Codex prepends synthetic user turns (AGENTS.md instructions, the
247
+ // environment context block) before the real first human message. Skip
248
+ // them so the title is the human's opening line, not harness boilerplate.
249
+ if (raw && !isCodexInjectedContext(raw)) {
250
+ const classified = classifyMessage(raw);
251
+ title = classified.text;
252
+ titleKind = classified.kind;
253
+ }
254
+ }
255
+ }
256
+ let preview = "";
257
+ let previewKind = "message";
258
+ let previewRole = "assistant";
259
+ for (const entry of parseLines(tail)) {
260
+ const payload = entry.payload && typeof entry.payload === "object" ? entry.payload : undefined;
261
+ if (!payload)
262
+ continue;
263
+ const role = codexMessageRole(entry, payload);
264
+ if (!role)
265
+ continue;
266
+ const raw = codexMessageText(entry, payload);
267
+ if (!raw)
268
+ continue;
269
+ const classified = classifyMessage(raw);
270
+ preview = classified.text;
271
+ previewKind = classified.kind;
272
+ previewRole = role;
273
+ }
274
+ return { ...(cwd ? { cwd } : {}), preview, previewKind, previewRole, sessionId, title, titleKind };
275
+ }
276
+ // Claude Code wraps slash commands and injected context in XML-ish tags. We
277
+ // keep the inner text (a `/pm` command, a caveat — all real conversation) and
278
+ // only drop the tag markers so the one-line title/preview reads cleanly.
279
+ const WRAPPER_TAGS = /<\/?(?:system-reminder|local-command-caveat|local-command-stdout|local-command-stderr|command-name|command-message|command-args|command-contents|bash-input|bash-stdout|bash-stderr)>/g;
280
+ function unwrapTags(text) {
281
+ return text.replace(WRAPPER_TAGS, " ").replace(/\s+/g, " ").trim();
282
+ }
283
+ const CONTEXT_BLOCKS = /<(system-reminder|local-command-caveat|local-command-stdout|local-command-stderr)>[\s\S]*?<\/\1>/g;
284
+ // Classify a message into command / injected-context / real-message and produce
285
+ // its display text. A slash command shows its name+args; pure injected context
286
+ // shows its unwrapped text; a human message with some injected context shows the
287
+ // human part. Mirrors the client copy in frontend/agent-chat/conversation-index.
288
+ function classifyMessage(raw) {
289
+ const commandName = innerTag(raw, "command-name");
290
+ const commandMessage = innerTag(raw, "command-message");
291
+ const commandArgs = innerTag(raw, "command-args");
292
+ if (commandName || commandMessage || commandArgs) {
293
+ const text = [commandName || commandMessage, commandArgs].filter(Boolean).join(" ").replace(/\s+/g, " ").trim();
294
+ return { kind: "command", text: text || unwrapTags(raw) };
295
+ }
296
+ const human = unwrapTags(raw.replace(CONTEXT_BLOCKS, " "));
297
+ if (human)
298
+ return { kind: "message", text: human };
299
+ return { kind: "context", text: unwrapTags(raw) };
300
+ }
301
+ function innerTag(raw, tag) {
302
+ const match = raw.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`));
303
+ return match?.[1]?.replace(/\s+/g, " ").trim() ?? "";
304
+ }
305
+ // Only text blocks count as conversational, matching the transcript parser:
306
+ // tool calls and results never become titles or previews.
307
+ function claudeMessageText(content, userOnly) {
308
+ if (typeof content === "string")
309
+ return content.trim();
310
+ if (!Array.isArray(content))
311
+ return "";
312
+ const parts = [];
313
+ for (const block of content) {
314
+ if (!block || typeof block !== "object")
315
+ continue;
316
+ const record = block;
317
+ if (record.type !== "text") {
318
+ // A user turn that carries a tool_result is not a real user message.
319
+ if (userOnly)
320
+ return "";
321
+ continue;
322
+ }
323
+ if (typeof record.text === "string" && record.text.trim())
324
+ parts.push(record.text);
325
+ }
326
+ return parts.join("\n\n").trim();
327
+ }
328
+ function codexMessageRole(entry, payload) {
329
+ if (entry.type === "response_item" && payload.type === "message") {
330
+ const role = typeof payload.role === "string" ? payload.role : "assistant";
331
+ if (role === "user")
332
+ return "user";
333
+ if (role === "assistant")
334
+ return "assistant";
335
+ return undefined;
336
+ }
337
+ if (entry.type === "event_msg") {
338
+ if (payload.type === "user_message")
339
+ return "user";
340
+ if (payload.type === "agent_message")
341
+ return "assistant";
342
+ }
343
+ return undefined;
344
+ }
345
+ function codexMessageText(entry, payload) {
346
+ if (entry.type === "event_msg") {
347
+ return typeof payload.message === "string" ? payload.message.trim() : "";
348
+ }
349
+ const content = payload.content;
350
+ if (typeof content === "string")
351
+ return content.trim();
352
+ if (!Array.isArray(content))
353
+ return "";
354
+ return content
355
+ .map((item) => (item && typeof item === "object" && typeof item.text === "string"
356
+ ? item.text
357
+ : ""))
358
+ .filter(Boolean)
359
+ .join("\n\n")
360
+ .trim();
361
+ }
362
+ // A tail slice can begin mid-line; parse failures on the partial first line are
363
+ // simply skipped.
364
+ // Gemini transcript edge scan. Header line carries sessionId; user messages have
365
+ // content:[{text}], gemini messages have content:<string>; $set/info lines skipped.
366
+ // cwd is NOT in the transcript (resolved from the tmp/<slug> path in parseIndexRow).
367
+ function scanGeminiEdges(head, tail) {
368
+ let sessionId = "";
369
+ let title = "";
370
+ let titleKind = "message";
371
+ for (const entry of parseLines(head)) {
372
+ if (!sessionId && typeof entry.sessionId === "string")
373
+ sessionId = entry.sessionId;
374
+ if (title || entry.type !== "user")
375
+ continue;
376
+ const raw = geminiContentText(entry.content);
377
+ if (raw) {
378
+ const classified = classifyMessage(raw);
379
+ title = classified.text;
380
+ titleKind = classified.kind;
381
+ }
382
+ }
383
+ let preview = "";
384
+ let previewKind = "message";
385
+ let previewRole = "assistant";
386
+ for (const entry of parseLines(tail)) {
387
+ if (entry.type !== "user" && entry.type !== "gemini")
388
+ continue;
389
+ const raw = geminiContentText(entry.content);
390
+ if (!raw)
391
+ continue;
392
+ const classified = classifyMessage(raw);
393
+ preview = classified.text;
394
+ previewKind = classified.kind;
395
+ previewRole = entry.type === "user" ? "user" : "assistant";
396
+ }
397
+ return { preview, previewKind, previewRole, sessionId, title, titleKind };
398
+ }
399
+ // Gemini stores cwd→slug in ~/.gemini/projects.json; reverse it to label a
400
+ // transcript (…/.gemini/tmp/<slug>/chats/…) with its working directory. Cached per
401
+ // .gemini dir (projects.json changes rarely).
402
+ const geminiProjectsCache = new Map();
403
+ function geminiCwdForPath(transcriptPath) {
404
+ const marker = `${sep}.gemini${sep}tmp${sep}`;
405
+ const at = transcriptPath.indexOf(marker);
406
+ if (at < 0)
407
+ return undefined;
408
+ const slug = transcriptPath.slice(at + marker.length).split(sep)[0];
409
+ if (!slug)
410
+ return undefined;
411
+ return geminiSlugToCwd(`${transcriptPath.slice(0, at)}${sep}.gemini`)[slug];
412
+ }
413
+ function geminiSlugToCwd(geminiDir) {
414
+ const cached = geminiProjectsCache.get(geminiDir);
415
+ if (cached)
416
+ return cached;
417
+ const slugToCwd = {};
418
+ try {
419
+ const raw = JSON.parse(readFileSync(join(geminiDir, "projects.json"), "utf8"));
420
+ const projects = raw.projects;
421
+ if (projects && typeof projects === "object") {
422
+ for (const [cwd, slug] of Object.entries(projects)) {
423
+ if (typeof slug === "string")
424
+ slugToCwd[slug] = cwd;
425
+ }
426
+ }
427
+ }
428
+ catch { /* no projects.json — gemini rows just lack cwd */ }
429
+ geminiProjectsCache.set(geminiDir, slugToCwd);
430
+ return slugToCwd;
431
+ }
432
+ function* parseLines(text) {
433
+ for (const line of text.split("\n")) {
434
+ if (!line.trim())
435
+ continue;
436
+ try {
437
+ yield JSON.parse(line);
438
+ }
439
+ catch {
440
+ continue;
441
+ }
442
+ }
443
+ }
444
+ function readEdges(transcriptPath) {
445
+ let fd;
446
+ try {
447
+ fd = openSync(transcriptPath, "r");
448
+ }
449
+ catch {
450
+ return undefined;
451
+ }
452
+ try {
453
+ const size = statSync(transcriptPath).size;
454
+ const head = readSlice(fd, 0, Math.min(EDGE_BYTES, size));
455
+ if (size <= EDGE_BYTES) {
456
+ return { head, tail: head };
457
+ }
458
+ return { head, tail: readSlice(fd, size - EDGE_BYTES, EDGE_BYTES) };
459
+ }
460
+ finally {
461
+ closeSync(fd);
462
+ }
463
+ }
464
+ function readSlice(fd, position, length) {
465
+ if (length <= 0)
466
+ return "";
467
+ const buffer = Buffer.alloc(length);
468
+ const bytesRead = readSync(fd, buffer, 0, length, position);
469
+ return buffer.toString("utf8", 0, bytesRead);
470
+ }
471
+ function mtimeOf(transcriptPath) {
472
+ try {
473
+ return statSync(transcriptPath).mtimeMs;
474
+ }
475
+ catch {
476
+ return undefined;
477
+ }
478
+ }
479
+ function pruneCache(candidates) {
480
+ const seen = new Set(candidates.map((candidate) => candidate.transcriptPath));
481
+ for (const key of indexCache.keys()) {
482
+ if (!seen.has(key))
483
+ indexCache.delete(key);
484
+ }
485
+ }
486
+ function normalizeLimit(limit) {
487
+ if (limit === undefined)
488
+ return 50;
489
+ if (!Number.isInteger(limit) || limit < 1) {
490
+ throw new Error("Agent session index limit must be a positive integer.");
491
+ }
492
+ return Math.min(limit, 200);
493
+ }
494
+ function normalizeCursor(cursor) {
495
+ if (cursor === undefined || cursor === null || cursor === "")
496
+ return 0;
497
+ const offset = Number(cursor);
498
+ if (!Number.isInteger(offset) || offset < 0) {
499
+ throw new Error(`Invalid agent session index cursor: ${cursor}`);
500
+ }
501
+ return offset;
502
+ }
503
+ function* walkJsonl(root) {
504
+ let entries;
505
+ try {
506
+ entries = readdirSync(root, { withFileTypes: true });
507
+ }
508
+ catch {
509
+ return;
510
+ }
511
+ for (const entry of entries) {
512
+ const path = join(root, entry.name);
513
+ if (entry.isDirectory()) {
514
+ // Claude writes Task-tool subagent transcripts under
515
+ // <session>/subagents/agent-*.jsonl. They are sidechains, not resumable
516
+ // sessions, and they carry the PARENT session id — so indexing them
517
+ // collides on the dedup key and overwrites the real session's row with a
518
+ // subagent's older mtime and prompt. Never treat them as candidates.
519
+ if (entry.name === "subagents")
520
+ continue;
521
+ yield* walkJsonl(path);
522
+ }
523
+ else if (entry.isFile() && path.endsWith(".jsonl")) {
524
+ yield path;
525
+ }
526
+ }
527
+ }
@@ -0,0 +1,33 @@
1
+ import { type AgentSessionDetails, type ClaudeAgentSessionDetails } from "./agent-session.js";
2
+ export type AgentSessionReadResult = {
3
+ details: AgentSessionDetails;
4
+ signature?: string;
5
+ };
6
+ export declare class ClaudeTranscriptTail {
7
+ private readonly options;
8
+ private offset;
9
+ private pendingTail;
10
+ private anchor;
11
+ private parser;
12
+ private totalLines;
13
+ private cached?;
14
+ private lastIno?;
15
+ private lastMtimeMs?;
16
+ constructor(options: {
17
+ sessionId: string;
18
+ transcriptPath: string;
19
+ });
20
+ get transcriptPath(): string;
21
+ private reset;
22
+ read(): Promise<{
23
+ consumedLines: number;
24
+ details: ClaudeAgentSessionDetails;
25
+ signature: string;
26
+ }>;
27
+ }
28
+ export declare function clearAgentSessionTailCache(): void;
29
+ export declare function readLocalAgentSessionTailed(options: {
30
+ cwd: string;
31
+ home?: string;
32
+ sessionId: string;
33
+ }): Promise<AgentSessionReadResult>;