doer-agent 0.4.2 → 0.4.3

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