@adhdev/daemon-core 0.9.82-rc.183 → 0.9.82-rc.185

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.183",
3
+ "version": "0.9.82-rc.185",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -101,16 +101,27 @@ function executeJsonl(src: NativeHistoryJsonlSource, input: NativeHistoryInput):
101
101
  // otherwise look for a matching file; the session-start floor wins
102
102
  // when it's later.
103
103
  const sessionFloor = typeof input.sessionStartedAtMs === 'number' ? input.sessionStartedAtMs : 0;
104
+ // When multiple concurrent CLI sessions in the same workspace each create
105
+ // their own rollout (e.g. two codex-cli sessions both writing into
106
+ // ~/.codex/sessions/{date}), `newestRecentFile` picks the same file for
107
+ // every reader and the daemon sessions cross-alias each other. Prefer
108
+ // session-meta-aware matching: the candidate whose meta.cwd matches the
109
+ // workspace AND whose meta.timestamp is closest to (or within
110
+ // spawnGraceMs of) the daemon's spawn time wins. Falls back to mtime
111
+ // ordering when no candidate exposes a usable session_meta.
112
+ const workspaceHint = typeof input.workspace === 'string' && input.workspace.trim() ? input.workspace.trim() : '';
104
113
  let sourcePath: string | null = null;
105
114
  if (resolved.includes('*')) {
106
- sourcePath = newestRecentFileAcrossGlob(resolved, filePat, windowMs, sessionFloor);
115
+ sourcePath = pickSessionBoundFileAcrossGlob(resolved, filePat, windowMs, sessionFloor, workspaceHint)
116
+ || newestRecentFileAcrossGlob(resolved, filePat, windowMs, sessionFloor);
107
117
  } else {
108
118
  let stat: fs.Stats | null = null;
109
119
  try { stat = fs.statSync(resolved); } catch { /* fall through to date-walk fallback */ }
110
120
  if (stat && stat.isFile()) {
111
121
  sourcePath = resolved;
112
122
  } else if (stat && stat.isDirectory()) {
113
- sourcePath = newestRecentFile(resolved, filePat, windowMs, sessionFloor);
123
+ sourcePath = pickSessionBoundFile(resolved, filePat, windowMs, sessionFloor, workspaceHint)
124
+ || newestRecentFile(resolved, filePat, windowMs, sessionFloor);
114
125
  }
115
126
  // Date-templated directories (e.g. ~/.codex/sessions/{yyyy}/{mm}/{dd})
116
127
  // expand to today's UTC dir. A session started before UTC midnight
@@ -119,7 +130,8 @@ function executeJsonl(src: NativeHistoryJsonlSource, input: NativeHistoryInput):
119
130
  // or doesn't exist yet, walk the last 3 UTC days at the same depth
120
131
  // and pick the newest matching rollout across all of them.
121
132
  if (!sourcePath && hasDateTemplateSegment(src.path)) {
122
- sourcePath = newestRecentFileAcrossDateWindow(src.path, input, filePat, windowMs, sessionFloor);
133
+ sourcePath = pickSessionBoundFileAcrossDateWindow(src.path, input, filePat, windowMs, sessionFloor, workspaceHint)
134
+ || newestRecentFileAcrossDateWindow(src.path, input, filePat, windowMs, sessionFloor);
123
135
  }
124
136
  }
125
137
  if (!sourcePath) return null;
@@ -469,6 +481,161 @@ function safeMtimeMs(p: string): number {
469
481
  try { return Math.floor(fs.statSync(p).mtimeMs); } catch { return 0; }
470
482
  }
471
483
 
484
+ // ────────────────────────────────────────────────────────────────────────────
485
+ // Per-session rollout binding
486
+ //
487
+ // Reads the first JSONL line of a candidate file and returns a `session_meta`
488
+ // payload if present. Codex-cli writes
489
+ // {"timestamp":"...","type":"session_meta","payload":{"id":...,"cwd":...,
490
+ // "timestamp":"..."}}
491
+ // as the first record. Other providers that don't follow this convention
492
+ // return null and fall back to the mtime-based picker.
493
+ // ────────────────────────────────────────────────────────────────────────────
494
+
495
+ interface CandidateMeta {
496
+ cwd?: string;
497
+ sessionTimestampMs?: number;
498
+ }
499
+
500
+ function readCandidateSessionMeta(filePath: string): CandidateMeta | null {
501
+ try {
502
+ // Read only the first line — meta is always the first JSONL record
503
+ // and full file reads here would scale O(files × file_size).
504
+ const fd = fs.openSync(filePath, 'r');
505
+ try {
506
+ const buf = Buffer.alloc(8192);
507
+ const bytes = fs.readSync(fd, buf, 0, buf.length, 0);
508
+ if (bytes <= 0) return null;
509
+ const text = buf.subarray(0, bytes).toString('utf8');
510
+ const nl = text.indexOf('\n');
511
+ const firstLine = (nl >= 0 ? text.slice(0, nl) : text).trim();
512
+ if (!firstLine) return null;
513
+ const parsed = JSON.parse(firstLine) as Record<string, unknown>;
514
+ if (String(parsed.type ?? '') !== 'session_meta') return null;
515
+ const payload = parsed.payload && typeof parsed.payload === 'object'
516
+ ? (parsed.payload as Record<string, unknown>)
517
+ : null;
518
+ if (!payload) return null;
519
+ const cwd = typeof payload.cwd === 'string' ? payload.cwd : undefined;
520
+ const tsRaw = payload.timestamp;
521
+ const tsMs = typeof tsRaw === 'string'
522
+ ? Date.parse(tsRaw)
523
+ : typeof tsRaw === 'number'
524
+ ? (tsRaw < 1e12 ? Math.floor(tsRaw * 1000) : Math.floor(tsRaw))
525
+ : NaN;
526
+ return {
527
+ cwd,
528
+ sessionTimestampMs: Number.isFinite(tsMs) ? tsMs : undefined,
529
+ };
530
+ } finally {
531
+ fs.closeSync(fd);
532
+ }
533
+ } catch {
534
+ return null;
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Spawn-bind grace window: an on-disk rollout whose session_meta.timestamp
540
+ * lands within ±SPAWN_BIND_GRACE_MS of the daemon's spawnedAtMs is treated
541
+ * as belonging to that daemon session. 10s is long enough to absorb codex
542
+ * binary startup latency on cold caches and short enough that two
543
+ * back-to-back launches don't both fall inside the same window.
544
+ */
545
+ const SPAWN_BIND_GRACE_MS = 10_000;
546
+
547
+ function pickBoundFromEntries(
548
+ candidatePaths: string[],
549
+ sessionFloorMs: number,
550
+ workspaceHint: string,
551
+ ): string | null {
552
+ if (!sessionFloorMs || !workspaceHint || candidatePaths.length === 0) return null;
553
+ // Resolve workspaceHint to handle macOS /tmp → /private/tmp aliasing, the
554
+ // same way expandPath does for the template substitution. Without this
555
+ // the daemon's `/Users/foo/repo` and codex's `/private/Users/foo/repo`
556
+ // never compare equal and disambiguation silently fails.
557
+ let workspaceResolved = workspaceHint;
558
+ try { workspaceResolved = fs.realpathSync(workspaceHint); } catch { /* keep raw */ }
559
+ let best: { p: string; diff: number } | null = null;
560
+ for (const p of candidatePaths) {
561
+ const meta = readCandidateSessionMeta(p);
562
+ if (!meta || !meta.cwd || meta.sessionTimestampMs == null) continue;
563
+ let candidateCwd = meta.cwd;
564
+ try { candidateCwd = fs.realpathSync(meta.cwd); } catch { /* keep raw */ }
565
+ if (candidateCwd !== workspaceResolved && meta.cwd !== workspaceHint) continue;
566
+ const diff = Math.abs(meta.sessionTimestampMs - sessionFloorMs);
567
+ if (diff > SPAWN_BIND_GRACE_MS) continue;
568
+ if (!best || diff < best.diff) best = { p, diff };
569
+ }
570
+ return best ? best.p : null;
571
+ }
572
+
573
+ function listMatchingFiles(dir: string, pattern: RegExp): string[] {
574
+ let entries: fs.Dirent[];
575
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
576
+ const out: string[] = [];
577
+ for (const e of entries) {
578
+ if (!e.isFile() || !pattern.test(e.name)) continue;
579
+ out.push(path.join(dir, e.name));
580
+ }
581
+ return out;
582
+ }
583
+
584
+ function pickSessionBoundFile(
585
+ dir: string,
586
+ pattern: RegExp,
587
+ windowMs: number,
588
+ sessionFloorMs: number,
589
+ workspaceHint: string,
590
+ ): string | null {
591
+ if (!sessionFloorMs || !workspaceHint) return null;
592
+ const cutoff = Math.max(Date.now() - windowMs, sessionFloorMs - SPAWN_BIND_GRACE_MS);
593
+ const files = listMatchingFiles(dir, pattern).filter(p => safeMtimeMs(p) >= cutoff);
594
+ return pickBoundFromEntries(files, sessionFloorMs, workspaceHint);
595
+ }
596
+
597
+ function pickSessionBoundFileAcrossGlob(
598
+ template: string,
599
+ pattern: RegExp,
600
+ windowMs: number,
601
+ sessionFloorMs: number,
602
+ workspaceHint: string,
603
+ ): string | null {
604
+ if (!sessionFloorMs || !workspaceHint) return null;
605
+ const dirs = expandDirGlob(template);
606
+ const cutoff = Math.max(Date.now() - windowMs, sessionFloorMs - SPAWN_BIND_GRACE_MS);
607
+ const files: string[] = [];
608
+ for (const d of dirs) {
609
+ for (const p of listMatchingFiles(d, pattern)) {
610
+ if (safeMtimeMs(p) >= cutoff) files.push(p);
611
+ }
612
+ }
613
+ return pickBoundFromEntries(files, sessionFloorMs, workspaceHint);
614
+ }
615
+
616
+ function pickSessionBoundFileAcrossDateWindow(
617
+ template: string,
618
+ input: NativeHistoryInput,
619
+ pattern: RegExp,
620
+ windowMs: number,
621
+ sessionFloorMs: number,
622
+ workspaceHint: string,
623
+ ): string | null {
624
+ if (!sessionFloorMs || !workspaceHint) return null;
625
+ const cutoff = Math.max(Date.now() - windowMs, sessionFloorMs - SPAWN_BIND_GRACE_MS);
626
+ const files: string[] = [];
627
+ for (let dayOffset = 0; dayOffset < 3; dayOffset += 1) {
628
+ const dayMs = sessionFloorMs - dayOffset * 24 * 60 * 60 * 1000;
629
+ const dayInput: NativeHistoryInput = { ...input, sessionStartedAtMs: sessionFloorMs };
630
+ const resolved = expandPathForDate(template, dayInput, new Date(dayMs));
631
+ if (!resolved) continue;
632
+ for (const p of listMatchingFiles(resolved, pattern)) {
633
+ if (safeMtimeMs(p) >= cutoff) files.push(p);
634
+ }
635
+ }
636
+ return pickBoundFromEntries(files, sessionFloorMs, workspaceHint);
637
+ }
638
+
472
639
  // ────────────────────────────────────────────────────────────────────────────
473
640
  // jsonpath-lite + projection
474
641
  // ────────────────────────────────────────────────────────────────────────────