@imdeadpool/guardex 7.0.19 → 7.0.21

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.
@@ -5,8 +5,30 @@ const cp = require('node:child_process');
5
5
  const ACTIVE_SESSIONS_RELATIVE_DIR = path.join('.omx', 'state', 'active-sessions');
6
6
  const SESSION_SCHEMA_VERSION = 1;
7
7
  const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json');
8
+ const AGENT_WORKTREE_LOCK_FILE = 'AGENT.lock';
9
+ const MANAGED_WORKTREE_ROOTS = [
10
+ path.join('.omx', 'agent-worktrees'),
11
+ path.join('.omc', 'agent-worktrees'),
12
+ ];
8
13
  const MAX_CHANGED_PATH_PREVIEW = 3;
9
14
  const ACTIVE_SESSIONS_FILTER_PREFIX = ACTIVE_SESSIONS_RELATIVE_DIR.split(path.sep).join('/');
15
+ const LOCK_FILE_FILTER_PATH = LOCK_FILE_RELATIVE.split(path.sep).join('/');
16
+ const IDLE_ACTIVITY_WINDOW_MS = 2 * 60 * 1000;
17
+ const STALLED_ACTIVITY_WINDOW_MS = 15 * 60 * 1000;
18
+ const BLOCKING_GIT_STATES = [
19
+ {
20
+ label: 'Rebase in progress.',
21
+ markers: ['REBASE_HEAD', 'rebase-apply', 'rebase-merge'],
22
+ },
23
+ {
24
+ label: 'Merge in progress.',
25
+ markers: ['MERGE_HEAD'],
26
+ },
27
+ {
28
+ label: 'Cherry-pick in progress.',
29
+ markers: ['CHERRY_PICK_HEAD'],
30
+ },
31
+ ];
10
32
 
11
33
  function toNonEmptyString(value, fallback = '') {
12
34
  const normalized = typeof value === 'string' ? value.trim() : String(value || '').trim();
@@ -18,6 +40,16 @@ function toPositiveInteger(value) {
18
40
  return Number.isInteger(normalized) && normalized > 0 ? normalized : null;
19
41
  }
20
42
 
43
+ function normalizeTaskMode(value) {
44
+ const normalized = toNonEmptyString(value).toLowerCase();
45
+ return normalized === 'caveman' || normalized === 'omx' ? normalized : '';
46
+ }
47
+
48
+ function normalizeOpenSpecTier(value) {
49
+ const normalized = toNonEmptyString(value).toUpperCase();
50
+ return ['T0', 'T1', 'T2', 'T3'].includes(normalized) ? normalized : '';
51
+ }
52
+
21
53
  function sanitizeBranchForFile(branch) {
22
54
  const normalized = toNonEmptyString(branch, 'session');
23
55
  return normalized.replace(/[^a-zA-Z0-9._-]+/g, '__').replace(/^_+|_+$/g, '') || 'session';
@@ -35,6 +67,10 @@ function sessionFilePathForBranch(repoRoot, branch) {
35
67
  return path.join(activeSessionsDirForRepo(repoRoot), sessionFileNameForBranch(branch));
36
68
  }
37
69
 
70
+ function resolveManagedWorktreeRoots(repoRoot) {
71
+ return MANAGED_WORKTREE_ROOTS.map((relativeRoot) => path.join(path.resolve(repoRoot), relativeRoot));
72
+ }
73
+
38
74
  function splitOutputLines(output) {
39
75
  if (typeof output !== 'string') {
40
76
  return null;
@@ -45,6 +81,28 @@ function splitOutputLines(output) {
45
81
  .filter((line) => line.trim().length > 0);
46
82
  }
47
83
 
84
+ function normalizeRelativePath(value) {
85
+ return toNonEmptyString(value).replace(/\\/g, '/').replace(/^\.\//, '');
86
+ }
87
+
88
+ function readJsonFile(filePath) {
89
+ try {
90
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
91
+ } catch (_error) {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ function normalizeIsoString(value, fallback = '') {
97
+ const normalized = toNonEmptyString(value);
98
+ if (!normalized) {
99
+ return fallback;
100
+ }
101
+
102
+ const timestamp = Date.parse(normalized);
103
+ return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : fallback;
104
+ }
105
+
48
106
  function runGitLines(worktreePath, args) {
49
107
  try {
50
108
  const output = cp.execFileSync('git', ['-C', worktreePath, ...args], {
@@ -150,7 +208,9 @@ function parseRepoChangeLine(repoRoot, line) {
150
208
 
151
209
  const normalizedRelativePath = relativePath.split(path.sep).join('/');
152
210
  if (
153
- normalizedRelativePath === ACTIVE_SESSIONS_FILTER_PREFIX
211
+ normalizedRelativePath === LOCK_FILE_FILTER_PATH
212
+ || normalizedRelativePath.startsWith(`${LOCK_FILE_FILTER_PATH}/`)
213
+ || normalizedRelativePath === ACTIVE_SESSIONS_FILTER_PREFIX
154
214
  || normalizedRelativePath.startsWith(`${ACTIVE_SESSIONS_FILTER_PREFIX}/`)
155
215
  ) {
156
216
  return null;
@@ -167,8 +227,8 @@ function parseRepoChangeLine(repoRoot, line) {
167
227
 
168
228
  function collectWorktreeChangedPaths(worktreePath) {
169
229
  const changedGroups = [
170
- runGitLines(worktreePath, ['diff', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`]),
171
- runGitLines(worktreePath, ['diff', '--cached', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`]),
230
+ runGitLines(worktreePath, ['diff', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`, `:(exclude)${AGENT_WORKTREE_LOCK_FILE}`]),
231
+ runGitLines(worktreePath, ['diff', '--cached', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`, `:(exclude)${AGENT_WORKTREE_LOCK_FILE}`]),
172
232
  runGitLines(worktreePath, ['ls-files', '--others', '--exclude-standard']),
173
233
  ];
174
234
 
@@ -177,41 +237,196 @@ function collectWorktreeChangedPaths(worktreePath) {
177
237
  }
178
238
 
179
239
  return [...new Set(changedGroups.flat())]
180
- .filter((relativePath) => relativePath && relativePath !== LOCK_FILE_RELATIVE)
240
+ .filter((relativePath) => (
241
+ relativePath
242
+ && relativePath !== LOCK_FILE_RELATIVE
243
+ && relativePath !== AGENT_WORKTREE_LOCK_FILE
244
+ ))
245
+ .sort((left, right) => left.localeCompare(right));
246
+ }
247
+
248
+ function resolveWorktreeGitDir(worktreePath) {
249
+ const gitPath = path.join(path.resolve(worktreePath), '.git');
250
+ try {
251
+ if (fs.statSync(gitPath).isDirectory()) {
252
+ return gitPath;
253
+ }
254
+ } catch (_error) {
255
+ return null;
256
+ }
257
+
258
+ try {
259
+ const gitPointer = fs.readFileSync(gitPath, 'utf8');
260
+ const match = gitPointer.match(/^gitdir:\s*(.+)$/m);
261
+ if (match?.[1]) {
262
+ return path.resolve(worktreePath, match[1].trim());
263
+ }
264
+ } catch (_error) {
265
+ return null;
266
+ }
267
+
268
+ return null;
269
+ }
270
+
271
+ function deriveBlockingGitLabel(worktreePath) {
272
+ const gitDir = resolveWorktreeGitDir(worktreePath);
273
+ if (!gitDir) {
274
+ return '';
275
+ }
276
+
277
+ for (const blockingState of BLOCKING_GIT_STATES) {
278
+ if (blockingState.markers.some((marker) => fs.existsSync(path.join(gitDir, marker)))) {
279
+ return blockingState.label;
280
+ }
281
+ }
282
+
283
+ return '';
284
+ }
285
+
286
+ function collectWorktreeTrackedPaths(worktreePath) {
287
+ const trackedPaths = runGitLines(worktreePath, ['ls-files', '--cached', '--others', '--exclude-standard']);
288
+ if (!trackedPaths) {
289
+ return null;
290
+ }
291
+
292
+ return [...new Set(trackedPaths)]
293
+ .filter(Boolean)
181
294
  .sort((left, right) => left.localeCompare(right));
182
295
  }
183
296
 
184
- function deriveSessionActivity(session) {
185
- const changedPaths = collectWorktreeChangedPaths(session.worktreePath);
186
- if (!changedPaths) {
297
+ function deriveLatestWorktreeFileActivity(worktreePath) {
298
+ const trackedPaths = collectWorktreeTrackedPaths(worktreePath);
299
+ if (!trackedPaths) {
300
+ return null;
301
+ }
302
+
303
+ let latestMtimeMs = null;
304
+ for (const relativePath of trackedPaths) {
305
+ const absolutePath = path.join(worktreePath, relativePath);
306
+ try {
307
+ const stats = fs.statSync(absolutePath);
308
+ if (!stats.isFile() || !Number.isFinite(stats.mtimeMs)) {
309
+ continue;
310
+ }
311
+ latestMtimeMs = latestMtimeMs === null
312
+ ? stats.mtimeMs
313
+ : Math.max(latestMtimeMs, stats.mtimeMs);
314
+ } catch (_error) {
315
+ continue;
316
+ }
317
+ }
318
+
319
+ return latestMtimeMs;
320
+ }
321
+
322
+ function deriveSessionActivity(session, options = {}) {
323
+ const now = Number.isFinite(options.now) ? options.now : Date.now();
324
+ const pid = toPositiveInteger(session?.pid);
325
+ const pidAlive = pid ? isPidAlive(pid) : null;
326
+ const blockingLabel = deriveBlockingGitLabel(session.worktreePath);
327
+ if (blockingLabel) {
187
328
  return {
188
- activityKind: 'thinking',
189
- activityLabel: 'thinking',
329
+ activityKind: 'blocked',
330
+ activityLabel: 'blocked',
331
+ activityCountLabel: '',
332
+ activitySummary: blockingLabel,
333
+ changeCount: 0,
334
+ changedPaths: [],
335
+ pidAlive,
336
+ lastFileActivityAt: '',
337
+ lastFileActivityLabel: '',
338
+ };
339
+ }
340
+
341
+ if (pid && !pidAlive) {
342
+ return {
343
+ activityKind: 'dead',
344
+ activityLabel: 'dead',
345
+ activityCountLabel: '',
346
+ activitySummary: 'Recorded PID is not alive.',
347
+ changeCount: 0,
348
+ changedPaths: [],
349
+ pidAlive,
350
+ lastFileActivityAt: '',
351
+ lastFileActivityLabel: '',
352
+ };
353
+ }
354
+
355
+ const worktreeChangedPaths = collectWorktreeChangedPaths(session.worktreePath);
356
+ if (!worktreeChangedPaths) {
357
+ return {
358
+ activityKind: 'idle',
359
+ activityLabel: 'idle',
190
360
  activityCountLabel: '',
191
361
  activitySummary: 'Worktree activity unavailable.',
192
362
  changeCount: 0,
193
363
  changedPaths: [],
364
+ pidAlive,
365
+ lastFileActivityAt: '',
366
+ lastFileActivityLabel: '',
194
367
  };
195
368
  }
196
369
 
197
- if (changedPaths.length === 0) {
370
+ if (worktreeChangedPaths.length > 0) {
371
+ const changedPaths = [...new Set(worktreeChangedPaths
372
+ .map((relativePath) => normalizeRelativePath(
373
+ path.relative(session.repoRoot, path.resolve(session.worktreePath, relativePath)),
374
+ ))
375
+ .filter(Boolean))]
376
+ .sort((left, right) => left.localeCompare(right));
377
+
198
378
  return {
199
- activityKind: 'thinking',
200
- activityLabel: 'thinking',
379
+ activityKind: 'working',
380
+ activityLabel: 'working',
381
+ activityCountLabel: formatFileCount(worktreeChangedPaths.length),
382
+ activitySummary: previewChangedPaths(worktreeChangedPaths),
383
+ changeCount: worktreeChangedPaths.length,
384
+ changedPaths,
385
+ pidAlive,
386
+ lastFileActivityAt: '',
387
+ lastFileActivityLabel: '',
388
+ };
389
+ }
390
+
391
+ const latestFileActivityMs = deriveLatestWorktreeFileActivity(session.worktreePath);
392
+ const lastFileActivityAt = Number.isFinite(latestFileActivityMs)
393
+ ? new Date(latestFileActivityMs).toISOString()
394
+ : '';
395
+ const lastFileActivityLabel = lastFileActivityAt
396
+ ? formatElapsedFrom(lastFileActivityAt, now)
397
+ : '';
398
+ const lastFileActivityAgeMs = Number.isFinite(latestFileActivityMs)
399
+ ? Math.max(0, now - latestFileActivityMs)
400
+ : null;
401
+
402
+ if (lastFileActivityAgeMs !== null && lastFileActivityAgeMs > STALLED_ACTIVITY_WINDOW_MS) {
403
+ return {
404
+ activityKind: 'stalled',
405
+ activityLabel: 'stalled',
201
406
  activityCountLabel: '',
202
- activitySummary: 'Worktree clean.',
407
+ activitySummary: `Worktree clean. No file activity for ${lastFileActivityLabel}.`,
203
408
  changeCount: 0,
204
409
  changedPaths: [],
410
+ pidAlive,
411
+ lastFileActivityAt,
412
+ lastFileActivityLabel,
205
413
  };
206
414
  }
207
415
 
208
416
  return {
209
- activityKind: 'working',
210
- activityLabel: 'working',
211
- activityCountLabel: formatFileCount(changedPaths.length),
212
- activitySummary: previewChangedPaths(changedPaths),
213
- changeCount: changedPaths.length,
214
- changedPaths,
417
+ activityKind: 'idle',
418
+ activityLabel: 'idle',
419
+ activityCountLabel: '',
420
+ activitySummary: lastFileActivityAgeMs !== null && lastFileActivityAgeMs <= IDLE_ACTIVITY_WINDOW_MS
421
+ ? `Worktree clean. Recent file activity ${lastFileActivityLabel} ago.`
422
+ : lastFileActivityLabel
423
+ ? `Worktree clean. Last file activity ${lastFileActivityLabel} ago.`
424
+ : 'Worktree clean.',
425
+ changeCount: 0,
426
+ changedPaths: [],
427
+ pidAlive,
428
+ lastFileActivityAt,
429
+ lastFileActivityLabel,
215
430
  };
216
431
  }
217
432
 
@@ -243,10 +458,14 @@ function buildSessionRecord(input) {
243
458
  repoRoot,
244
459
  branch,
245
460
  taskName: toNonEmptyString(input.taskName, 'task'),
461
+ latestTaskPreview: '',
246
462
  agentName: toNonEmptyString(input.agentName, 'agent'),
247
463
  worktreePath,
248
464
  pid,
249
465
  cliName: toNonEmptyString(input.cliName, 'codex'),
466
+ taskMode: normalizeTaskMode(input.taskMode),
467
+ openspecTier: normalizeOpenSpecTier(input.openspecTier),
468
+ taskRoutingReason: toNonEmptyString(input.taskRoutingReason),
250
469
  startedAt: startedAt.toISOString(),
251
470
  };
252
471
  }
@@ -279,13 +498,24 @@ function normalizeSessionRecord(input, options = {}) {
279
498
  repoRoot: path.resolve(repoRoot),
280
499
  branch,
281
500
  taskName: toNonEmptyString(input.taskName, 'task'),
501
+ latestTaskPreview: '',
282
502
  agentName: toNonEmptyString(input.agentName, 'agent'),
283
503
  worktreePath: path.resolve(worktreePath),
284
504
  pid,
285
505
  cliName: toNonEmptyString(input.cliName, 'codex'),
506
+ taskMode: normalizeTaskMode(input.taskMode),
507
+ openspecTier: normalizeOpenSpecTier(input.openspecTier),
508
+ taskRoutingReason: toNonEmptyString(input.taskRoutingReason),
286
509
  startedAt: startedAt.toISOString(),
287
510
  filePath: toNonEmptyString(options.filePath),
288
511
  label: deriveSessionLabel(branch, worktreePath),
512
+ changedPaths: [],
513
+ sourceKind: 'active-session',
514
+ telemetryUpdatedAt: '',
515
+ telemetrySource: '',
516
+ lockSnapshotCount: 0,
517
+ lockSessionCount: 0,
518
+ collaboration: false,
289
519
  };
290
520
  }
291
521
 
@@ -327,49 +557,212 @@ function isPidAlive(pid) {
327
557
  }
328
558
  }
329
559
 
330
- function readActiveSessions(repoRoot, options = {}) {
331
- const activeSessionsDir = activeSessionsDirForRepo(repoRoot);
332
- if (!fs.existsSync(activeSessionsDir)) {
333
- return [];
560
+ function readWorktreeBranch(worktreePath) {
561
+ const lines = runGitLines(worktreePath, ['rev-parse', '--abbrev-ref', 'HEAD']);
562
+ return Array.isArray(lines) && typeof lines[0] === 'string' ? lines[0].trim() : '';
563
+ }
564
+
565
+ function deriveAgentNameFromBranch(branch) {
566
+ const parts = toNonEmptyString(branch).split('/').filter(Boolean);
567
+ if (parts.length >= 2 && parts[0] === 'agent') {
568
+ return parts[1];
334
569
  }
570
+ return 'agent';
571
+ }
572
+
573
+ function flattenTelemetrySnapshotSessions(lockPayload) {
574
+ const flattened = [];
575
+ const snapshots = Array.isArray(lockPayload?.snapshots) ? lockPayload.snapshots : [];
576
+ for (const snapshot of snapshots) {
577
+ const snapshotSessions = Array.isArray(snapshot?.sessions) ? snapshot.sessions : [];
578
+ for (const session of snapshotSessions) {
579
+ flattened.push({
580
+ taskPreview: toNonEmptyString(session?.taskPreview),
581
+ taskUpdatedAt: normalizeIsoString(session?.taskUpdatedAt),
582
+ projectName: toNonEmptyString(session?.projectName),
583
+ projectPath: toNonEmptyString(session?.projectPath),
584
+ snapshotName: toNonEmptyString(snapshot?.snapshotName),
585
+ email: toNonEmptyString(snapshot?.email),
586
+ });
587
+ }
588
+ }
589
+ return flattened;
590
+ }
335
591
 
592
+ function sortSessionsByTimestamp(sessions) {
593
+ sessions.sort((left, right) => {
594
+ const timeDelta = Date.parse(right.startedAt) - Date.parse(left.startedAt);
595
+ if (timeDelta !== 0) {
596
+ return timeDelta;
597
+ }
598
+ return left.label.localeCompare(right.label);
599
+ });
600
+ return sessions;
601
+ }
602
+
603
+ function deriveLockTaskAnchor(entries, fallbackTaskName, fallbackTimestamp) {
604
+ const sortedEntries = [...entries].sort((left, right) => {
605
+ const timeDelta = Date.parse(right.taskUpdatedAt || '') - Date.parse(left.taskUpdatedAt || '');
606
+ if (timeDelta !== 0) {
607
+ return timeDelta;
608
+ }
609
+ if (Boolean(right.taskPreview) !== Boolean(left.taskPreview)) {
610
+ return Number(Boolean(right.taskPreview)) - Number(Boolean(left.taskPreview));
611
+ }
612
+ return (right.projectPath || '').localeCompare(left.projectPath || '');
613
+ });
614
+
615
+ const latestEntry = sortedEntries[0] || null;
616
+ return {
617
+ taskName: latestEntry?.taskPreview || fallbackTaskName || 'task',
618
+ latestTaskPreview: latestEntry?.taskPreview || '',
619
+ timestamp: latestEntry?.taskUpdatedAt || fallbackTimestamp || '',
620
+ };
621
+ }
622
+
623
+ function buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options = {}) {
336
624
  const now = options.now || Date.now();
625
+ const telemetryEntries = flattenTelemetrySnapshotSessions(lockPayload);
626
+ const telemetryUpdatedAt = normalizeIsoString(lockPayload?.updatedAt);
627
+ const branch = readWorktreeBranch(worktreePath);
628
+ const effectiveBranch = branch && branch !== 'HEAD'
629
+ ? branch
630
+ : `agent/telemetry/${path.basename(worktreePath)}`;
631
+ const label = deriveSessionLabel(effectiveBranch, worktreePath);
632
+ const taskAnchor = deriveLockTaskAnchor(telemetryEntries, label, telemetryUpdatedAt);
633
+ const startedAt = taskAnchor.timestamp || telemetryUpdatedAt || new Date(now).toISOString();
634
+
635
+ const session = {
636
+ schemaVersion: toPositiveInteger(lockPayload?.schemaVersion) || SESSION_SCHEMA_VERSION,
637
+ repoRoot: path.resolve(repoRoot),
638
+ branch: effectiveBranch,
639
+ taskName: taskAnchor.taskName,
640
+ latestTaskPreview: taskAnchor.latestTaskPreview,
641
+ agentName: deriveAgentNameFromBranch(effectiveBranch),
642
+ worktreePath: path.resolve(worktreePath),
643
+ pid: null,
644
+ cliName: 'codex',
645
+ taskMode: '',
646
+ openspecTier: '',
647
+ taskRoutingReason: '',
648
+ startedAt,
649
+ filePath: path.join(worktreePath, AGENT_WORKTREE_LOCK_FILE),
650
+ label,
651
+ changedPaths: [],
652
+ sourceKind: 'worktree-lock',
653
+ telemetryUpdatedAt: telemetryUpdatedAt || startedAt,
654
+ telemetrySource: toNonEmptyString(lockPayload?.source, 'worktree-lock'),
655
+ lockSnapshotCount: toPositiveInteger(lockPayload?.snapshotCount) || 0,
656
+ lockSessionCount: toPositiveInteger(lockPayload?.sessionCount) || telemetryEntries.length,
657
+ collaboration: Boolean(lockPayload?.collaboration),
658
+ };
659
+
660
+ session.elapsedLabel = formatElapsedFrom(session.startedAt, now);
661
+ Object.assign(session, deriveSessionActivity(session, { now }));
662
+ return session;
663
+ }
664
+
665
+ function readWorktreeLockSessions(repoRoot, options = {}) {
337
666
  const sessions = [];
338
- for (const entry of fs.readdirSync(activeSessionsDir, { withFileTypes: true })) {
339
- if (!entry.isFile() || !entry.name.endsWith('.json')) {
667
+ for (const managedRoot of resolveManagedWorktreeRoots(repoRoot)) {
668
+ if (!fs.existsSync(managedRoot)) {
340
669
  continue;
341
670
  }
342
671
 
343
- const filePath = path.join(activeSessionsDir, entry.name);
344
- let parsed;
672
+ let entries;
345
673
  try {
346
- parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
674
+ entries = fs.readdirSync(managedRoot, { withFileTypes: true });
347
675
  } catch (_error) {
348
676
  continue;
349
677
  }
350
678
 
351
- const normalized = normalizeSessionRecord(parsed, { filePath });
352
- if (!normalized) {
353
- continue;
679
+ for (const entry of entries) {
680
+ if (!entry.isDirectory()) {
681
+ continue;
682
+ }
683
+
684
+ const worktreePath = path.join(managedRoot, entry.name);
685
+ const lockPath = path.join(worktreePath, AGENT_WORKTREE_LOCK_FILE);
686
+ if (!fs.existsSync(lockPath)) {
687
+ continue;
688
+ }
689
+
690
+ const lockPayload = readJsonFile(lockPath);
691
+ if (!lockPayload || typeof lockPayload !== 'object' || Array.isArray(lockPayload)) {
692
+ continue;
693
+ }
694
+
695
+ const telemetryEntries = flattenTelemetrySnapshotSessions(lockPayload);
696
+ if (telemetryEntries.length === 0 && !toPositiveInteger(lockPayload.sessionCount)) {
697
+ continue;
698
+ }
699
+
700
+ sessions.push(buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options));
354
701
  }
355
- if (!options.includeStale && !isPidAlive(normalized.pid)) {
702
+ }
703
+
704
+ return sortSessionsByTimestamp(sessions);
705
+ }
706
+
707
+ function mergeSessionSources(primarySessions, lockSessions) {
708
+ const lockSessionsByWorktree = new Map(
709
+ lockSessions.map((session) => [path.resolve(session.worktreePath), session]),
710
+ );
711
+ const consumedLockWorktrees = new Set();
712
+ const merged = [];
713
+
714
+ for (const session of primarySessions) {
715
+ const worktreeKey = path.resolve(session.worktreePath);
716
+ const lockSession = lockSessionsByWorktree.get(worktreeKey);
717
+ if (lockSession && session.activityKind === 'dead') {
356
718
  continue;
357
719
  }
720
+ if (lockSession) {
721
+ consumedLockWorktrees.add(worktreeKey);
722
+ }
723
+ merged.push(session);
724
+ }
358
725
 
359
- normalized.elapsedLabel = formatElapsedFrom(normalized.startedAt, now);
360
- Object.assign(normalized, deriveSessionActivity(normalized));
361
- sessions.push(normalized);
726
+ for (const lockSession of lockSessions) {
727
+ const worktreeKey = path.resolve(lockSession.worktreePath);
728
+ if (!consumedLockWorktrees.has(worktreeKey)) {
729
+ merged.push(lockSession);
730
+ }
362
731
  }
363
732
 
364
- sessions.sort((left, right) => {
365
- const timeDelta = Date.parse(right.startedAt) - Date.parse(left.startedAt);
366
- if (timeDelta !== 0) {
367
- return timeDelta;
733
+ return sortSessionsByTimestamp(merged);
734
+ }
735
+
736
+ function readActiveSessions(repoRoot, options = {}) {
737
+ const activeSessionsDir = activeSessionsDirForRepo(repoRoot);
738
+ const now = options.now || Date.now();
739
+ const sessionFileSessions = [];
740
+ if (fs.existsSync(activeSessionsDir)) {
741
+ for (const entry of fs.readdirSync(activeSessionsDir, { withFileTypes: true })) {
742
+ if (!entry.isFile() || !entry.name.endsWith('.json')) {
743
+ continue;
744
+ }
745
+
746
+ const filePath = path.join(activeSessionsDir, entry.name);
747
+ const parsed = readJsonFile(filePath);
748
+ const normalized = normalizeSessionRecord(parsed, { filePath });
749
+ if (!normalized) {
750
+ continue;
751
+ }
752
+ if (!options.includeStale && !isPidAlive(normalized.pid)) {
753
+ continue;
754
+ }
755
+
756
+ normalized.elapsedLabel = formatElapsedFrom(normalized.startedAt, now);
757
+ Object.assign(normalized, deriveSessionActivity(normalized, { now }));
758
+ sessionFileSessions.push(normalized);
368
759
  }
369
- return left.label.localeCompare(right.label);
370
- });
760
+ }
371
761
 
372
- return sessions;
762
+ return mergeSessionSources(
763
+ sortSessionsByTimestamp(sessionFileSessions),
764
+ readWorktreeLockSessions(repoRoot, { now }),
765
+ );
373
766
  }
374
767
 
375
768
  function readRepoChanges(repoRoot) {
@@ -390,6 +783,9 @@ module.exports = {
390
783
  activeSessionsDirForRepo,
391
784
  buildSessionRecord,
392
785
  collectWorktreeChangedPaths,
786
+ collectWorktreeTrackedPaths,
787
+ deriveBlockingGitLabel,
788
+ deriveLatestWorktreeFileActivity,
393
789
  deriveSessionLabel,
394
790
  deriveSessionActivity,
395
791
  formatElapsedFrom,
@@ -399,8 +795,10 @@ module.exports = {
399
795
  parseRepoChangeLine,
400
796
  previewChangedPaths,
401
797
  readActiveSessions,
798
+ readWorktreeLockSessions,
402
799
  readRepoChanges,
403
800
  deriveRepoChangeStatus,
801
+ resolveWorktreeGitDir,
404
802
  sanitizeBranchForFile,
405
803
  sessionFileNameForBranch,
406
804
  sessionFilePathForBranch,