doer-agent 0.5.9 → 0.6.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.
@@ -1,1033 +0,0 @@
1
- import { watch } from "node:fs";
2
- import { open, mkdir, readdir, realpath, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
3
- import crypto from "node:crypto";
4
- import path from "node:path";
5
- import { StringCodec } from "nats";
6
- const sessionRpcCodec = StringCodec();
7
- const activeSessionWatchers = new Map();
8
- const sessionLineIndexCache = new Map();
9
- const SESSION_RPC_BLOB_KEYS = new Set([
10
- "image_url",
11
- "image_base64",
12
- "content_base64",
13
- "file_data",
14
- "bytes",
15
- "data",
16
- ]);
17
- function getSessionsRootPath(workspaceRoot) {
18
- return path.join(workspaceRoot, ".codex", "sessions");
19
- }
20
- function resolveSessionFilePath(workspaceRoot, filePath) {
21
- const root = path.resolve(getSessionsRootPath(workspaceRoot));
22
- const resolved = path.resolve(filePath);
23
- if (!(resolved === root || resolved.startsWith(root + path.sep))) {
24
- throw new Error("filePath is outside sessions root");
25
- }
26
- return resolved;
27
- }
28
- function isObjectRecord(value) {
29
- return !!value && typeof value === "object" && !Array.isArray(value);
30
- }
31
- function normalizeSessionRpcRequest(args) {
32
- const requestId = typeof args.request.requestId === "string" ? args.request.requestId.trim() : "";
33
- if (!requestId) {
34
- throw new Error("missing requestId");
35
- }
36
- const requestAgentId = typeof args.request.agentId === "string" ? args.request.agentId.trim() : "";
37
- if (!requestAgentId || requestAgentId !== args.agentId) {
38
- throw new Error("agent id mismatch");
39
- }
40
- const actionRaw = typeof args.request.action === "string" ? args.request.action.trim() : "";
41
- const action = actionRaw === "messages" || actionRaw === "delete" || actionRaw === "watch" || actionRaw === "stop_watch"
42
- ? actionRaw
43
- : "list";
44
- const responseSubject = typeof args.request.responseSubject === "string" ? args.request.responseSubject.trim() : "";
45
- if (!responseSubject) {
46
- throw new Error("missing responseSubject");
47
- }
48
- const filePath = typeof args.request.filePath === "string" && args.request.filePath.trim() ? args.request.filePath.trim() : null;
49
- if ((action === "messages" || action === "delete" || action === "watch") && !filePath) {
50
- throw new Error("missing filePath");
51
- }
52
- const sinceLineRaw = Number(args.request.sinceLine);
53
- const sinceLine = Number.isInteger(sinceLineRaw) && sinceLineRaw > 0 ? sinceLineRaw : 0;
54
- const beforeRowIdRaw = Number(args.request.beforeRowId);
55
- const beforeRowId = Number.isInteger(beforeRowIdRaw) && beforeRowIdRaw > 0 ? beforeRowIdRaw : null;
56
- const pageSizeRaw = Number(args.request.pageSize);
57
- const pageSize = Number.isFinite(pageSizeRaw) ? Math.max(1, Math.min(Math.floor(pageSizeRaw), 100)) : 100;
58
- const watchId = typeof args.request.watchId === "string" && args.request.watchId.trim() ? args.request.watchId.trim() : null;
59
- if (action === "stop_watch" && !watchId) {
60
- throw new Error("missing watchId");
61
- }
62
- return {
63
- requestId,
64
- action,
65
- agentId: requestAgentId,
66
- filePath,
67
- sessionId: typeof args.request.sessionId === "string" && args.request.sessionId.trim() ? args.request.sessionId.trim() : null,
68
- sinceLine,
69
- beforeRowId,
70
- pageSize,
71
- responseSubject,
72
- watchId,
73
- };
74
- }
75
- function isInlineBlobString(value) {
76
- const trimmed = value.trim();
77
- if (!trimmed) {
78
- return false;
79
- }
80
- return trimmed.startsWith("data:") || trimmed.includes(";base64,");
81
- }
82
- function buildInlineBlobMarker(value) {
83
- const trimmed = value.trim();
84
- if (trimmed.startsWith("data:")) {
85
- const mimeEnd = trimmed.indexOf(";");
86
- const mimeType = mimeEnd > 5 ? trimmed.slice(5, mimeEnd) : "";
87
- if (mimeType) {
88
- return `[inline blob omitted: ${mimeType}]`;
89
- }
90
- }
91
- return "[inline blob omitted]";
92
- }
93
- function extensionForImageMimeType(mimeType) {
94
- const normalized = mimeType.trim().toLowerCase();
95
- if (normalized === "image/jpeg" || normalized === "image/jpg")
96
- return ".jpg";
97
- if (normalized === "image/webp")
98
- return ".webp";
99
- if (normalized === "image/gif")
100
- return ".gif";
101
- if (normalized === "image/svg+xml")
102
- return ".svg";
103
- if (normalized === "image/bmp")
104
- return ".bmp";
105
- if (normalized === "image/avif")
106
- return ".avif";
107
- return ".png";
108
- }
109
- function decodeInlineImageDataUrl(value) {
110
- const trimmed = value.trim();
111
- const match = /^data:([^;,]+)(?:;[^,]*)?;base64,([\s\S]+)$/i.exec(trimmed);
112
- if (!match) {
113
- return null;
114
- }
115
- const mimeType = match[1]?.trim().toLowerCase() || "";
116
- if (!mimeType.startsWith("image/")) {
117
- return null;
118
- }
119
- const base64 = match[2]?.replace(/\s/g, "") || "";
120
- if (!base64) {
121
- return null;
122
- }
123
- try {
124
- return {
125
- bytes: Buffer.from(base64, "base64"),
126
- mimeType,
127
- };
128
- }
129
- catch {
130
- return null;
131
- }
132
- }
133
- function sanitizeSessionPathSegment(value, fallback) {
134
- return value.trim().replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 160) || fallback;
135
- }
136
- function inferSessionIdFromSessionFilePath(filePath) {
137
- const base = path.basename(filePath, path.extname(filePath));
138
- const match = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)$/.exec(base);
139
- const inferred = match?.[1]?.trim() || "";
140
- return inferred || null;
141
- }
142
- async function materializeInlineSessionImage(args) {
143
- const decoded = decodeInlineImageDataUrl(args.value);
144
- if (!decoded || decoded.bytes.byteLength === 0) {
145
- return null;
146
- }
147
- const inferredSessionId = inferSessionIdFromSessionFilePath(args.filePath);
148
- const sessionBase = (args.sessionId ? sanitizeSessionPathSegment(args.sessionId, "") : "")
149
- || (inferredSessionId ? sanitizeSessionPathSegment(inferredSessionId, "") : "")
150
- || sanitizeSessionPathSegment(path.basename(args.filePath, path.extname(args.filePath)), "session");
151
- const hash = crypto.createHash("sha256").update(decoded.bytes).digest("hex").slice(0, 16);
152
- const ext = extensionForImageMimeType(decoded.mimeType);
153
- const name = `line-${args.lineNumber}-${hash}${ext}`;
154
- const relPath = path.posix.join(".doer-agent", "sessions", sessionBase, "outputs", name);
155
- const absPath = path.resolve(args.workspaceRoot, relPath);
156
- if (!(absPath === args.workspaceRoot || absPath.startsWith(args.workspaceRoot + path.sep))) {
157
- return null;
158
- }
159
- await mkdir(path.dirname(absPath), { recursive: true });
160
- const existing = await stat(absPath).catch(() => null);
161
- if (!existing || !existing.isFile() || existing.size !== decoded.bytes.byteLength) {
162
- await writeFile(absPath, decoded.bytes);
163
- }
164
- return {
165
- relPath,
166
- name,
167
- size: decoded.bytes.byteLength,
168
- mimeType: decoded.mimeType,
169
- };
170
- }
171
- function getSessionRpcPayloadByteLength(value) {
172
- try {
173
- const serialized = typeof value === "string" ? value : JSON.stringify(value);
174
- return typeof serialized === "string" ? Buffer.byteLength(serialized, "utf8") : null;
175
- }
176
- catch {
177
- return null;
178
- }
179
- }
180
- function buildSessionRpcTruncatedMarker(label, value) {
181
- const byteLength = getSessionRpcPayloadByteLength(value);
182
- return byteLength === null
183
- ? `[${label} truncated for session RPC pagination]`
184
- : `[${label} truncated for session RPC pagination: ${byteLength} bytes omitted]`;
185
- }
186
- async function sanitizeSessionRpcPayload(value, args) {
187
- if (typeof value === "string") {
188
- return value;
189
- }
190
- if (Array.isArray(value)) {
191
- return Promise.all(value.map((entry) => sanitizeSessionRpcPayload(entry, args)));
192
- }
193
- if (!isObjectRecord(value)) {
194
- return value;
195
- }
196
- const sanitized = {};
197
- for (const [key, entry] of Object.entries(value)) {
198
- if (SESSION_RPC_BLOB_KEYS.has(key) && typeof entry === "string" && isInlineBlobString(entry)) {
199
- if (key === "image_url") {
200
- const materialized = await materializeInlineSessionImage({ ...args, value: entry });
201
- if (materialized) {
202
- sanitized[key] = materialized.relPath;
203
- sanitized.name = typeof value.name === "string" && value.name.trim() ? value.name : materialized.name;
204
- sanitized.size = typeof value.size === "number" && Number.isFinite(value.size) ? value.size : materialized.size;
205
- sanitized.mimeType =
206
- typeof value.mimeType === "string" && value.mimeType.trim() ? value.mimeType : materialized.mimeType;
207
- continue;
208
- }
209
- }
210
- sanitized[key] = buildInlineBlobMarker(entry);
211
- continue;
212
- }
213
- sanitized[key] = await sanitizeSessionRpcPayload(entry, args);
214
- }
215
- return sanitized;
216
- }
217
- async function sanitizeSessionRpcRawLine(line, args) {
218
- const trimmed = line.trim();
219
- if (!trimmed.startsWith("{")) {
220
- return line;
221
- }
222
- try {
223
- const parsed = JSON.parse(line);
224
- if (!isObjectRecord(parsed)) {
225
- return line;
226
- }
227
- if (parsed.type === "compacted" || parsed.type === "turn_context" || parsed.type === "session_meta") {
228
- return JSON.stringify({
229
- ...parsed,
230
- payload: buildSessionRpcTruncatedMarker("payload", parsed.payload),
231
- });
232
- }
233
- if (!isObjectRecord(parsed.payload)) {
234
- return line;
235
- }
236
- if (parsed.type !== "response_item") {
237
- return line;
238
- }
239
- const payloadType = typeof parsed.payload.type === "string" ? parsed.payload.type : "";
240
- if (payloadType === "message" || payloadType === "reasoning") {
241
- return JSON.stringify({
242
- ...parsed,
243
- payload: {
244
- type: payloadType,
245
- status: typeof parsed.payload.status === "string" ? parsed.payload.status : "completed",
246
- message: buildSessionRpcTruncatedMarker(`${payloadType} payload`, parsed.payload),
247
- },
248
- });
249
- }
250
- return JSON.stringify({
251
- ...parsed,
252
- payload: await sanitizeSessionRpcPayload(parsed.payload, args),
253
- });
254
- }
255
- catch {
256
- return line;
257
- }
258
- }
259
- function toTrimmedStringOrNull(value) {
260
- if (typeof value !== "string") {
261
- return null;
262
- }
263
- const trimmed = value.trim();
264
- return trimmed || null;
265
- }
266
- function pickSessionString(...values) {
267
- for (const value of values) {
268
- const picked = toTrimmedStringOrNull(value);
269
- if (picked) {
270
- return picked;
271
- }
272
- }
273
- return null;
274
- }
275
- function toSortableTimestampMs(value) {
276
- const parsed = Date.parse(value);
277
- return Number.isFinite(parsed) ? parsed : 0;
278
- }
279
- export async function collectSessionJsonlFiles(workspaceRoot) {
280
- const out = [];
281
- const stack = [getSessionsRootPath(workspaceRoot)];
282
- while (stack.length > 0) {
283
- const current = stack.pop();
284
- if (!current) {
285
- continue;
286
- }
287
- let entries = [];
288
- try {
289
- entries = await readdir(current, { withFileTypes: true });
290
- }
291
- catch {
292
- continue;
293
- }
294
- for (const entry of entries) {
295
- const fullPath = path.join(current, entry.name);
296
- if (entry.isDirectory()) {
297
- stack.push(fullPath);
298
- continue;
299
- }
300
- if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".jsonl")) {
301
- continue;
302
- }
303
- try {
304
- const entryStat = await stat(fullPath);
305
- out.push({ filePath: fullPath, mtimeMs: entryStat.mtimeMs });
306
- }
307
- catch {
308
- // ignore removed files
309
- }
310
- }
311
- }
312
- return out;
313
- }
314
- async function readFirstLine(fileHandle, fileSize) {
315
- const chunkBytes = 16_384;
316
- const maxScanBytes = 262_144;
317
- let position = 0;
318
- let scanned = 0;
319
- let raw = "";
320
- while (position < fileSize && scanned < maxScanBytes) {
321
- const readSize = Math.min(chunkBytes, fileSize - position, maxScanBytes - scanned);
322
- const buffer = Buffer.alloc(readSize);
323
- const { bytesRead } = await fileHandle.read(buffer, 0, readSize, position);
324
- if (bytesRead <= 0) {
325
- break;
326
- }
327
- raw += buffer.toString("utf8", 0, bytesRead);
328
- scanned += bytesRead;
329
- position += bytesRead;
330
- const newlineIndex = raw.search(/\r?\n/);
331
- if (newlineIndex >= 0) {
332
- return raw.slice(0, newlineIndex).trim();
333
- }
334
- }
335
- return raw.trim();
336
- }
337
- function normalizeSessionSummaryMessage(value) {
338
- const message = toTrimmedStringOrNull(value);
339
- if (!message || message.toLowerCase() === "empty") {
340
- return null;
341
- }
342
- return message;
343
- }
344
- function extractLastSessionMessage(candidateLines) {
345
- for (const line of candidateLines) {
346
- const trimmed = line.trim();
347
- if (!trimmed) {
348
- continue;
349
- }
350
- try {
351
- const parsed = JSON.parse(trimmed);
352
- if (parsed.type !== "event_msg" || !isObjectRecord(parsed.payload)) {
353
- continue;
354
- }
355
- if (parsed.payload.type !== "agent_message" && parsed.payload.type !== "user_message") {
356
- continue;
357
- }
358
- const message = normalizeSessionSummaryMessage(parsed.payload.message);
359
- if (message) {
360
- return {
361
- message,
362
- updatedAt: toTrimmedStringOrNull(parsed.timestamp),
363
- };
364
- }
365
- }
366
- catch {
367
- // ignore malformed lines
368
- }
369
- }
370
- return null;
371
- }
372
- function linePrefixContainsSessionMessageCandidate(prefix) {
373
- return (/"type"\s*:\s*"event_msg"/.test(prefix) &&
374
- /"type"\s*:\s*"(agent_message|user_message)"/.test(prefix));
375
- }
376
- async function readLineSpan(fileHandle, start, end) {
377
- const readSize = Math.max(0, end - start);
378
- if (readSize <= 0) {
379
- return "";
380
- }
381
- const buffer = Buffer.alloc(readSize);
382
- let totalBytesRead = 0;
383
- while (totalBytesRead < readSize) {
384
- const { bytesRead } = await fileHandle.read(buffer, totalBytesRead, readSize - totalBytesRead, start + totalBytesRead);
385
- if (bytesRead <= 0) {
386
- break;
387
- }
388
- totalBytesRead += bytesRead;
389
- }
390
- return buffer.toString("utf8", 0, totalBytesRead).trim();
391
- }
392
- async function readLastSessionMessage(workspaceRoot, filePath) {
393
- const index = await readSessionLineIndex(workspaceRoot, filePath);
394
- const totalLines = index.lineStartOffsets.length;
395
- if (totalLines <= 0 || index.size <= 0) {
396
- return null;
397
- }
398
- const resolvedFile = resolveSessionFilePath(workspaceRoot, filePath);
399
- const fileHandle = await open(resolvedFile, "r");
400
- try {
401
- for (let lineIndex = totalLines - 1; lineIndex >= 0; lineIndex -= 1) {
402
- const start = index.lineStartOffsets[lineIndex] ?? index.size;
403
- const end = lineIndex + 1 < totalLines ? (index.lineStartOffsets[lineIndex + 1] ?? index.size) : index.size;
404
- const spanBytes = Math.max(0, end - start);
405
- if (spanBytes <= 0) {
406
- continue;
407
- }
408
- const prefixBytes = Math.min(spanBytes, 1024);
409
- const prefix = await readLineSpan(fileHandle, start, start + prefixBytes);
410
- if (!linePrefixContainsSessionMessageCandidate(prefix)) {
411
- continue;
412
- }
413
- const found = extractLastSessionMessage([await readLineSpan(fileHandle, start, end)]);
414
- if (found) {
415
- return found;
416
- }
417
- }
418
- return null;
419
- }
420
- finally {
421
- await fileHandle.close().catch(() => undefined);
422
- }
423
- }
424
- function normalizeSessionMeta(rawMeta, filePath, mtimeMs) {
425
- const baseName = path.basename(filePath, path.extname(filePath));
426
- const meta = isObjectRecord(rawMeta) ? rawMeta : {};
427
- const updatedAtCandidate = pickSessionString(meta.updatedAt, meta.updated_at, meta.timestamp);
428
- return {
429
- id: pickSessionString(meta.sessionId, meta.session_id, meta.id) || baseName,
430
- label: pickSessionString(meta.label, meta.title, meta.name, meta.sessionLabel, meta.session_label) || baseName,
431
- updatedAt: updatedAtCandidate || new Date(mtimeMs).toISOString(),
432
- cwd: pickSessionString(meta.cwd, meta.workingDirectory, meta.working_directory),
433
- source: pickSessionString(meta.source, meta.sessionSource, meta.session_source) || "codex",
434
- originator: pickSessionString(meta.originator, meta.author, meta.user, meta.username) || "unknown",
435
- filePath,
436
- };
437
- }
438
- async function readSessionSummary(workspaceRoot, filePath, mtimeMs) {
439
- let fileHandle = null;
440
- try {
441
- fileHandle = await open(filePath, "r");
442
- const entryStat = await fileHandle.stat();
443
- const firstLine = await readFirstLine(fileHandle, entryStat.size);
444
- const tailSummary = await readLastSessionMessage(workspaceRoot, filePath);
445
- let normalized = normalizeSessionMeta({}, filePath, mtimeMs);
446
- if (firstLine) {
447
- try {
448
- const parsed = JSON.parse(firstLine);
449
- const candidateMeta = parsed && parsed.type === "session_meta" && isObjectRecord(parsed.payload)
450
- ? parsed.payload
451
- : isObjectRecord(parsed.session_meta)
452
- ? parsed.session_meta
453
- : isObjectRecord(parsed.sessionMeta)
454
- ? parsed.sessionMeta
455
- : isObjectRecord(parsed.meta)
456
- ? parsed.meta
457
- : isObjectRecord(parsed.payload)
458
- ? parsed.payload
459
- : parsed;
460
- normalized = normalizeSessionMeta(candidateMeta, filePath, mtimeMs);
461
- }
462
- catch {
463
- normalized = normalizeSessionMeta({}, filePath, mtimeMs);
464
- }
465
- }
466
- return {
467
- ...normalized,
468
- label: tailSummary?.message || "(no agent message)",
469
- updatedAt: tailSummary?.updatedAt || normalized.updatedAt,
470
- };
471
- }
472
- catch {
473
- return normalizeSessionMeta({}, filePath, mtimeMs);
474
- }
475
- finally {
476
- await fileHandle?.close().catch(() => undefined);
477
- }
478
- }
479
- async function listAgentSessions(workspaceRoot) {
480
- const maxSessionSummaries = 10;
481
- const sessionsRoot = getSessionsRootPath(workspaceRoot);
482
- let sessionsRootStat;
483
- try {
484
- sessionsRootStat = await stat(sessionsRoot);
485
- }
486
- catch {
487
- return [];
488
- }
489
- if (!sessionsRootStat.isDirectory()) {
490
- return [];
491
- }
492
- const files = await collectSessionJsonlFiles(workspaceRoot);
493
- files.sort((a, b) => b.mtimeMs - a.mtimeMs || a.filePath.localeCompare(b.filePath));
494
- const sessions = [];
495
- for (let index = 0; index < files.length; index += 1) {
496
- const file = files[index];
497
- sessions.push(await readSessionSummary(workspaceRoot, file.filePath, file.mtimeMs));
498
- sessions.sort((a, b) => toSortableTimestampMs(b.updatedAt) - toSortableTimestampMs(a.updatedAt) || b.filePath.localeCompare(a.filePath));
499
- if (sessions.length > maxSessionSummaries) {
500
- sessions.length = maxSessionSummaries;
501
- }
502
- const nextFile = files[index + 1] ?? null;
503
- const oldestSelectedSession = sessions[maxSessionSummaries - 1] ?? null;
504
- if (nextFile &&
505
- oldestSelectedSession &&
506
- toSortableTimestampMs(oldestSelectedSession.updatedAt) >= nextFile.mtimeMs) {
507
- break;
508
- }
509
- }
510
- return sessions;
511
- }
512
- async function readSessionLineIndex(workspaceRoot, filePath) {
513
- const resolvedFile = resolveSessionFilePath(workspaceRoot, filePath);
514
- const entryStat = await stat(resolvedFile);
515
- const nextSize = entryStat.size;
516
- const cached = sessionLineIndexCache.get(resolvedFile) ?? null;
517
- if (cached && cached.size === nextSize) {
518
- return cached;
519
- }
520
- const fileHandle = await open(resolvedFile, "r");
521
- try {
522
- let lineStartOffsets = cached?.lineStartOffsets.slice() ?? [];
523
- let scanStart = cached?.size ?? 0;
524
- let endsWithNewline = cached?.endsWithNewline ?? false;
525
- if (!cached || nextSize < cached.size) {
526
- lineStartOffsets = nextSize > 0 ? [0] : [];
527
- scanStart = 0;
528
- endsWithNewline = false;
529
- }
530
- else if (cached.endsWithNewline && nextSize > cached.size) {
531
- lineStartOffsets.push(cached.size);
532
- }
533
- let position = scanStart;
534
- const chunkBytes = 65_536;
535
- while (position < nextSize) {
536
- const readSize = Math.min(chunkBytes, nextSize - position);
537
- const buffer = Buffer.alloc(readSize);
538
- const { bytesRead } = await fileHandle.read(buffer, 0, readSize, position);
539
- if (bytesRead <= 0) {
540
- break;
541
- }
542
- for (let index = 0; index < bytesRead; index += 1) {
543
- if (buffer[index] !== 0x0a) {
544
- continue;
545
- }
546
- const nextLineStart = position + index + 1;
547
- if (nextLineStart < nextSize) {
548
- lineStartOffsets.push(nextLineStart);
549
- }
550
- }
551
- position += bytesRead;
552
- }
553
- if (nextSize > 0) {
554
- const tail = Buffer.alloc(1);
555
- const { bytesRead } = await fileHandle.read(tail, 0, 1, nextSize - 1);
556
- endsWithNewline = bytesRead > 0 && tail[0] === 0x0a;
557
- }
558
- else {
559
- endsWithNewline = false;
560
- }
561
- const nextEntry = {
562
- size: nextSize,
563
- lineStartOffsets,
564
- endsWithNewline,
565
- };
566
- sessionLineIndexCache.set(resolvedFile, nextEntry);
567
- return nextEntry;
568
- }
569
- finally {
570
- await fileHandle.close().catch(() => undefined);
571
- }
572
- }
573
- async function getAgentSessionRawRows(args) {
574
- const resolvedFile = resolveSessionFilePath(args.workspaceRoot, args.filePath);
575
- const effectiveSessionId = args.sessionId || await readSessionIdFromSessionFile(resolvedFile).catch(() => null);
576
- const index = await readSessionLineIndex(args.workspaceRoot, resolvedFile);
577
- const totalLines = index.lineStartOffsets.length;
578
- const sinceLine = Math.max(0, Math.floor(args.sinceLine));
579
- const beforeRowId = args.beforeRowId && args.beforeRowId > 0 ? Math.floor(args.beforeRowId) : null;
580
- const maxRawRows = 200;
581
- const maxSelectionBytes = 120_000;
582
- const maxLineSelectionBytes = 4_096;
583
- const maxReadBytes = 2_000_000;
584
- if (totalLines === 0) {
585
- return {
586
- rawRows: [],
587
- nextCursor: 0,
588
- };
589
- }
590
- let startLineIndex = 0;
591
- let endLineIndex = totalLines;
592
- const getLineSpanBytes = (lineIndex) => {
593
- const start = index.lineStartOffsets[lineIndex] ?? index.size;
594
- const end = lineIndex + 1 < totalLines ? (index.lineStartOffsets[lineIndex + 1] ?? index.size) : index.size;
595
- return Math.max(0, end - start);
596
- };
597
- if (beforeRowId !== null) {
598
- endLineIndex = Math.max(0, Math.min(totalLines, beforeRowId - 1));
599
- startLineIndex = endLineIndex;
600
- let collectedRows = 0;
601
- let collectedSelectionBytes = 0;
602
- let collectedReadBytes = 0;
603
- while (startLineIndex > 0 && collectedRows < maxRawRows) {
604
- const nextIndex = startLineIndex - 1;
605
- const nextReadBytes = getLineSpanBytes(nextIndex);
606
- const nextSelectionBytes = Math.min(nextReadBytes, maxLineSelectionBytes);
607
- if (collectedRows > 0 && collectedSelectionBytes + nextSelectionBytes > maxSelectionBytes) {
608
- break;
609
- }
610
- if (collectedRows > 0 && collectedReadBytes + nextReadBytes > maxReadBytes) {
611
- break;
612
- }
613
- startLineIndex = nextIndex;
614
- collectedRows += 1;
615
- collectedSelectionBytes += nextSelectionBytes;
616
- collectedReadBytes += nextReadBytes;
617
- }
618
- }
619
- else if (sinceLine > 0) {
620
- startLineIndex = Math.min(totalLines, sinceLine);
621
- endLineIndex = startLineIndex;
622
- let collectedRows = 0;
623
- let collectedSelectionBytes = 0;
624
- let collectedReadBytes = 0;
625
- while (endLineIndex < totalLines && collectedRows < maxRawRows) {
626
- const nextReadBytes = getLineSpanBytes(endLineIndex);
627
- const nextSelectionBytes = Math.min(nextReadBytes, maxLineSelectionBytes);
628
- if (collectedRows > 0 && collectedSelectionBytes + nextSelectionBytes > maxSelectionBytes) {
629
- break;
630
- }
631
- if (collectedRows > 0 && collectedReadBytes + nextReadBytes > maxReadBytes) {
632
- break;
633
- }
634
- endLineIndex += 1;
635
- collectedRows += 1;
636
- collectedSelectionBytes += nextSelectionBytes;
637
- collectedReadBytes += nextReadBytes;
638
- }
639
- }
640
- else {
641
- startLineIndex = totalLines;
642
- let collectedRows = 0;
643
- let collectedSelectionBytes = 0;
644
- let collectedReadBytes = 0;
645
- while (startLineIndex > 0 && collectedRows < maxRawRows) {
646
- const nextIndex = startLineIndex - 1;
647
- const nextReadBytes = getLineSpanBytes(nextIndex);
648
- const nextSelectionBytes = Math.min(nextReadBytes, maxLineSelectionBytes);
649
- if (collectedRows > 0 && collectedSelectionBytes + nextSelectionBytes > maxSelectionBytes) {
650
- break;
651
- }
652
- if (collectedRows > 0 && collectedReadBytes + nextReadBytes > maxReadBytes) {
653
- break;
654
- }
655
- startLineIndex = nextIndex;
656
- collectedRows += 1;
657
- collectedSelectionBytes += nextSelectionBytes;
658
- collectedReadBytes += nextReadBytes;
659
- }
660
- }
661
- if (startLineIndex >= endLineIndex) {
662
- return {
663
- rawRows: [],
664
- nextCursor: endLineIndex,
665
- };
666
- }
667
- const startOffset = index.lineStartOffsets[startLineIndex] ?? index.size;
668
- const endOffset = endLineIndex < totalLines ? (index.lineStartOffsets[endLineIndex] ?? index.size) : index.size;
669
- if (startOffset >= endOffset) {
670
- return {
671
- rawRows: [],
672
- nextCursor: endLineIndex,
673
- };
674
- }
675
- const fileHandle = await open(resolvedFile, "r");
676
- try {
677
- const readSize = endOffset - startOffset;
678
- const buffer = Buffer.alloc(readSize);
679
- const { bytesRead } = await fileHandle.read(buffer, 0, readSize, startOffset);
680
- const raw = buffer.toString("utf8", 0, bytesRead);
681
- const lines = raw.split(/\r?\n/);
682
- if (lines.length > 0 && lines[lines.length - 1] === "") {
683
- lines.pop();
684
- }
685
- const rawRows = [];
686
- let lineNumber = startLineIndex + 1;
687
- for (const line of lines) {
688
- if (line.trim()) {
689
- const sanitized = await sanitizeSessionRpcRawLine(line, {
690
- workspaceRoot: args.workspaceRoot,
691
- filePath: resolvedFile,
692
- lineNumber,
693
- sessionId: effectiveSessionId,
694
- });
695
- rawRows.push({
696
- id: lineNumber,
697
- raw: sanitized,
698
- });
699
- }
700
- lineNumber += 1;
701
- }
702
- return {
703
- rawRows,
704
- nextCursor: endLineIndex,
705
- };
706
- }
707
- finally {
708
- await fileHandle.close().catch(() => undefined);
709
- }
710
- }
711
- function resolveSessionUploadsDir(workspaceRoot, sessionId) {
712
- const safeSessionId = sanitizeSessionPathSegment(sessionId, "session");
713
- return path.join(workspaceRoot, ".doer-agent", "sessions", safeSessionId);
714
- }
715
- async function deleteAgentSession(workspaceRoot, filePath, sessionId) {
716
- const resolvedFile = resolveSessionFilePath(workspaceRoot, filePath);
717
- sessionLineIndexCache.delete(resolvedFile);
718
- await unlink(resolvedFile);
719
- if (sessionId) {
720
- await rm(resolveSessionUploadsDir(workspaceRoot, sessionId), { recursive: true, force: true }).catch(() => undefined);
721
- }
722
- const sessionsRoot = path.resolve(getSessionsRootPath(workspaceRoot));
723
- let currentDir = path.dirname(resolvedFile);
724
- while (currentDir.startsWith(sessionsRoot + path.sep)) {
725
- try {
726
- const entries = await readdir(currentDir);
727
- if (entries.length > 0) {
728
- break;
729
- }
730
- await rmdir(currentDir);
731
- }
732
- catch {
733
- break;
734
- }
735
- currentDir = path.dirname(currentDir);
736
- }
737
- }
738
- function publishSessionRpcResponse(args) {
739
- try {
740
- args.nc.publish(args.responseSubject, sessionRpcCodec.encode(JSON.stringify(args.payload)));
741
- }
742
- catch (error) {
743
- const message = error instanceof Error ? error.message : String(error);
744
- args.onError(`session rpc publish failed responseSubject=${args.responseSubject}: ${message}`);
745
- }
746
- }
747
- export function stopAllSessionWatchers(args) {
748
- const stops = [...activeSessionWatchers.values()];
749
- for (const stop of stops) {
750
- try {
751
- stop();
752
- }
753
- catch (error) {
754
- const message = error instanceof Error ? error.message : String(error);
755
- args.onError(`session watcher cleanup failed: ${message}`);
756
- }
757
- }
758
- }
759
- async function startSessionWatch(args) {
760
- const resolvedFile = resolveSessionFilePath(args.workspaceRoot, args.filePath);
761
- const canonicalFile = await realpath(resolvedFile).catch(() => resolvedFile);
762
- const watchId = `watch_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
763
- let watcher = null;
764
- let active = true;
765
- let eventCount = 0;
766
- const emitEvent = (event) => {
767
- if (!active) {
768
- return;
769
- }
770
- publishSessionRpcResponse({
771
- nc: args.nc,
772
- responseSubject: args.responseSubject,
773
- payload: {
774
- requestId: args.requestId,
775
- ok: true,
776
- action: "watch",
777
- watchId,
778
- event,
779
- },
780
- onError: args.onError,
781
- });
782
- };
783
- const cleanup = () => {
784
- if (!active) {
785
- return;
786
- }
787
- active = false;
788
- activeSessionWatchers.delete(watchId);
789
- watcher?.close();
790
- watcher = null;
791
- };
792
- const notifyFromContent = () => {
793
- eventCount += 1;
794
- emitEvent({
795
- type: "messages.changed",
796
- at: args.formatTimestamp(),
797
- });
798
- };
799
- watcher = watch(canonicalFile, { persistent: false }, (eventType) => {
800
- if (!active) {
801
- return;
802
- }
803
- if (eventType === "change" || eventType === "rename") {
804
- notifyFromContent();
805
- }
806
- });
807
- watcher.on("error", () => undefined);
808
- activeSessionWatchers.set(watchId, cleanup);
809
- emitEvent({ type: "stream.started", watchId, at: args.formatTimestamp() });
810
- return watchId;
811
- }
812
- async function handleSessionRpcMessage(args) {
813
- let requestId = "unknown";
814
- let responseSubject = "";
815
- try {
816
- const payload = JSON.parse(sessionRpcCodec.decode(args.msg.data));
817
- const request = normalizeSessionRpcRequest({ request: payload, agentId: args.agentId });
818
- requestId = request.requestId;
819
- responseSubject = request.responseSubject;
820
- if (request.action === "list") {
821
- const sessions = await listAgentSessions(args.workspaceRoot);
822
- publishSessionRpcResponse({
823
- nc: args.nc,
824
- responseSubject,
825
- payload: { requestId, ok: true, action: "list", sessions },
826
- onError: args.onError,
827
- });
828
- return;
829
- }
830
- if (request.action === "messages") {
831
- const result = await getAgentSessionRawRows({
832
- workspaceRoot: args.workspaceRoot,
833
- filePath: request.filePath ?? "",
834
- sessionId: request.sessionId,
835
- sinceLine: request.sinceLine,
836
- beforeRowId: request.beforeRowId,
837
- pageSize: request.pageSize,
838
- });
839
- publishSessionRpcResponse({
840
- nc: args.nc,
841
- responseSubject,
842
- payload: { requestId, ok: true, action: "messages", rawRows: result.rawRows, nextCursor: result.nextCursor },
843
- onError: args.onError,
844
- });
845
- return;
846
- }
847
- if (request.action === "delete") {
848
- await deleteAgentSession(args.workspaceRoot, request.filePath ?? "", request.sessionId);
849
- publishSessionRpcResponse({
850
- nc: args.nc,
851
- responseSubject,
852
- payload: { requestId, ok: true, action: "delete" },
853
- onError: args.onError,
854
- });
855
- return;
856
- }
857
- if (request.action === "watch") {
858
- const watchId = await startSessionWatch({
859
- nc: args.nc,
860
- requestId,
861
- responseSubject,
862
- filePath: request.filePath ?? "",
863
- workspaceRoot: args.workspaceRoot,
864
- onError: args.onError,
865
- formatTimestamp: args.formatTimestamp,
866
- });
867
- publishSessionRpcResponse({
868
- nc: args.nc,
869
- responseSubject,
870
- payload: { requestId, ok: true, action: "watch", watchId },
871
- onError: args.onError,
872
- });
873
- return;
874
- }
875
- const stop = request.watchId ? activeSessionWatchers.get(request.watchId) : null;
876
- stop?.();
877
- publishSessionRpcResponse({
878
- nc: args.nc,
879
- responseSubject,
880
- payload: { requestId, ok: true, action: "stop_watch", watchId: request.watchId },
881
- onError: args.onError,
882
- });
883
- }
884
- catch (error) {
885
- const message = error instanceof Error ? error.message : String(error);
886
- if (responseSubject) {
887
- publishSessionRpcResponse({
888
- nc: args.nc,
889
- responseSubject,
890
- payload: {
891
- requestId,
892
- ok: false,
893
- error: message,
894
- },
895
- onError: args.onError,
896
- });
897
- }
898
- args.onError(`session rpc failed requestId=${requestId} error=${message}`);
899
- }
900
- }
901
- export function subscribeToSessionRpc(args) {
902
- args.nc.subscribe(args.subject, {
903
- callback: (error, msg) => {
904
- if (error) {
905
- const message = error instanceof Error ? error.message : String(error);
906
- args.onError(`session rpc subscription error: ${message}`);
907
- return;
908
- }
909
- void handleSessionRpcMessage({
910
- msg,
911
- nc: args.nc,
912
- agentId: args.agentId,
913
- workspaceRoot: args.workspaceRoot,
914
- onError: args.onError,
915
- formatTimestamp: args.formatTimestamp,
916
- });
917
- },
918
- });
919
- args.onInfo(`session rpc subscribed subject=${args.subject}`);
920
- }
921
- export async function findSessionFilePathBySessionId(workspaceRoot, sessionId) {
922
- const targetSessionId = sessionId.trim();
923
- if (!targetSessionId) {
924
- return null;
925
- }
926
- const sessionsRoot = getSessionsRootPath(workspaceRoot);
927
- let sessionsRootStat;
928
- try {
929
- sessionsRootStat = await stat(sessionsRoot);
930
- }
931
- catch {
932
- return null;
933
- }
934
- if (!sessionsRootStat.isDirectory()) {
935
- return null;
936
- }
937
- const files = await collectSessionJsonlFiles(workspaceRoot);
938
- files.sort((a, b) => b.mtimeMs - a.mtimeMs || a.filePath.localeCompare(b.filePath));
939
- for (const file of files) {
940
- let fileHandle = null;
941
- try {
942
- fileHandle = await open(file.filePath, "r");
943
- const entryStat = await fileHandle.stat();
944
- const firstLine = await readFirstLine(fileHandle, entryStat.size);
945
- if (!firstLine) {
946
- continue;
947
- }
948
- const parsed = JSON.parse(firstLine);
949
- const candidateMeta = parsed && parsed.type === "session_meta" && isObjectRecord(parsed.payload)
950
- ? parsed.payload
951
- : isObjectRecord(parsed.session_meta)
952
- ? parsed.session_meta
953
- : isObjectRecord(parsed.sessionMeta)
954
- ? parsed.sessionMeta
955
- : isObjectRecord(parsed.meta)
956
- ? parsed.meta
957
- : isObjectRecord(parsed.payload)
958
- ? parsed.payload
959
- : parsed;
960
- const candidateId = pickSessionString(candidateMeta.sessionId, candidateMeta.session_id, candidateMeta.id);
961
- if (candidateId === targetSessionId) {
962
- return file.filePath;
963
- }
964
- }
965
- catch {
966
- // ignore malformed session files
967
- }
968
- finally {
969
- await fileHandle?.close().catch(() => undefined);
970
- }
971
- }
972
- return null;
973
- }
974
- export async function readSessionIdFromSessionFile(filePath) {
975
- let fileHandle = null;
976
- try {
977
- fileHandle = await open(filePath, "r");
978
- const entryStat = await fileHandle.stat();
979
- const firstLine = await readFirstLine(fileHandle, entryStat.size);
980
- if (!firstLine) {
981
- return null;
982
- }
983
- const parsed = JSON.parse(firstLine);
984
- const candidateMeta = parsed && parsed.type === "session_meta" && isObjectRecord(parsed.payload)
985
- ? parsed.payload
986
- : isObjectRecord(parsed.session_meta)
987
- ? parsed.session_meta
988
- : isObjectRecord(parsed.sessionMeta)
989
- ? parsed.sessionMeta
990
- : isObjectRecord(parsed.meta)
991
- ? parsed.meta
992
- : isObjectRecord(parsed.payload)
993
- ? parsed.payload
994
- : parsed;
995
- return pickSessionString(candidateMeta.sessionId, candidateMeta.session_id, candidateMeta.id);
996
- }
997
- catch {
998
- return null;
999
- }
1000
- finally {
1001
- await fileHandle?.close().catch(() => undefined);
1002
- }
1003
- }
1004
- export async function detectPendingRunSession(workspaceRoot, knownFilePaths) {
1005
- const sessionsRoot = getSessionsRootPath(workspaceRoot);
1006
- let sessionsRootStat;
1007
- try {
1008
- sessionsRootStat = await stat(sessionsRoot);
1009
- }
1010
- catch {
1011
- return null;
1012
- }
1013
- if (!sessionsRootStat.isDirectory()) {
1014
- return null;
1015
- }
1016
- const files = await collectSessionJsonlFiles(workspaceRoot);
1017
- files.sort((a, b) => a.mtimeMs - b.mtimeMs || a.filePath.localeCompare(b.filePath));
1018
- for (const file of files) {
1019
- if (knownFilePaths.has(file.filePath)) {
1020
- continue;
1021
- }
1022
- const sessionId = await readSessionIdFromSessionFile(file.filePath);
1023
- if (!sessionId) {
1024
- continue;
1025
- }
1026
- knownFilePaths.add(file.filePath);
1027
- return {
1028
- sessionId,
1029
- sessionFilePath: file.filePath,
1030
- };
1031
- }
1032
- return null;
1033
- }