@dreb/coding-agent 2.66.3 → 2.68.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 (70) hide show
  1. package/README.md +4 -2
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +1 -1
  4. package/dist/cli/args.js.map +1 -1
  5. package/dist/config.d.ts +12 -2
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/config.js +27 -4
  8. package/dist/config.js.map +1 -1
  9. package/dist/core/agent-session.d.ts +5 -0
  10. package/dist/core/agent-session.d.ts.map +1 -1
  11. package/dist/core/agent-session.js +16 -1
  12. package/dist/core/agent-session.js.map +1 -1
  13. package/dist/core/daily-cost-tracker.d.ts +22 -6
  14. package/dist/core/daily-cost-tracker.d.ts.map +1 -1
  15. package/dist/core/daily-cost-tracker.js +117 -47
  16. package/dist/core/daily-cost-tracker.js.map +1 -1
  17. package/dist/core/footer-data-provider.d.ts +10 -2
  18. package/dist/core/footer-data-provider.d.ts.map +1 -1
  19. package/dist/core/footer-data-provider.js +17 -1
  20. package/dist/core/footer-data-provider.js.map +1 -1
  21. package/dist/core/sdk.d.ts.map +1 -1
  22. package/dist/core/sdk.js +2 -1
  23. package/dist/core/sdk.js.map +1 -1
  24. package/dist/core/session-manager.d.ts +32 -4
  25. package/dist/core/session-manager.d.ts.map +1 -1
  26. package/dist/core/session-manager.js +234 -108
  27. package/dist/core/session-manager.js.map +1 -1
  28. package/dist/core/settings-manager.d.ts +4 -0
  29. package/dist/core/settings-manager.d.ts.map +1 -1
  30. package/dist/core/settings-manager.js +4 -0
  31. package/dist/core/settings-manager.js.map +1 -1
  32. package/dist/core/subagent-session-cost-tracker.d.ts +22 -0
  33. package/dist/core/subagent-session-cost-tracker.d.ts.map +1 -0
  34. package/dist/core/subagent-session-cost-tracker.js +94 -0
  35. package/dist/core/subagent-session-cost-tracker.js.map +1 -0
  36. package/dist/core/tools/subagent.d.ts +14 -8
  37. package/dist/core/tools/subagent.d.ts.map +1 -1
  38. package/dist/core/tools/subagent.js +145 -71
  39. package/dist/core/tools/subagent.js.map +1 -1
  40. package/dist/index.d.ts +1 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +1 -1
  43. package/dist/index.js.map +1 -1
  44. package/dist/main.d.ts.map +1 -1
  45. package/dist/main.js +16 -7
  46. package/dist/main.js.map +1 -1
  47. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  48. package/dist/modes/interactive/components/footer.js +8 -2
  49. package/dist/modes/interactive/components/footer.js.map +1 -1
  50. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  51. package/dist/modes/interactive/interactive-mode.js +5 -1
  52. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  53. package/dist/modes/rpc/rpc-client.d.ts +7 -2
  54. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  55. package/dist/modes/rpc/rpc-client.js +3 -2
  56. package/dist/modes/rpc/rpc-client.js.map +1 -1
  57. package/dist/modes/rpc/rpc-mode.d.ts +2 -2
  58. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  59. package/dist/modes/rpc/rpc-mode.js +34 -9
  60. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  61. package/dist/modes/rpc/rpc-types.d.ts +2 -0
  62. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  63. package/dist/modes/rpc/rpc-types.js.map +1 -1
  64. package/docs/dashboard.md +27 -15
  65. package/docs/rpc.md +9 -5
  66. package/docs/session.md +16 -7
  67. package/docs/settings.md +17 -5
  68. package/docs/skills.md +1 -1
  69. package/package.json +1 -1
  70. package/skills/model-routing-guide/SKILL.md +10 -5
@@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import { readdir, readFile, stat, unlink } from "node:fs/promises";
3
3
  import { randomUUID } from "crypto";
4
4
  import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, statSync, writeFileSync, } from "fs";
5
- import { join, resolve } from "path";
5
+ import { dirname, join, resolve, sep } from "path";
6
6
  import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.js";
7
7
  import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, } from "./messages.js";
8
8
  export const CURRENT_SESSION_VERSION = 3;
@@ -340,99 +340,197 @@ function getSessionCreatedDate(header, statsMtime) {
340
340
  function resolveForDeletion(filePath) {
341
341
  return resolve(filePath);
342
342
  }
343
- async function buildSessionInfo(filePath) {
344
- try {
345
- const content = await readFile(filePath, "utf8");
346
- const entries = [];
347
- const lines = content.trim().split("\n");
348
- for (const line of lines) {
349
- if (!line.trim())
350
- continue;
351
- try {
352
- entries.push(JSON.parse(line));
353
- }
354
- catch {
355
- // Skip malformed lines
356
- }
343
+ const sessionMetadataCache = new Map();
344
+ const sessionMetadataInflight = new Map();
345
+ function cloneSessionMetadata(metadata) {
346
+ return {
347
+ ...metadata,
348
+ created: new Date(metadata.created),
349
+ modified: new Date(metadata.modified),
350
+ };
351
+ }
352
+ function toSessionMetadata(info) {
353
+ const { allMessagesText: _allMessagesText, ...metadata } = info;
354
+ return metadata;
355
+ }
356
+ async function parseSessionInfo(filePath, stats, includeAllMessagesText) {
357
+ const content = await readFile(filePath, "utf8");
358
+ const entries = [];
359
+ const lines = content.trim().split("\n");
360
+ for (const line of lines) {
361
+ if (!line.trim())
362
+ continue;
363
+ try {
364
+ entries.push(JSON.parse(line));
357
365
  }
358
- if (entries.length === 0)
359
- return null;
360
- const header = entries[0];
361
- if (header.type !== "session")
362
- return null;
363
- const stats = await stat(filePath);
364
- let messageCount = 0;
365
- let firstMessage = "";
366
- const allMessages = [];
367
- let name;
368
- for (const entry of entries) {
369
- // Extract session name (use latest, including explicit clears)
370
- if (entry.type === "session_info") {
371
- const infoEntry = entry;
372
- name = infoEntry.name?.trim() || undefined;
373
- }
374
- if (entry.type !== "message")
375
- continue;
376
- messageCount++;
377
- const message = entry.message;
378
- if (!isMessageWithContent(message))
379
- continue;
380
- if (message.role !== "user" && message.role !== "assistant")
381
- continue;
382
- const textContent = extractTextContent(message);
383
- if (!textContent)
384
- continue;
366
+ catch {
367
+ // Skip malformed lines
368
+ }
369
+ }
370
+ if (entries.length === 0)
371
+ return null;
372
+ const header = entries[0];
373
+ if (header.type !== "session")
374
+ return null;
375
+ let messageCount = 0;
376
+ let firstMessage = "";
377
+ const allMessages = [];
378
+ let name;
379
+ for (const entry of entries) {
380
+ // Extract session name (use latest, including explicit clears)
381
+ if (entry.type === "session_info") {
382
+ const infoEntry = entry;
383
+ name = infoEntry.name?.trim() || undefined;
384
+ }
385
+ if (entry.type !== "message")
386
+ continue;
387
+ messageCount++;
388
+ const message = entry.message;
389
+ if (!isMessageWithContent(message))
390
+ continue;
391
+ if (message.role !== "user" && message.role !== "assistant")
392
+ continue;
393
+ const textContent = extractTextContent(message);
394
+ if (!textContent)
395
+ continue;
396
+ if (includeAllMessagesText)
385
397
  allMessages.push(textContent);
386
- if (!firstMessage && message.role === "user") {
387
- firstMessage = textContent;
388
- }
398
+ if (!firstMessage && message.role === "user") {
399
+ firstMessage = textContent;
400
+ }
401
+ }
402
+ const cwd = typeof header.cwd === "string" ? header.cwd : "";
403
+ const parentSessionPath = header.parentSession;
404
+ const modified = getSessionModifiedDate(entries, header, stats.mtime);
405
+ const created = getSessionCreatedDate(header, stats.mtime);
406
+ return {
407
+ path: filePath,
408
+ id: header.id,
409
+ cwd,
410
+ name,
411
+ parentSessionPath,
412
+ created,
413
+ modified,
414
+ messageCount,
415
+ firstMessage: firstMessage || "(no messages)",
416
+ allMessagesText: allMessages.join(" "),
417
+ };
418
+ }
419
+ function isFilesystemError(error) {
420
+ return typeof error?.code === "string";
421
+ }
422
+ async function buildSessionInfo(filePath, throwOnFilesystemError = false) {
423
+ try {
424
+ const stats = await stat(filePath);
425
+ return await parseSessionInfo(filePath, stats, true);
426
+ }
427
+ catch (error) {
428
+ if (throwOnFilesystemError && isFilesystemError(error))
429
+ throw error;
430
+ /* Corrupt session file — exclude from listing */
431
+ return null;
432
+ }
433
+ }
434
+ async function buildSessionMetadata(filePath, throwOnFilesystemError = false) {
435
+ try {
436
+ const stats = await stat(filePath);
437
+ const cached = sessionMetadataCache.get(filePath);
438
+ if (cached?.mtimeMs === stats.mtimeMs && cached.size === stats.size) {
439
+ return cached.metadata ? cloneSessionMetadata(cached.metadata) : null;
440
+ }
441
+ const inflight = sessionMetadataInflight.get(filePath);
442
+ if (inflight?.mtimeMs === stats.mtimeMs && inflight.size === stats.size) {
443
+ const metadata = await inflight.promise;
444
+ return metadata ? cloneSessionMetadata(metadata) : null;
445
+ }
446
+ const promise = parseSessionInfo(filePath, stats, false).then((info) => {
447
+ const metadata = info ? toSessionMetadata(info) : null;
448
+ sessionMetadataCache.set(filePath, {
449
+ mtimeMs: stats.mtimeMs,
450
+ size: stats.size,
451
+ metadata: metadata ? cloneSessionMetadata(metadata) : null,
452
+ });
453
+ return metadata;
454
+ });
455
+ const current = { mtimeMs: stats.mtimeMs, size: stats.size, promise };
456
+ sessionMetadataInflight.set(filePath, current);
457
+ try {
458
+ const metadata = await promise;
459
+ return metadata ? cloneSessionMetadata(metadata) : null;
460
+ }
461
+ finally {
462
+ if (sessionMetadataInflight.get(filePath) === current)
463
+ sessionMetadataInflight.delete(filePath);
389
464
  }
390
- const cwd = typeof header.cwd === "string" ? header.cwd : "";
391
- const parentSessionPath = header.parentSession;
392
- const modified = getSessionModifiedDate(entries, header, stats.mtime);
393
- const created = getSessionCreatedDate(header, stats.mtime);
394
- return {
395
- path: filePath,
396
- id: header.id,
397
- cwd,
398
- name,
399
- parentSessionPath,
400
- created,
401
- modified,
402
- messageCount,
403
- firstMessage: firstMessage || "(no messages)",
404
- allMessagesText: allMessages.join(" "),
405
- };
406
465
  }
407
- catch {
466
+ catch (error) {
467
+ if (throwOnFilesystemError && isFilesystemError(error))
468
+ throw error;
408
469
  /* Corrupt session file — exclude from listing */
409
470
  return null;
410
471
  }
411
472
  }
412
- async function listSessionsFromDir(dir, onProgress, progressOffset = 0, progressTotal) {
473
+ async function collectSessionMetadata(files, isInScope, onProgress, throwOnFilesystemError = false) {
474
+ // Prune cache entries for deleted files, but only within the scanned scope so
475
+ // default and custom-directory listings don't evict each other's entries.
476
+ const currentFiles = new Set(files);
477
+ for (const cachedPath of sessionMetadataCache.keys()) {
478
+ if (isInScope(cachedPath) && !currentFiles.has(cachedPath))
479
+ sessionMetadataCache.delete(cachedPath);
480
+ }
481
+ let loaded = 0;
482
+ const results = await Promise.all(files.map(async (file) => {
483
+ const metadata = await buildSessionMetadata(file, throwOnFilesystemError);
484
+ loaded++;
485
+ onProgress?.(loaded, files.length);
486
+ return metadata;
487
+ }));
488
+ const sessions = results.filter((metadata) => metadata !== null);
489
+ sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
490
+ return sessions;
491
+ }
492
+ async function listAllSessionFiles() {
493
+ const sessionsDir = getSessionsDir();
494
+ if (!existsSync(sessionsDir))
495
+ return [];
496
+ const entries = await readdir(sessionsDir, { withFileTypes: true });
497
+ const dirs = entries.filter((entry) => entry.isDirectory()).map((entry) => join(sessionsDir, entry.name));
498
+ const dirFiles = await Promise.all(dirs.map(async (dir) => (await readdir(dir)).filter((file) => file.endsWith(".jsonl")).map((file) => join(dir, file))));
499
+ return dirFiles.flat();
500
+ }
501
+ async function listSessionsFromDir(dir, onProgress, progressOffset = 0, progressTotal, throwOnFilesystemError = false) {
413
502
  const sessions = [];
414
- if (!existsSync(dir)) {
503
+ if (!throwOnFilesystemError && !existsSync(dir)) {
415
504
  return sessions;
416
505
  }
506
+ let dirEntries;
507
+ try {
508
+ dirEntries = await readdir(dir);
509
+ }
510
+ catch (error) {
511
+ if (throwOnFilesystemError && error?.code !== "ENOENT")
512
+ throw error;
513
+ return sessions;
514
+ }
515
+ const files = dirEntries.filter((file) => file.endsWith(".jsonl")).map((file) => join(dir, file));
516
+ const total = progressTotal ?? files.length;
417
517
  try {
418
- const dirEntries = await readdir(dir);
419
- const files = dirEntries.filter((f) => f.endsWith(".jsonl")).map((f) => join(dir, f));
420
- const total = progressTotal ?? files.length;
421
518
  let loaded = 0;
422
519
  const results = await Promise.all(files.map(async (file) => {
423
- const info = await buildSessionInfo(file);
520
+ const info = await buildSessionInfo(file, throwOnFilesystemError);
424
521
  loaded++;
425
522
  onProgress?.(progressOffset + loaded, total);
426
523
  return info;
427
524
  }));
428
525
  for (const info of results) {
429
- if (info) {
526
+ if (info)
430
527
  sessions.push(info);
431
- }
432
528
  }
433
529
  }
434
- catch {
435
- /* Session directory read failed — return partial results */
530
+ catch (error) {
531
+ if (throwOnFilesystemError)
532
+ throw error;
533
+ /* Session file read failed — return partial results */
436
534
  }
437
535
  return sessions;
438
536
  }
@@ -451,6 +549,7 @@ export class SessionManager {
451
549
  sessionId = "";
452
550
  sessionFile;
453
551
  sessionDir;
552
+ customSessionInventoryRoot;
454
553
  cwd;
455
554
  persist;
456
555
  flushed = false;
@@ -458,9 +557,10 @@ export class SessionManager {
458
557
  byId = new Map();
459
558
  labelsById = new Map();
460
559
  leafId = null;
461
- constructor(cwd, sessionDir, sessionFile, persist) {
560
+ constructor(cwd, sessionDir, sessionFile, persist, customSessionInventoryRoot) {
462
561
  this.cwd = cwd;
463
562
  this.sessionDir = sessionDir;
563
+ this.customSessionInventoryRoot = customSessionInventoryRoot;
464
564
  this.persist = persist;
465
565
  if (persist && sessionDir && !existsSync(sessionDir)) {
466
566
  mkdirSync(sessionDir, { recursive: true });
@@ -588,6 +688,13 @@ export class SessionManager {
588
688
  getSessionDir() {
589
689
  return this.sessionDir;
590
690
  }
691
+ /**
692
+ * Flat inventory root selected explicitly for this manager, if any.
693
+ * Built-in per-project managers and in-memory managers return undefined.
694
+ */
695
+ getCustomSessionInventoryRoot() {
696
+ return this.customSessionInventoryRoot;
697
+ }
591
698
  getSessionId() {
592
699
  return this.sessionId;
593
700
  }
@@ -1050,9 +1157,10 @@ export class SessionManager {
1050
1157
  * @param cwd Working directory (stored in session header)
1051
1158
  * @param sessionDir Optional session directory. If omitted, uses default (~/.dreb/agent/sessions/<encoded-cwd>/).
1052
1159
  */
1053
- static create(cwd, sessionDir) {
1160
+ static create(cwd, sessionDir, options = {}) {
1054
1161
  const dir = sessionDir ?? getDefaultSessionDir(cwd);
1055
- return new SessionManager(cwd, dir, undefined, true);
1162
+ const customInventoryRoot = sessionDir !== undefined && options.customSessionInventory !== false ? sessionDir : undefined;
1163
+ return new SessionManager(cwd, dir, undefined, true, customInventoryRoot);
1056
1164
  }
1057
1165
  /**
1058
1166
  * Open a specific session file.
@@ -1066,7 +1174,7 @@ export class SessionManager {
1066
1174
  const cwd = header?.cwd ?? process.cwd();
1067
1175
  // If no sessionDir provided, derive from file's parent directory
1068
1176
  const dir = sessionDir ?? resolve(path, "..");
1069
- return new SessionManager(cwd, dir, path, true);
1177
+ return new SessionManager(cwd, dir, path, true, sessionDir);
1070
1178
  }
1071
1179
  /**
1072
1180
  * Continue the most recent session, or create new if none.
@@ -1077,13 +1185,13 @@ export class SessionManager {
1077
1185
  const dir = sessionDir ?? getDefaultSessionDir(cwd);
1078
1186
  const mostRecent = findMostRecentSession(dir);
1079
1187
  if (mostRecent) {
1080
- return new SessionManager(cwd, dir, mostRecent, true);
1188
+ return new SessionManager(cwd, dir, mostRecent, true, sessionDir);
1081
1189
  }
1082
- return new SessionManager(cwd, dir, undefined, true);
1190
+ return new SessionManager(cwd, dir, undefined, true, sessionDir);
1083
1191
  }
1084
- /** Create an in-memory session (no file persistence) */
1085
- static inMemory(cwd = process.cwd()) {
1086
- return new SessionManager(cwd, "", undefined, false);
1192
+ /** Create an in-memory session (no active-file persistence), optionally retaining a flat inventory root. */
1193
+ static inMemory(cwd = process.cwd(), customSessionInventoryRoot) {
1194
+ return new SessionManager(cwd, "", undefined, false, customSessionInventoryRoot);
1087
1195
  }
1088
1196
  /**
1089
1197
  * Fork a session from another project directory into the current project.
@@ -1126,7 +1234,7 @@ export class SessionManager {
1126
1234
  appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
1127
1235
  }
1128
1236
  }
1129
- return new SessionManager(targetCwd, dir, newSessionFile, true);
1237
+ return new SessionManager(targetCwd, dir, newSessionFile, true, sessionDir);
1130
1238
  }
1131
1239
  /**
1132
1240
  * Delete a session file, trying the `trash` CLI first, then falling back to unlink.
@@ -1201,46 +1309,64 @@ export class SessionManager {
1201
1309
  sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
1202
1310
  return sessions;
1203
1311
  }
1312
+ /**
1313
+ * List every session stored directly in one explicit flat directory.
1314
+ * This is intentionally separate from listAll(), whose default contract scans
1315
+ * the built-in nested all-project tree.
1316
+ */
1317
+ static async listAllFromDir(dir, onProgress) {
1318
+ const sessions = await listSessionsFromDir(dir, onProgress, 0, undefined, true);
1319
+ sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
1320
+ return sessions;
1321
+ }
1322
+ /**
1323
+ * List lightweight metadata for every session stored directly in one explicit flat directory.
1324
+ * Uses the same mtime/size cache as listAllMetadata(); filesystem errors propagate.
1325
+ */
1326
+ static async listAllMetadataFromDir(dir, onProgress) {
1327
+ let dirEntries;
1328
+ try {
1329
+ dirEntries = await readdir(dir);
1330
+ }
1331
+ catch (error) {
1332
+ if (error?.code === "ENOENT")
1333
+ return [];
1334
+ throw error;
1335
+ }
1336
+ const files = dirEntries.filter((file) => file.endsWith(".jsonl")).map((file) => join(dir, file));
1337
+ const scope = resolve(dir);
1338
+ return collectSessionMetadata(files, (cachedPath) => dirname(resolve(cachedPath)) === scope, onProgress, true);
1339
+ }
1204
1340
  /**
1205
1341
  * List all sessions across all project directories.
1206
1342
  * @param onProgress Optional callback for progress updates (loaded, total)
1207
1343
  */
1208
1344
  static async listAll(onProgress) {
1209
- const sessionsDir = getSessionsDir();
1210
1345
  // A missing sessions directory is a legitimate empty state (fresh install), not a
1211
- // failure — return []. Any actual I/O failure below propagates loudly so callers can
1212
- // distinguish "no sessions" from "listing failed" (RPC responds success:false; the TUI
1213
- // selector surfaces "Failed to load sessions"). No silent catch-and-return-[].
1214
- if (!existsSync(sessionsDir)) {
1215
- return [];
1216
- }
1217
- const entries = await readdir(sessionsDir, { withFileTypes: true });
1218
- const dirs = entries.filter((e) => e.isDirectory()).map((e) => join(sessionsDir, e.name));
1219
- // Count total files first for accurate progress
1220
- let totalFiles = 0;
1221
- const dirFiles = [];
1222
- for (const dir of dirs) {
1223
- const files = (await readdir(dir)).filter((f) => f.endsWith(".jsonl"));
1224
- dirFiles.push(files.map((f) => join(dir, f)));
1225
- totalFiles += files.length;
1226
- }
1227
- // Process all files with progress tracking
1346
+ // failure. Actual I/O failures propagate so callers can distinguish "no sessions"
1347
+ // from "listing failed".
1348
+ const allFiles = await listAllSessionFiles();
1228
1349
  let loaded = 0;
1229
- const sessions = [];
1230
- const allFiles = dirFiles.flat();
1231
1350
  const results = await Promise.all(allFiles.map(async (file) => {
1232
1351
  const info = await buildSessionInfo(file);
1233
1352
  loaded++;
1234
- onProgress?.(loaded, totalFiles);
1353
+ onProgress?.(loaded, allFiles.length);
1235
1354
  return info;
1236
1355
  }));
1237
- for (const info of results) {
1238
- if (info) {
1239
- sessions.push(info);
1240
- }
1241
- }
1356
+ const sessions = results.filter((info) => info !== null);
1242
1357
  sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
1243
1358
  return sessions;
1244
1359
  }
1360
+ /**
1361
+ * List lightweight metadata across all project directories.
1362
+ *
1363
+ * Results are cached by file mtime and size, so dashboard inventory refreshes
1364
+ * stat every session but only reread and parse files that changed.
1365
+ */
1366
+ static async listAllMetadata(onProgress) {
1367
+ const allFiles = await listAllSessionFiles();
1368
+ const scope = resolve(getSessionsDir()) + sep;
1369
+ return collectSessionMetadata(allFiles, (cachedPath) => resolve(cachedPath).startsWith(scope), onProgress);
1370
+ }
1245
1371
  }
1246
1372
  //# sourceMappingURL=session-manager.js.map