@imdeadpool/guardex 7.0.24 → 7.0.25

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.
@@ -17,17 +17,40 @@ const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json');
17
17
  const ACTIVE_SESSION_FILES_GLOB = '**/.omx/state/active-sessions/*.json';
18
18
  const AGENT_FILE_LOCKS_GLOB = '**/.omx/state/agent-file-locks.json';
19
19
  const WORKTREE_AGENT_LOCKS_GLOB = '**/{.omx,.omc}/agent-worktrees/**/AGENT.lock';
20
+ const MANAGED_WORKTREE_GIT_FILES_GLOB = '**/{.omx,.omc}/agent-worktrees/*/.git';
21
+ const MANAGED_WORKTREE_RELATIVE_ROOTS = [
22
+ path.join('.omx', 'agent-worktrees'),
23
+ path.join('.omc', 'agent-worktrees'),
24
+ ];
20
25
  const AGENT_LOG_FILES_GLOB = '**/.omx/logs/*.log';
21
26
  const SESSION_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git,.omx/agent-worktrees,.omc/agent-worktrees}/**';
22
27
  const WORKTREE_LOCK_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git}/**';
28
+ const MANAGED_WORKTREE_GIT_SCAN_EXCLUDE_GLOB = '**/node_modules/**';
23
29
  const SESSION_SCAN_LIMIT = 200;
24
30
  const REFRESH_DEBOUNCE_MS = 250;
31
+ const RECENTLY_ACTIVE_WINDOW_MS = 10 * 60 * 1000;
32
+ const SESSION_TOP_FILE_COUNT = 3;
25
33
  const ACTIVE_AGENTS_MANIFEST_RELATIVE = path.join('vscode', 'guardex-active-agents', 'package.json');
26
34
  const ACTIVE_AGENTS_INSTALL_SCRIPT_RELATIVE = path.join('scripts', 'install-vscode-active-agents-extension.js');
27
35
  const RELOAD_WINDOW_ACTION = 'Reload Window';
28
36
  const UPDATE_LATER_ACTION = 'Later';
37
+ const ACTIVE_AGENTS_EXTENSION_ID = 'recodeee.gitguardex-active-agents';
38
+ const RESTART_EXTENSION_HOST_COMMAND = 'workbench.action.restartExtensionHost';
29
39
  const REFRESH_POLL_INTERVAL_MS = 30_000;
30
40
  const INSPECT_PANEL_VIEW_TYPE = 'gitguardex.activeAgents.inspect';
41
+ const GIT_CONFIGURATION_SECTION = 'git';
42
+ const REPO_SCAN_IGNORED_FOLDERS_SETTING = 'repositoryScanIgnoredFolders';
43
+ const BUNDLED_FILE_ICONS_MANIFEST_RELATIVE = path.join('fileicons', 'gitguardex-fileicons.json');
44
+ const MANAGED_REPO_SCAN_IGNORED_FOLDERS = [
45
+ '.omx/agent-worktrees',
46
+ '**/.omx/agent-worktrees',
47
+ '.omx/.tmp-worktrees',
48
+ '**/.omx/.tmp-worktrees',
49
+ '.omc/agent-worktrees',
50
+ '**/.omc/agent-worktrees',
51
+ '.omc/.tmp-worktrees',
52
+ '**/.omc/.tmp-worktrees',
53
+ ];
31
54
  const SESSION_ACTIVITY_GROUPS = [
32
55
  { kind: 'blocked', label: 'BLOCKED' },
33
56
  { kind: 'working', label: 'WORKING NOW' },
@@ -42,11 +65,134 @@ const SESSION_ACTIVITY_ICON_IDS = {
42
65
  stalled: 'clock',
43
66
  dead: 'error',
44
67
  };
68
+ const SESSION_PROVIDER_BRANDS = {
69
+ openai: {
70
+ id: 'openai',
71
+ label: 'OpenAI',
72
+ badge: 'AI',
73
+ },
74
+ claude: {
75
+ id: 'claude',
76
+ label: 'Claude',
77
+ badge: 'CL',
78
+ },
79
+ };
80
+ let bundledTreeIconThemeCache = null;
81
+
82
+ function iconColorId(iconId) {
83
+ switch (iconId) {
84
+ case 'warning':
85
+ case 'clock':
86
+ return 'list.warningForeground';
87
+ case 'error':
88
+ return 'list.errorForeground';
89
+ case 'loading~spin':
90
+ return 'gitDecoration.addedResourceForeground';
91
+ case 'comment-discussion':
92
+ case 'info':
93
+ case 'repo':
94
+ case 'folder':
95
+ case 'graph':
96
+ case 'history':
97
+ return 'textLink.foreground';
98
+ case 'git-branch':
99
+ return 'gitDecoration.modifiedResourceForeground';
100
+ case 'account':
101
+ return 'terminal.ansiYellow';
102
+ case 'sparkle':
103
+ return 'terminal.ansiMagenta';
104
+ case 'list-flat':
105
+ return 'terminal.ansiCyan';
106
+ case 'list-tree':
107
+ return 'terminal.ansiBlue';
108
+ default:
109
+ return '';
110
+ }
111
+ }
112
+
113
+ function themeIcon(iconId, colorId = iconColorId(iconId)) {
114
+ if (!iconId) {
115
+ return undefined;
116
+ }
117
+ return colorId
118
+ ? new vscode.ThemeIcon(iconId, new vscode.ThemeColor(colorId))
119
+ : new vscode.ThemeIcon(iconId);
120
+ }
45
121
 
46
122
  function sessionDecorationUri(branch) {
47
123
  return vscode.Uri.parse(`${SESSION_DECORATION_SCHEME}://${sanitizeBranchForFile(branch)}`);
48
124
  }
49
125
 
126
+ function emptyBundledTreeIconTheme() {
127
+ return {
128
+ iconPathById: new Map(),
129
+ fileNames: {},
130
+ folderNames: {},
131
+ fileExtensions: {},
132
+ };
133
+ }
134
+
135
+ function loadBundledTreeIconTheme() {
136
+ if (bundledTreeIconThemeCache) {
137
+ return bundledTreeIconThemeCache;
138
+ }
139
+
140
+ const manifestPath = path.join(__dirname, BUNDLED_FILE_ICONS_MANIFEST_RELATIVE);
141
+ try {
142
+ const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
143
+ const manifestDir = path.dirname(manifestPath);
144
+ const iconPathById = new Map();
145
+ for (const [iconId, definition] of Object.entries(parsed?.iconDefinitions || {})) {
146
+ if (typeof definition?.iconPath !== 'string' || !definition.iconPath.trim()) {
147
+ continue;
148
+ }
149
+ const iconUri = vscode.Uri.file(path.resolve(manifestDir, definition.iconPath));
150
+ iconPathById.set(iconId, {
151
+ light: iconUri,
152
+ dark: iconUri,
153
+ });
154
+ }
155
+ bundledTreeIconThemeCache = {
156
+ iconPathById,
157
+ fileNames: parsed?.fileNames || {},
158
+ folderNames: parsed?.folderNames || {},
159
+ fileExtensions: parsed?.fileExtensions || {},
160
+ };
161
+ } catch (_error) {
162
+ bundledTreeIconThemeCache = emptyBundledTreeIconTheme();
163
+ }
164
+
165
+ return bundledTreeIconThemeCache;
166
+ }
167
+
168
+ function resolveBundledTreeItemIconId(relativePath, kind = 'file') {
169
+ const normalizedRelativePath = normalizeRelativePath(relativePath);
170
+ const entryName = path.posix.basename(normalizedRelativePath || '');
171
+ if (!entryName) {
172
+ return '';
173
+ }
174
+
175
+ const bundledTheme = loadBundledTreeIconTheme();
176
+ if (kind === 'folder') {
177
+ return bundledTheme.folderNames[entryName] || '';
178
+ }
179
+
180
+ if (bundledTheme.fileNames[entryName]) {
181
+ return bundledTheme.fileNames[entryName];
182
+ }
183
+
184
+ const matchingExtension = Object.keys(bundledTheme.fileExtensions)
185
+ .sort((left, right) => right.length - left.length)
186
+ .find((extension) => entryName === extension || entryName.endsWith(`.${extension}`));
187
+ return matchingExtension ? bundledTheme.fileExtensions[matchingExtension] : '';
188
+ }
189
+
190
+ function resolveBundledTreeItemIcon(relativePath, kind = 'file') {
191
+ const bundledTheme = loadBundledTreeIconTheme();
192
+ const iconId = resolveBundledTreeItemIconId(relativePath, kind);
193
+ return iconId ? bundledTheme.iconPathById.get(iconId) : undefined;
194
+ }
195
+
50
196
  function sessionIdleDecoration(session, now = Date.now()) {
51
197
  if (!session) {
52
198
  return undefined;
@@ -105,9 +251,182 @@ function formatCountLabel(count, singular, plural = `${singular}s`) {
105
251
  return `${count} ${count === 1 ? singular : plural}`;
106
252
  }
107
253
 
254
+ function branchSegments(branch) {
255
+ return String(branch || '')
256
+ .split('/')
257
+ .map((segment) => segment.trim())
258
+ .filter(Boolean);
259
+ }
260
+
261
+ function compactBranchLabel(branch) {
262
+ const segments = branchSegments(branch);
263
+ if (segments.length >= 3 && segments[0] === 'agent') {
264
+ return `${segments[1]}/${segments.slice(2).join('/')}`;
265
+ }
266
+ return segments.join('/');
267
+ }
268
+
269
+ function sessionFileCountLabel(session) {
270
+ const activityCountLabel = typeof session?.activityCountLabel === 'string'
271
+ ? session.activityCountLabel.trim()
272
+ : '';
273
+ if (activityCountLabel) {
274
+ return activityCountLabel;
275
+ }
276
+ if ((session?.changeCount || 0) > 0) {
277
+ return formatCountLabel(session.changeCount, 'file');
278
+ }
279
+ return '';
280
+ }
281
+
282
+ function uniqueStringList(values) {
283
+ const seen = new Set();
284
+ const result = [];
285
+
286
+ for (const value of values) {
287
+ if (typeof value !== 'string' || seen.has(value)) {
288
+ continue;
289
+ }
290
+ seen.add(value);
291
+ result.push(value);
292
+ }
293
+
294
+ return result;
295
+ }
296
+
297
+ function normalizeSessionProviderToken(value) {
298
+ return typeof value === 'string' ? value.trim().toLowerCase() : '';
299
+ }
300
+
301
+ function resolveSessionProvider(session) {
302
+ const signals = [
303
+ session?.cliName,
304
+ session?.agentName,
305
+ session?.branch,
306
+ ]
307
+ .map(normalizeSessionProviderToken)
308
+ .filter(Boolean);
309
+
310
+ if (signals.some((value) => value.includes('claude'))) {
311
+ return {
312
+ ...SESSION_PROVIDER_BRANDS.claude,
313
+ cliName: typeof session?.cliName === 'string' ? session.cliName.trim() : '',
314
+ };
315
+ }
316
+ if (signals.some((value) => value.includes('codex') || value.includes('openai'))) {
317
+ return {
318
+ ...SESSION_PROVIDER_BRANDS.openai,
319
+ cliName: typeof session?.cliName === 'string' ? session.cliName.trim() : '',
320
+ };
321
+ }
322
+ return null;
323
+ }
324
+
325
+ function sessionProviderDecoration(session) {
326
+ const provider = resolveSessionProvider(session);
327
+ if (!provider) {
328
+ return undefined;
329
+ }
330
+
331
+ const cliName = provider.cliName || provider.id;
332
+ return {
333
+ badge: provider.badge,
334
+ tooltip: `${provider.label} session via ${cliName}`,
335
+ };
336
+ }
337
+
338
+ function normalizeSnapshotIdentityValue(value) {
339
+ return typeof value === 'string' ? value.trim() : '';
340
+ }
341
+
342
+ function sessionSnapshotDisplayName(session) {
343
+ return normalizeSnapshotIdentityValue(session?.snapshotName)
344
+ || normalizeSnapshotIdentityValue(session?.snapshotEmail);
345
+ }
346
+
347
+ function sessionSnapshotBadge(session) {
348
+ const displayName = sessionSnapshotDisplayName(session);
349
+ const match = displayName.match(/[a-z0-9]/i);
350
+ return match ? match[0].toUpperCase() : '';
351
+ }
352
+
353
+ function sessionSnapshotDescription(session) {
354
+ const displayName = sessionSnapshotDisplayName(session);
355
+ return displayName ? `snapshot ${displayName}` : '';
356
+ }
357
+
358
+ function sessionSnapshotDecoration(session) {
359
+ const badge = sessionSnapshotBadge(session);
360
+ const displayName = sessionSnapshotDisplayName(session);
361
+ if (!badge || !displayName) {
362
+ return undefined;
363
+ }
364
+
365
+ return {
366
+ badge,
367
+ tooltip: `Snapshot ${displayName}`,
368
+ };
369
+ }
370
+
371
+ function sessionIdentityDecoration(session) {
372
+ return sessionSnapshotDecoration(session) || sessionProviderDecoration(session);
373
+ }
374
+
375
+ function stringListsEqual(left, right) {
376
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
377
+ return false;
378
+ }
379
+
380
+ return left.every((value, index) => value === right[index]);
381
+ }
382
+
383
+ async function ensureManagedRepoScanIgnores() {
384
+ if (typeof vscode.workspace.getConfiguration !== 'function') {
385
+ return;
386
+ }
387
+
388
+ const workspaceFolders = vscode.workspace.workspaceFolders || [];
389
+ if (workspaceFolders.length === 0) {
390
+ return;
391
+ }
392
+
393
+ const workspaceFolderTarget = workspaceFolders.length > 1
394
+ ? vscode.ConfigurationTarget?.WorkspaceFolder
395
+ : vscode.ConfigurationTarget?.Workspace;
396
+ if (workspaceFolderTarget === undefined) {
397
+ return;
398
+ }
399
+
400
+ for (const workspaceFolder of workspaceFolders) {
401
+ const gitConfig = vscode.workspace.getConfiguration(GIT_CONFIGURATION_SECTION, workspaceFolder);
402
+ const configuredIgnoredFolders = gitConfig.get(REPO_SCAN_IGNORED_FOLDERS_SETTING);
403
+ const existingIgnoredFolders = Array.isArray(configuredIgnoredFolders)
404
+ ? configuredIgnoredFolders
405
+ : [];
406
+ const nextIgnoredFolders = uniqueStringList([
407
+ ...existingIgnoredFolders,
408
+ ...MANAGED_REPO_SCAN_IGNORED_FOLDERS,
409
+ ]);
410
+
411
+ if (stringListsEqual(existingIgnoredFolders, nextIgnoredFolders)) {
412
+ continue;
413
+ }
414
+
415
+ try {
416
+ await gitConfig.update(
417
+ REPO_SCAN_IGNORED_FOLDERS_SETTING,
418
+ nextIgnoredFolders,
419
+ workspaceFolderTarget,
420
+ );
421
+ } catch {
422
+ // Leave the extension usable even when the current workspace settings cannot be updated.
423
+ }
424
+ }
425
+ }
426
+
108
427
  function sessionIdentityLabel(session) {
109
428
  const agentName = typeof session?.agentName === 'string' ? session.agentName.trim() : '';
110
- const taskName = typeof session?.taskName === 'string' ? session.taskName.trim() : '';
429
+ const taskName = sessionDisplayLabel(session);
111
430
  const label = typeof session?.label === 'string' ? session.label.trim() : '';
112
431
 
113
432
  if (agentName && taskName) {
@@ -145,9 +464,10 @@ function agentBadgeFromBranch(branch) {
145
464
  }
146
465
 
147
466
  function buildActiveAgentsStatusSummary(summary) {
148
- const activeCount = Math.max(0, (summary?.sessionCount || 0) - (summary?.deadCount || 0));
149
- if (activeCount > 0) {
150
- return `$(git-branch) ${formatCountLabel(activeCount, 'active agent')}`;
467
+ const workingCount = summary?.workingCount || 0;
468
+ const idleCount = summary?.idleCount || 0;
469
+ if (workingCount > 0 || idleCount > 0) {
470
+ return `$(git-branch) ${workingCount} working · ${idleCount} idle`;
151
471
  }
152
472
  return `$(git-branch) ${formatCountLabel(summary?.sessionCount || 0, 'tracked session')}`;
153
473
  }
@@ -159,7 +479,7 @@ function buildActiveAgentsStatusTooltip(selectedSession, summary) {
159
479
  sessionIdentityLabel(selectedSession),
160
480
  formatCountLabel(selectedSession.lockCount || 0, 'lock'),
161
481
  selectedSession.worktreePath,
162
- 'Click to open Source Control.',
482
+ 'Click to open Active Agents.',
163
483
  ].filter(Boolean).join('\n');
164
484
  }
165
485
 
@@ -167,11 +487,490 @@ function buildActiveAgentsStatusTooltip(selectedSession, summary) {
167
487
  return [
168
488
  formatCountLabel(activeCount, 'active agent'),
169
489
  formatCountLabel(summary?.workingCount || 0, 'working now session', 'working now sessions'),
490
+ formatCountLabel(summary?.idleCount || 0, 'idle session'),
491
+ formatCountLabel(summary?.unassignedChangeCount || 0, 'unassigned change'),
492
+ formatCountLabel(summary?.lockedFileCount || 0, 'locked file'),
170
493
  summary?.deadCount ? formatCountLabel(summary.deadCount, 'dead session') : '',
171
- 'Click to open Source Control.',
494
+ 'Click to open Active Agents.',
495
+ ].filter(Boolean).join('\n');
496
+ }
497
+
498
+ function compactRelativePath(relativePath) {
499
+ const normalized = normalizeRelativePath(relativePath);
500
+ if (!normalized) {
501
+ return '';
502
+ }
503
+
504
+ const segments = normalized.split('/').filter(Boolean);
505
+ if (segments.length <= 2) {
506
+ return normalized;
507
+ }
508
+
509
+ return `${segments[0]}/.../${segments[segments.length - 1]}`;
510
+ }
511
+
512
+ function summarizeCompactPaths(paths, maxCount = SESSION_TOP_FILE_COUNT) {
513
+ const compactPaths = uniqueStringList((paths || [])
514
+ .map(normalizeRelativePath)
515
+ .filter(Boolean)
516
+ .map((relativePath) => compactRelativePath(relativePath)))
517
+ .slice(0, maxCount);
518
+ if (compactPaths.length === 0) {
519
+ return '';
520
+ }
521
+ return compactPaths.join(', ');
522
+ }
523
+
524
+ function isProtectedBranchName(branch) {
525
+ return branch === 'main' || branch === 'dev';
526
+ }
527
+
528
+ function countWorkingSessions(sessions) {
529
+ return sessions.filter((session) => (
530
+ session.activityKind === 'working' || session.activityKind === 'blocked'
531
+ )).length;
532
+ }
533
+
534
+ function countIdleSessions(sessions) {
535
+ return sessions.filter((session) => (
536
+ session.activityKind === 'idle' || session.activityKind === 'stalled'
537
+ )).length;
538
+ }
539
+
540
+ function sessionLastActiveAt(session) {
541
+ return [
542
+ session?.lastHeartbeatAt,
543
+ session?.lastFileActivityAt,
544
+ session?.telemetryUpdatedAt,
545
+ session?.startedAt,
546
+ ].find((value) => typeof value === 'string' && value.trim().length > 0) || '';
547
+ }
548
+
549
+ function sessionLastActiveLabel(session) {
550
+ const lastActiveAt = sessionLastActiveAt(session);
551
+ if (!lastActiveAt) {
552
+ return '';
553
+ }
554
+ return formatElapsedFrom(lastActiveAt);
555
+ }
556
+
557
+ function sessionLastActiveAgeMs(session, now = Date.now()) {
558
+ const lastActiveAt = sessionLastActiveAt(session);
559
+ const timestamp = Date.parse(lastActiveAt);
560
+ if (!Number.isFinite(timestamp)) {
561
+ return null;
562
+ }
563
+ return Math.max(0, now - timestamp);
564
+ }
565
+
566
+ function sessionFreshnessLabel(session, now = Date.now()) {
567
+ const ageMs = sessionLastActiveAgeMs(session, now);
568
+ if (session.activityKind === 'blocked') {
569
+ return 'Needs attention';
570
+ }
571
+ if (session.activityKind === 'stalled') {
572
+ return 'Possibly stale';
573
+ }
574
+ if (session.activityKind === 'dead') {
575
+ return 'Stopped';
576
+ }
577
+ if (ageMs === null) {
578
+ return '';
579
+ }
580
+ if (ageMs <= IDLE_WARNING_MS) {
581
+ return 'Fresh';
582
+ }
583
+ if (ageMs <= RECENTLY_ACTIVE_WINDOW_MS) {
584
+ return 'Recently active';
585
+ }
586
+ if (session.activityKind === 'idle') {
587
+ return 'Idle';
588
+ }
589
+ return 'Recently active';
590
+ }
591
+
592
+ function sessionStatusLabel(session) {
593
+ switch (session.activityKind) {
594
+ case 'blocked':
595
+ return 'Blocked';
596
+ case 'working':
597
+ return 'Working';
598
+ case 'idle':
599
+ return 'Idle';
600
+ case 'stalled':
601
+ return 'Stale';
602
+ case 'dead':
603
+ return 'Dead';
604
+ default:
605
+ return 'Thinking';
606
+ }
607
+ }
608
+
609
+ function sessionHealthScore(session) {
610
+ return Number.isInteger(session?.sessionHealth?.score) ? session.sessionHealth.score : null;
611
+ }
612
+
613
+ function buildSessionHealthCompactLabel(session) {
614
+ const score = sessionHealthScore(session);
615
+ return score === null ? '' : `${score}/100`;
616
+ }
617
+
618
+ function buildSessionHealthSummary(session) {
619
+ const compactLabel = buildSessionHealthCompactLabel(session);
620
+ if (!compactLabel) {
621
+ return '';
622
+ }
623
+
624
+ const label = typeof session?.sessionHealth?.label === 'string'
625
+ ? session.sessionHealth.label.trim()
626
+ : '';
627
+ return label ? `${compactLabel} · ${label}` : compactLabel;
628
+ }
629
+
630
+ function buildSessionHealthDriversSummary(session) {
631
+ const primaryDriver = typeof session?.sessionHealth?.primaryDriver === 'string'
632
+ ? session.sessionHealth.primaryDriver.trim()
633
+ : '';
634
+ const secondaries = uniqueStringList(Array.isArray(session?.sessionHealth?.secondaries)
635
+ ? session.sessionHealth.secondaries.map((value) => String(value || '').trim())
636
+ : []);
637
+ return [
638
+ primaryDriver ? `Primary: ${primaryDriver}` : '',
639
+ secondaries.length > 0 ? `Secondary: ${secondaries.join(', ')}` : '',
640
+ ].filter(Boolean).join(' | ');
641
+ }
642
+
643
+ function buildSessionHealthTooltip(session) {
644
+ const outputLine = typeof session?.sessionHealth?.outputLine === 'string'
645
+ ? session.sessionHealth.outputLine.trim()
646
+ : '';
647
+ if (outputLine) {
648
+ return outputLine;
649
+ }
650
+
651
+ return [
652
+ buildSessionHealthSummary(session),
653
+ buildSessionHealthDriversSummary(session),
172
654
  ].filter(Boolean).join('\n');
173
655
  }
174
656
 
657
+ function buildSessionTopFiles(session) {
658
+ return uniqueStringList((session?.worktreeChangedPaths || [])
659
+ .map(normalizeRelativePath)
660
+ .filter(Boolean))
661
+ .slice(0, SESSION_TOP_FILE_COUNT);
662
+ }
663
+
664
+ function buildSessionRecentChangeSummary(session) {
665
+ if (session?.latestTaskPreview && session.latestTaskPreview !== session.taskName) {
666
+ return session.latestTaskPreview;
667
+ }
668
+ const topFiles = summarizeCompactPaths(session?.worktreeChangedPaths || []);
669
+ if (topFiles) {
670
+ return `Changed ${topFiles}`;
671
+ }
672
+ if (session?.activitySummary) {
673
+ return session.activitySummary;
674
+ }
675
+ return 'No recent change summary.';
676
+ }
677
+
678
+ function sessionRiskBadges(session) {
679
+ return uniqueStringList([
680
+ session?.activityKind === 'blocked' ? 'Blocked' : '',
681
+ session?.activityKind === 'stalled' ? 'Stale' : '',
682
+ session?.conflictCount > 0 ? 'Conflict' : '',
683
+ session?.lockCount > 0 ? 'Locked' : '',
684
+ ].filter(Boolean));
685
+ }
686
+
687
+ function changeRiskBadges(change) {
688
+ return uniqueStringList([
689
+ change?.protectedBranch ? 'Protected branch' : '',
690
+ change?.hasForeignLock ? 'Conflict' : '',
691
+ !change?.hasForeignLock && change?.lockOwnerBranch ? 'Locked' : '',
692
+ change?.deltaLabel || '',
693
+ ].filter(Boolean));
694
+ }
695
+
696
+ function buildSessionCardDescription(session) {
697
+ const provider = resolveSessionProvider(session);
698
+ const statusAgentLabel = `${sessionStatusLabel(session)}: ${session.agentName || 'agent'}`;
699
+ const descriptionParts = [
700
+ statusAgentLabel,
701
+ provider?.label ? `via ${provider.label}` : '',
702
+ sessionSnapshotDescription(session),
703
+ session.deltaLabel || '',
704
+ session.changeCount > 0 ? formatCountLabel(session.changeCount, 'changed file') : '',
705
+ session.lockCount > 0 ? formatCountLabel(session.lockCount, 'lock') : '',
706
+ buildSessionHealthCompactLabel(session),
707
+ session.freshnessLabel || '',
708
+ session.lastActiveLabel ? `${session.lastActiveLabel} ago` : '',
709
+ ].filter(Boolean);
710
+ return descriptionParts.join(' · ');
711
+ }
712
+
713
+ function buildRawSessionDescription(session) {
714
+ const provider = resolveSessionProvider(session);
715
+ const descriptionParts = [sessionStatusLabel(session)];
716
+ const fileCountLabel = sessionFileCountLabel(session);
717
+ if (fileCountLabel) {
718
+ descriptionParts.push(fileCountLabel);
719
+ }
720
+ if (provider?.label) {
721
+ descriptionParts.push(provider.label);
722
+ }
723
+ const snapshot = sessionSnapshotDescription(session);
724
+ if (snapshot) {
725
+ descriptionParts.push(snapshot);
726
+ }
727
+ descriptionParts.push(session.elapsedLabel || formatElapsedFrom(session.startedAt));
728
+ const sessionHealthLabel = buildSessionHealthCompactLabel(session);
729
+ if (sessionHealthLabel) {
730
+ descriptionParts.push(sessionHealthLabel);
731
+ }
732
+ if (session.lockCount > 0) {
733
+ descriptionParts.push(formatCountLabel(session.lockCount, 'lock'));
734
+ }
735
+ return descriptionParts.join(' · ');
736
+ }
737
+
738
+ function buildSessionTooltip(session, description) {
739
+ const provider = resolveSessionProvider(session);
740
+ const riskSummary = uniqueStringList([
741
+ ...(session?.riskBadges || []),
742
+ session?.deltaLabel || '',
743
+ ].filter(Boolean)).join(', ');
744
+ const topFiles = session?.topChangedFilesLabel || summarizeCompactPaths(session?.worktreeChangedPaths || []);
745
+ const sessionHealthSummary = buildSessionHealthSummary(session);
746
+ const sessionHealthDrivers = buildSessionHealthDriversSummary(session);
747
+ return [
748
+ session.branch,
749
+ provider?.label
750
+ ? `Provider ${provider.label}${provider.cliName ? ` (${provider.cliName})` : ''}`
751
+ : '',
752
+ sessionSnapshotDisplayName(session) ? `Snapshot ${sessionSnapshotDisplayName(session)}` : '',
753
+ `${session.agentName} · ${sessionDisplayLabel(session)}`,
754
+ `Status ${description}`,
755
+ sessionHealthSummary ? `Session health ${sessionHealthSummary}` : '',
756
+ sessionHealthDrivers ? `Drivers ${sessionHealthDrivers}` : '',
757
+ session.recentChangeSummary ? `Recent ${session.recentChangeSummary}` : '',
758
+ topFiles ? `Top files ${topFiles}` : '',
759
+ riskSummary ? `Signals ${riskSummary}` : '',
760
+ session.conflictCount > 0 ? `Conflicts ${session.conflictCount}` : '',
761
+ session.lastActiveAt ? `Last active ${session.lastActiveAt}` : '',
762
+ session.sourceKind === 'worktree-lock'
763
+ ? `Telemetry updated ${session.telemetryUpdatedAt || session.startedAt}`
764
+ : `Started ${session.startedAt}`,
765
+ session.worktreePath,
766
+ ].filter(Boolean).join('\n');
767
+ }
768
+
769
+ function buildUnassignedChangeDescription(change) {
770
+ return [
771
+ change.statusLabel,
772
+ ...changeRiskBadges(change),
773
+ ].filter(Boolean).join(' · ');
774
+ }
775
+
776
+ function buildWorktreeBranchDescription(sessions) {
777
+ const sessionList = Array.isArray(sessions) ? sessions : [];
778
+ const primarySession = sessionList[0] || null;
779
+ if (!primarySession) {
780
+ return '';
781
+ }
782
+
783
+ const descriptionParts = [
784
+ `${sessionStatusLabel(primarySession).toLowerCase()}: ${primarySession.agentName || 'agent'}`,
785
+ sessionSnapshotDescription(primarySession),
786
+ ];
787
+ if (sessionList.length > 1) {
788
+ descriptionParts.push(formatCountLabel(sessionList.length, 'agent'));
789
+ }
790
+ return descriptionParts.filter(Boolean).join(' · ');
791
+ }
792
+
793
+ function buildOverviewDescription(summary) {
794
+ return [
795
+ formatCountLabel(summary?.workingCount || 0, 'working agent'),
796
+ formatCountLabel(summary?.idleCount || 0, 'idle agent'),
797
+ formatCountLabel(summary?.unassignedChangeCount || 0, 'unassigned change'),
798
+ formatCountLabel(summary?.lockedFileCount || 0, 'locked file'),
799
+ formatCountLabel(summary?.conflictCount || 0, 'conflict'),
800
+ ].join(' · ');
801
+ }
802
+
803
+ function buildRepoDescription(summary) {
804
+ return buildOverviewDescription(summary);
805
+ }
806
+
807
+ function buildRepoTooltip(repoRoot, summary) {
808
+ return [
809
+ repoRoot,
810
+ buildOverviewDescription(summary),
811
+ ].join('\n');
812
+ }
813
+
814
+ function repoRootDisplayLabel(repoRoot) {
815
+ const normalizedRepoRoot = path.resolve(repoRoot);
816
+ const matchingWorkspaceRoots = (vscode.workspace.workspaceFolders || [])
817
+ .map((folder) => (typeof folder?.uri?.fsPath === 'string' ? path.resolve(folder.uri.fsPath) : ''))
818
+ .filter((workspaceRoot) => workspaceRoot && isPathWithin(workspaceRoot, normalizedRepoRoot))
819
+ .sort((left, right) => right.length - left.length);
820
+
821
+ const workspaceRoot = matchingWorkspaceRoots[0];
822
+ if (!workspaceRoot) {
823
+ return path.basename(normalizedRepoRoot);
824
+ }
825
+
826
+ const workspaceLabel = path.basename(workspaceRoot);
827
+ const relativePath = normalizeRelativePath(path.relative(workspaceRoot, normalizedRepoRoot));
828
+ if (!relativePath) {
829
+ return workspaceLabel;
830
+ }
831
+
832
+ return [
833
+ workspaceLabel,
834
+ ...relativePath.split('/').filter(Boolean),
835
+ ].join('/');
836
+ }
837
+
838
+ function sessionSnapshotKey(session) {
839
+ return `${session?.repoRoot || ''}::${session?.branch || ''}`;
840
+ }
841
+
842
+ function changeSnapshotKey(repoRoot, change) {
843
+ return `${repoRoot || ''}::${normalizeRelativePath(change?.relativePath)}`;
844
+ }
845
+
846
+ function buildSessionSnapshot(session) {
847
+ return {
848
+ activityKind: session.activityKind,
849
+ changeCount: session.changeCount || 0,
850
+ conflictCount: session.conflictCount || 0,
851
+ lockCount: session.lockCount || 0,
852
+ changedPaths: [...(session.changedPaths || [])],
853
+ };
854
+ }
855
+
856
+ function buildChangeSnapshot(change) {
857
+ return {
858
+ statusLabel: change.statusLabel,
859
+ hasForeignLock: Boolean(change.hasForeignLock),
860
+ lockOwnerBranch: change.lockOwnerBranch || '',
861
+ };
862
+ }
863
+
864
+ function deriveSessionDelta(previousSnapshot, currentSession) {
865
+ if (!previousSnapshot) {
866
+ return '';
867
+ }
868
+ if (currentSession.conflictCount > previousSnapshot.conflictCount) {
869
+ return 'Conflict';
870
+ }
871
+ if (currentSession.activityKind !== previousSnapshot.activityKind) {
872
+ return sessionStatusLabel(currentSession);
873
+ }
874
+ if (
875
+ currentSession.changeCount !== previousSnapshot.changeCount
876
+ || !stringListsEqual(currentSession.changedPaths || [], previousSnapshot.changedPaths || [])
877
+ ) {
878
+ return 'New';
879
+ }
880
+ if (currentSession.lockCount !== previousSnapshot.lockCount) {
881
+ return 'Updated';
882
+ }
883
+ return '';
884
+ }
885
+
886
+ function deriveChangeDelta(previousSnapshot, currentChange) {
887
+ if (!previousSnapshot) {
888
+ return '';
889
+ }
890
+ if (currentChange.hasForeignLock && !previousSnapshot.hasForeignLock) {
891
+ return 'Conflict';
892
+ }
893
+ if (
894
+ currentChange.statusLabel !== previousSnapshot.statusLabel
895
+ || currentChange.lockOwnerBranch !== previousSnapshot.lockOwnerBranch
896
+ ) {
897
+ return 'Updated';
898
+ }
899
+ return '';
900
+ }
901
+
902
+ function workingSessionSortKey(session) {
903
+ if (session.activityKind === 'blocked') {
904
+ return 0;
905
+ }
906
+ if (session.conflictCount > 0) {
907
+ return 1;
908
+ }
909
+ if (session.deltaLabel === 'Conflict') {
910
+ return 2;
911
+ }
912
+ if (session.deltaLabel === 'New') {
913
+ return 3;
914
+ }
915
+ return 4;
916
+ }
917
+
918
+ function idleSessionSortKey(session) {
919
+ if (session.activityKind === 'stalled') {
920
+ return 0;
921
+ }
922
+ if (session.activityKind === 'idle') {
923
+ return 1;
924
+ }
925
+ if (session.activityKind === 'dead') {
926
+ return 2;
927
+ }
928
+ return 3;
929
+ }
930
+
931
+ function sortSessionsForWorkingNow(sessions) {
932
+ return [...sessions].sort((left, right) => {
933
+ const keyDelta = workingSessionSortKey(left) - workingSessionSortKey(right);
934
+ if (keyDelta !== 0) {
935
+ return keyDelta;
936
+ }
937
+ const timeDelta = sessionLastActiveAgeMs(left) - sessionLastActiveAgeMs(right);
938
+ if (Number.isFinite(timeDelta) && timeDelta !== 0) {
939
+ return timeDelta;
940
+ }
941
+ const changeDelta = (right.changeCount || 0) - (left.changeCount || 0);
942
+ if (changeDelta !== 0) {
943
+ return changeDelta;
944
+ }
945
+ return sessionDisplayLabel(left).localeCompare(sessionDisplayLabel(right));
946
+ });
947
+ }
948
+
949
+ function sortSessionsForIdleThinking(sessions) {
950
+ return [...sessions].sort((left, right) => {
951
+ const keyDelta = idleSessionSortKey(left) - idleSessionSortKey(right);
952
+ if (keyDelta !== 0) {
953
+ return keyDelta;
954
+ }
955
+ const timeDelta = sessionLastActiveAgeMs(right) - sessionLastActiveAgeMs(left);
956
+ if (Number.isFinite(timeDelta) && timeDelta !== 0) {
957
+ return timeDelta;
958
+ }
959
+ return sessionDisplayLabel(left).localeCompare(sessionDisplayLabel(right));
960
+ });
961
+ }
962
+
963
+ function sortUnassignedChanges(changes) {
964
+ return [...changes].sort((left, right) => {
965
+ const leftBadges = changeRiskBadges(left).length;
966
+ const rightBadges = changeRiskBadges(right).length;
967
+ if (leftBadges !== rightBadges) {
968
+ return rightBadges - leftBadges;
969
+ }
970
+ return normalizeRelativePath(left.relativePath).localeCompare(normalizeRelativePath(right.relativePath));
971
+ });
972
+ }
973
+
175
974
  function escapeHtml(value) {
176
975
  return String(value || '')
177
976
  .replace(/&/g, '&amp;')
@@ -366,7 +1165,12 @@ class SessionDecorationProvider {
366
1165
  };
367
1166
  }
368
1167
 
369
- return sessionIdleDecoration(this.sessionsByUri.get(uri.toString()), this.nowProvider());
1168
+ const session = this.sessionsByUri.get(uri.toString());
1169
+ const idleDecoration = sessionIdleDecoration(session, this.nowProvider());
1170
+ if (idleDecoration) {
1171
+ return idleDecoration;
1172
+ }
1173
+ return sessionIdentityDecoration(session);
370
1174
  }
371
1175
  }
372
1176
 
@@ -374,49 +1178,50 @@ class InfoItem extends vscode.TreeItem {
374
1178
  constructor(label, description = '') {
375
1179
  super(label, vscode.TreeItemCollapsibleState.None);
376
1180
  this.description = description;
377
- this.iconPath = new vscode.ThemeIcon('info');
1181
+ this.iconPath = themeIcon('info');
1182
+ this.tooltip = [label, description].filter(Boolean).join('\n');
1183
+ }
1184
+ }
1185
+
1186
+ class DetailItem extends vscode.TreeItem {
1187
+ constructor(label, description = '', options = {}) {
1188
+ super(label, vscode.TreeItemCollapsibleState.None);
1189
+ this.description = description;
1190
+ this.tooltip = options.tooltip || [label, description].filter(Boolean).join('\n');
1191
+ this.iconPath = options.iconId ? themeIcon(options.iconId, options.iconColorId) : undefined;
378
1192
  }
379
1193
  }
380
1194
 
381
1195
  class RepoItem extends vscode.TreeItem {
382
- constructor(repoRoot, sessions, changes) {
383
- super(path.basename(repoRoot), vscode.TreeItemCollapsibleState.Expanded);
1196
+ constructor(repoRoot, sessions, changes, options = {}) {
1197
+ const label = typeof options.label === 'string' && options.label.trim()
1198
+ ? options.label.trim()
1199
+ : repoRootDisplayLabel(repoRoot);
1200
+ super(label, vscode.TreeItemCollapsibleState.Expanded);
384
1201
  this.repoRoot = repoRoot;
385
1202
  this.sessions = sessions;
386
1203
  this.changes = changes;
387
- const descriptionParts = [];
388
- const activeCount = countActiveSessions(sessions);
389
- const deadCount = countSessionsByActivityKind(sessions, 'dead');
390
- const workingCount = countWorkingSessions(sessions);
391
- if (activeCount > 0) {
392
- descriptionParts.push(`${activeCount} active`);
393
- }
394
- if (deadCount > 0) {
395
- descriptionParts.push(`${deadCount} dead`);
396
- }
397
- if (workingCount > 0) {
398
- descriptionParts.push(`${workingCount} working`);
399
- }
400
- const changedCount = countChangedPaths(repoRoot, sessions, changes);
401
- if (changedCount > 0) {
402
- descriptionParts.push(`${changedCount} changed`);
403
- }
404
- this.description = descriptionParts.join(' · ');
405
- this.tooltip = [
406
- repoRoot,
407
- this.description,
408
- ].join('\n');
409
- this.iconPath = new vscode.ThemeIcon('repo');
1204
+ this.unassignedChanges = options.unassignedChanges || [];
1205
+ this.lockEntries = options.lockEntries || [];
1206
+ this.overview = options.overview || buildRepoOverview(sessions, this.unassignedChanges, this.lockEntries);
1207
+ this.description = buildRepoDescription(this.overview);
1208
+ this.tooltip = buildRepoTooltip(repoRoot, this.overview);
1209
+ this.iconPath = themeIcon('repo');
410
1210
  this.contextValue = 'gitguardex.repo';
411
1211
  }
412
1212
  }
413
1213
 
414
1214
  class SectionItem extends vscode.TreeItem {
415
1215
  constructor(label, items, options = {}) {
416
- super(label, vscode.TreeItemCollapsibleState.Expanded);
1216
+ const collapsibleState = items.length > 0
1217
+ ? (options.collapsedState ?? vscode.TreeItemCollapsibleState.Expanded)
1218
+ : vscode.TreeItemCollapsibleState.None;
1219
+ super(label, collapsibleState);
417
1220
  this.items = items;
418
1221
  this.description = options.description
419
1222
  || (items.length > 0 ? String(items.length) : '');
1223
+ this.tooltip = options.tooltip || [label, this.description].filter(Boolean).join('\n');
1224
+ this.iconPath = options.iconId ? themeIcon(options.iconId, options.iconColorId) : undefined;
420
1225
  this.contextValue = 'gitguardex.section';
421
1226
  }
422
1227
  }
@@ -425,32 +1230,35 @@ class WorktreeItem extends vscode.TreeItem {
425
1230
  constructor(worktreePath, sessions, items = [], options = {}) {
426
1231
  const normalizedWorktreePath = typeof worktreePath === 'string' ? worktreePath.trim() : '';
427
1232
  const sessionList = Array.isArray(sessions) ? sessions : [];
1233
+ const primarySession = options.resourceSession || sessionList[0] || null;
428
1234
  const changedCount = Number.isInteger(options.changedCount)
429
1235
  ? options.changedCount
430
1236
  : sessionList.reduce((total, session) => total + (session.changeCount || 0), 0);
431
- const descriptionParts = [formatCountLabel(sessionList.length, 'agent')];
432
- if (changedCount > 0) {
433
- descriptionParts.push(`${changedCount} changed`);
434
- }
1237
+ const label = typeof options.label === 'string' && options.label.trim()
1238
+ ? options.label.trim()
1239
+ : worktreeDisplayLabel(normalizedWorktreePath, sessionList);
435
1240
  super(
436
- path.basename(normalizedWorktreePath || '') || 'worktree',
1241
+ label,
437
1242
  items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None,
438
1243
  );
439
1244
  this.worktreePath = normalizedWorktreePath;
440
1245
  this.sessions = sessionList;
441
1246
  this.items = items;
442
- this.description = options.description || descriptionParts.join(' · ');
1247
+ this.description = options.description || buildWorktreeDescription(sessionList, changedCount);
443
1248
  this.tooltip = [
444
1249
  normalizedWorktreePath,
445
1250
  ...sessionList.map((session) => session.branch).filter(Boolean),
446
1251
  ].filter(Boolean).join('\n');
447
- this.iconPath = new vscode.ThemeIcon('folder');
1252
+ this.iconPath = themeIcon(options.iconId || 'folder', options.iconColorId);
1253
+ if (options.useSessionDecoration && primarySession?.branch) {
1254
+ this.resourceUri = sessionDecorationUri(primarySession.branch);
1255
+ }
448
1256
  this.contextValue = 'gitguardex.worktree';
449
- if (sessionList[0]?.worktreePath) {
1257
+ if (primarySession?.worktreePath) {
450
1258
  this.command = {
451
1259
  command: 'gitguardex.activeAgents.openWorktree',
452
1260
  title: 'Open Agent Worktree',
453
- arguments: [sessionList[0]],
1261
+ arguments: [primarySession],
454
1262
  };
455
1263
  }
456
1264
  }
@@ -458,51 +1266,29 @@ class WorktreeItem extends vscode.TreeItem {
458
1266
 
459
1267
  class SessionItem extends vscode.TreeItem {
460
1268
  constructor(session, items = [], options = {}) {
461
- const lockCount = Number.isFinite(session.lockCount) ? session.lockCount : 0;
1269
+ const variant = options.variant === 'raw' ? 'raw' : 'card';
462
1270
  const label = typeof options.label === 'string' && options.label.trim()
463
1271
  ? options.label.trim()
464
- : session.label;
1272
+ : (variant === 'raw' ? session.label : sessionDisplayLabel(session));
1273
+ const collapsibleState = items.length > 0
1274
+ ? (options.collapsedState ?? (
1275
+ variant === 'raw'
1276
+ ? vscode.TreeItemCollapsibleState.Expanded
1277
+ : vscode.TreeItemCollapsibleState.Collapsed
1278
+ ))
1279
+ : vscode.TreeItemCollapsibleState.None;
465
1280
  super(
466
1281
  label,
467
- items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None,
1282
+ collapsibleState,
468
1283
  );
469
1284
  this.session = session;
470
1285
  this.items = items;
471
1286
  this.resourceUri = sessionDecorationUri(session.branch);
472
- const descriptionParts = [session.activityLabel || 'thinking'];
473
- if (session.activityCountLabel) {
474
- descriptionParts.push(session.activityCountLabel);
475
- }
476
- descriptionParts.push(session.elapsedLabel || formatElapsedFrom(session.startedAt));
477
- if (lockCount > 0) {
478
- descriptionParts.push(`${lockCount} $(lock)`);
479
- }
480
- this.description = descriptionParts.join(' · ');
481
- const tooltipLines = [
482
- session.branch,
483
- `${session.agentName} · ${session.taskName}`,
484
- session.latestTaskPreview && session.latestTaskPreview !== session.taskName
485
- ? `Live task ${session.latestTaskPreview}`
486
- : '',
487
- `Status ${this.description}`,
488
- session.changeCount > 0
489
- ? `Changed ${session.activityCountLabel}: ${session.activitySummary}`
490
- : session.activitySummary,
491
- `Locks ${lockCount}`,
492
- session.conflictCount > 0 ? `Conflicts ${session.conflictCount}` : '',
493
- Number.isInteger(session.pid) && session.pid > 0
494
- ? session.pidAlive === false
495
- ? `PID ${session.pid} not alive`
496
- : `PID ${session.pid} alive`
497
- : '',
498
- session.lastFileActivityAt ? `Last file activity ${session.lastFileActivityAt}` : '',
499
- session.sourceKind === 'worktree-lock'
500
- ? `Telemetry updated ${session.telemetryUpdatedAt || session.startedAt}`
501
- : `Started ${session.startedAt}`,
502
- session.worktreePath,
503
- ];
504
- this.tooltip = tooltipLines.filter(Boolean).join('\n');
505
- this.iconPath = new vscode.ThemeIcon(resolveSessionActivityIconId(session.activityKind));
1287
+ this.description = variant === 'raw'
1288
+ ? buildRawSessionDescription(session)
1289
+ : buildSessionCardDescription(session);
1290
+ this.tooltip = buildSessionTooltip(session, this.description);
1291
+ this.iconPath = themeIcon(resolveSessionActivityIconId(session.activityKind));
506
1292
  this.contextValue = 'gitguardex.session';
507
1293
  this.command = {
508
1294
  command: 'gitguardex.activeAgents.openWorktree',
@@ -513,31 +1299,47 @@ class SessionItem extends vscode.TreeItem {
513
1299
  }
514
1300
 
515
1301
  class FolderItem extends vscode.TreeItem {
516
- constructor(label, relativePath, items) {
517
- super(label, vscode.TreeItemCollapsibleState.Expanded);
1302
+ constructor(label, relativePath, items, options = {}) {
1303
+ super(
1304
+ label,
1305
+ items.length > 0
1306
+ ? (options.collapsedState ?? vscode.TreeItemCollapsibleState.Expanded)
1307
+ : vscode.TreeItemCollapsibleState.None,
1308
+ );
518
1309
  this.relativePath = relativePath;
519
1310
  this.items = items;
520
- this.tooltip = relativePath;
521
- this.iconPath = new vscode.ThemeIcon('folder');
522
- this.contextValue = 'gitguardex.folder';
1311
+ this.description = typeof options.description === 'string' ? options.description : '';
1312
+ this.tooltip = options.tooltip || relativePath || label;
1313
+ this.iconPath = options.iconPath
1314
+ || (!options.iconId ? resolveBundledTreeItemIcon(relativePath || label, 'folder') : undefined)
1315
+ || themeIcon(options.iconId || 'folder', options.iconColorId);
1316
+ this.contextValue = options.contextValue || 'gitguardex.folder';
523
1317
  }
524
1318
  }
525
1319
 
526
1320
  class ChangeItem extends vscode.TreeItem {
527
- constructor(change) {
528
- super(path.basename(change.relativePath), vscode.TreeItemCollapsibleState.None);
1321
+ constructor(change, options = {}) {
1322
+ const label = typeof options.label === 'string' && options.label.trim()
1323
+ ? options.label.trim()
1324
+ : path.basename(change.relativePath);
1325
+ super(label, vscode.TreeItemCollapsibleState.None);
529
1326
  this.change = change;
530
- this.description = change.statusLabel;
1327
+ this.description = typeof options.description === 'string'
1328
+ ? options.description
1329
+ : change.statusLabel;
531
1330
  this.tooltip = [
532
1331
  change.relativePath,
1332
+ `Summary ${this.description}`,
533
1333
  `Status ${change.statusText}`,
534
1334
  change.originalPath ? `Renamed from ${change.originalPath}` : '',
535
1335
  change.hasForeignLock ? `Locked by ${change.lockOwnerBranch}` : '',
536
1336
  change.absolutePath,
537
1337
  ].filter(Boolean).join('\n');
538
1338
  this.resourceUri = vscode.Uri.file(change.absolutePath);
539
- if (change.hasForeignLock) {
540
- this.iconPath = new vscode.ThemeIcon('warning');
1339
+ if (options.iconId || change.hasForeignLock) {
1340
+ this.iconPath = themeIcon(options.iconId || 'warning', options.iconColorId || 'list.warningForeground');
1341
+ } else {
1342
+ this.iconPath = options.iconPath || resolveBundledTreeItemIcon(change.relativePath || label, 'file');
541
1343
  }
542
1344
  this.contextValue = 'gitguardex.change';
543
1345
  this.command = {
@@ -562,32 +1364,278 @@ function readPackageJson(repoRoot) {
562
1364
  }
563
1365
  }
564
1366
 
565
- function resolveStartAgentCommand(repoRoot, details) {
566
- const taskArg = shellQuote(details.taskName);
567
- const agentArg = shellQuote(details.agentName);
568
- const localCodexAgentPath = path.join(repoRoot, 'scripts', 'codex-agent.sh');
569
- if (fs.existsSync(localCodexAgentPath)) {
570
- return `bash ./scripts/codex-agent.sh ${taskArg} ${agentArg}`;
1367
+ function resolveStartAgentCommand(repoRoot, details) {
1368
+ const taskArg = shellQuote(details.taskName);
1369
+ const agentArg = shellQuote(details.agentName);
1370
+ const localCodexAgentPath = path.join(repoRoot, 'scripts', 'codex-agent.sh');
1371
+ if (fs.existsSync(localCodexAgentPath)) {
1372
+ return `bash ./scripts/codex-agent.sh ${taskArg} ${agentArg}`;
1373
+ }
1374
+
1375
+ const agentCodexScript = readPackageJson(repoRoot)?.scripts?.['agent:codex'];
1376
+ if (typeof agentCodexScript === 'string' && agentCodexScript.trim().length > 0) {
1377
+ return `npm run agent:codex -- ${taskArg} ${agentArg}`;
1378
+ }
1379
+
1380
+ return `gx branch start ${taskArg} ${agentArg}`;
1381
+ }
1382
+
1383
+ function sessionTaskLabel(session) {
1384
+ const latestTaskPreview = typeof session?.latestTaskPreview === 'string'
1385
+ ? session.latestTaskPreview.trim()
1386
+ : '';
1387
+ if (latestTaskPreview) {
1388
+ return latestTaskPreview;
1389
+ }
1390
+
1391
+ const taskName = typeof session?.taskName === 'string' ? session.taskName.trim() : '';
1392
+ if (taskName) {
1393
+ return taskName;
1394
+ }
1395
+
1396
+ return '';
1397
+ }
1398
+
1399
+ function sessionDisplayLabel(session) {
1400
+ return sessionTaskLabel(session)
1401
+ || session?.label
1402
+ || compactBranchLabel(session?.branch)
1403
+ || session?.branch
1404
+ || path.basename(session?.worktreePath || '')
1405
+ || 'session';
1406
+ }
1407
+
1408
+ function sessionTreeLabel(session) {
1409
+ return sessionTaskLabel(session) || compactBranchLabel(session?.branch) || sessionDisplayLabel(session);
1410
+ }
1411
+
1412
+ function worktreeDisplayLabel(worktreePath, sessions) {
1413
+ const sessionList = Array.isArray(sessions)
1414
+ ? sessions.filter(Boolean)
1415
+ : [];
1416
+ if (sessionList.length === 1) {
1417
+ return sessionDisplayLabel(sessionList[0]);
1418
+ }
1419
+
1420
+ return path.basename(String(worktreePath || '').trim()) || 'worktree';
1421
+ }
1422
+
1423
+ function buildWorktreeDescription(sessions, changedCount) {
1424
+ const sessionList = Array.isArray(sessions)
1425
+ ? sessions.filter(Boolean)
1426
+ : [];
1427
+ const primarySession = sessionList.length === 1 ? sessionList[0] : null;
1428
+ const totalLocks = sessionList.reduce((total, session) => total + (session.lockCount || 0), 0);
1429
+ const descriptionParts = [];
1430
+
1431
+ if (primarySession?.agentName) {
1432
+ descriptionParts.push(primarySession.agentName);
1433
+ } else {
1434
+ descriptionParts.push(formatCountLabel(sessionList.length, 'agent'));
1435
+ }
1436
+
1437
+ const fileCountLabel = primarySession
1438
+ ? sessionFileCountLabel(primarySession)
1439
+ : changedCount > 0
1440
+ ? formatCountLabel(changedCount, 'file')
1441
+ : '';
1442
+ if (fileCountLabel) {
1443
+ descriptionParts.push(fileCountLabel);
1444
+ }
1445
+ if (totalLocks > 0) {
1446
+ descriptionParts.push(formatCountLabel(totalLocks, 'lock'));
1447
+ }
1448
+
1449
+ return descriptionParts.join(' · ');
1450
+ }
1451
+
1452
+ function sessionWorktreePath(session) {
1453
+ return typeof session?.worktreePath === 'string' ? session.worktreePath.trim() : '';
1454
+ }
1455
+
1456
+ function resolveSessionProjectRelativePath(session) {
1457
+ const repoRoot = typeof session?.repoRoot === 'string' ? session.repoRoot.trim() : '';
1458
+ if (!repoRoot) {
1459
+ return '';
1460
+ }
1461
+
1462
+ const resolveCandidate = (candidatePath) => {
1463
+ const normalizedCandidate = typeof candidatePath === 'string' ? candidatePath.trim() : '';
1464
+ if (!normalizedCandidate) {
1465
+ return '';
1466
+ }
1467
+
1468
+ const absolutePath = path.isAbsolute(normalizedCandidate)
1469
+ ? path.resolve(normalizedCandidate)
1470
+ : path.resolve(repoRoot, normalizedCandidate);
1471
+ if (!isPathWithin(repoRoot, absolutePath) || !fs.existsSync(absolutePath)) {
1472
+ return '';
1473
+ }
1474
+
1475
+ return normalizeRelativePath(path.relative(repoRoot, absolutePath));
1476
+ };
1477
+
1478
+ const isManagedWorktreeRelativePath = (relativePath) => {
1479
+ const normalizedRelativePath = normalizeRelativePath(relativePath);
1480
+ return MANAGED_WORKTREE_RELATIVE_ROOTS.some((managedRoot) => {
1481
+ const normalizedManagedRoot = normalizeRelativePath(managedRoot);
1482
+ return normalizedRelativePath === normalizedManagedRoot
1483
+ || normalizedRelativePath.startsWith(`${normalizedManagedRoot}/`);
1484
+ });
1485
+ };
1486
+
1487
+ const explicitProjectPath = resolveCandidate(session?.projectPath);
1488
+ if (explicitProjectPath && !isManagedWorktreeRelativePath(explicitProjectPath)) {
1489
+ return explicitProjectPath;
1490
+ }
1491
+
1492
+ const namedProjectPath = resolveCandidate(session?.projectName);
1493
+ if (namedProjectPath && !isManagedWorktreeRelativePath(namedProjectPath)) {
1494
+ return namedProjectPath;
1495
+ }
1496
+ return '';
1497
+ }
1498
+
1499
+ function worktreeProjectRelativePath(sessions) {
1500
+ const projectPaths = uniqueStringList((sessions || [])
1501
+ .map((session) => resolveSessionProjectRelativePath(session))
1502
+ .filter(Boolean));
1503
+ return projectPaths.length === 1 ? projectPaths[0] : '';
1504
+ }
1505
+
1506
+ function repoEntryDisplayLabel(repoRoot, sessions) {
1507
+ const repoLabel = repoRootDisplayLabel(repoRoot);
1508
+ const projectPaths = uniqueStringList((sessions || [])
1509
+ .map((session) => resolveSessionProjectRelativePath(session))
1510
+ .filter(Boolean));
1511
+ if (projectPaths.length !== 1) {
1512
+ return repoLabel;
1513
+ }
1514
+
1515
+ const [projectRelativePath] = projectPaths;
1516
+ const hasRootScopedSession = (sessions || []).some(
1517
+ (session) => !resolveSessionProjectRelativePath(session),
1518
+ );
1519
+ if (!projectRelativePath || hasRootScopedSession) {
1520
+ return repoLabel;
1521
+ }
1522
+ if (repoLabel.endsWith(`/${projectRelativePath}`)) {
1523
+ return repoLabel;
1524
+ }
1525
+ return `${repoLabel}/${projectRelativePath}`;
1526
+ }
1527
+
1528
+ function buildProjectScopedDescription(entries) {
1529
+ const sessions = (entries || []).flatMap((entry) => Array.isArray(entry?.sessions) ? entry.sessions : []);
1530
+ if (sessions.length === 0) {
1531
+ return '';
1532
+ }
1533
+
1534
+ const changedCount = sessions.reduce((total, session) => total + (session.changeCount || 0), 0);
1535
+ const lockCount = sessions.reduce((total, session) => total + (session.lockCount || 0), 0);
1536
+ const descriptionParts = [formatCountLabel(sessions.length, 'agent')];
1537
+ if (changedCount > 0) {
1538
+ descriptionParts.push(formatCountLabel(changedCount, 'file'));
1539
+ }
1540
+ if (lockCount > 0) {
1541
+ descriptionParts.push(formatCountLabel(lockCount, 'lock'));
1542
+ }
1543
+ return descriptionParts.join(' · ');
1544
+ }
1545
+
1546
+ function buildProjectScopedItems(entries, options = {}) {
1547
+ const normalizedEntries = Array.isArray(entries)
1548
+ ? entries.filter((entry) => entry?.item)
1549
+ : [];
1550
+ const projectRoots = [];
1551
+ const rootEntries = [];
1552
+ let hasProjectFolders = false;
1553
+
1554
+ function sortFolders(nodes) {
1555
+ nodes.sort((left, right) => left.label.localeCompare(right.label));
1556
+ for (const node of nodes) {
1557
+ sortFolders(node.children);
1558
+ }
1559
+ }
1560
+
1561
+ for (const entry of normalizedEntries) {
1562
+ const projectRelativePath = normalizeRelativePath(entry.projectRelativePath);
1563
+ if (!projectRelativePath) {
1564
+ rootEntries.push(entry);
1565
+ continue;
1566
+ }
1567
+
1568
+ hasProjectFolders = true;
1569
+ let nodes = projectRoots;
1570
+ let folderPath = '';
1571
+ let parentNode = null;
1572
+ for (const segment of projectRelativePath.split('/').filter(Boolean)) {
1573
+ folderPath = folderPath ? path.posix.join(folderPath, segment) : segment;
1574
+ let folderNode = nodes.find((node) => node.relativePath === folderPath);
1575
+ if (!folderNode) {
1576
+ folderNode = {
1577
+ label: segment,
1578
+ relativePath: folderPath,
1579
+ children: [],
1580
+ entries: [],
1581
+ directEntries: [],
1582
+ };
1583
+ nodes.push(folderNode);
1584
+ }
1585
+ folderNode.entries.push(entry);
1586
+ parentNode = folderNode;
1587
+ nodes = folderNode.children;
1588
+ }
1589
+
1590
+ if (parentNode) {
1591
+ parentNode.directEntries.push(entry);
1592
+ } else {
1593
+ rootEntries.push(entry);
1594
+ }
571
1595
  }
572
1596
 
573
- const agentCodexScript = readPackageJson(repoRoot)?.scripts?.['agent:codex'];
574
- if (typeof agentCodexScript === 'string' && agentCodexScript.trim().length > 0) {
575
- return `npm run agent:codex -- ${taskArg} ${agentArg}`;
1597
+ if (!hasProjectFolders) {
1598
+ return rootEntries.map((entry) => entry.item);
576
1599
  }
577
1600
 
578
- return `gx branch start ${taskArg} ${agentArg}`;
579
- }
1601
+ sortFolders(projectRoots);
580
1602
 
581
- function sessionDisplayLabel(session) {
582
- return session?.taskName || session?.label || session?.branch || path.basename(session?.worktreePath || '') || 'session';
583
- }
1603
+ function materialize(nodes) {
1604
+ return nodes.map((node) => new FolderItem(
1605
+ node.label,
1606
+ node.relativePath,
1607
+ [
1608
+ ...materialize(node.children),
1609
+ ...node.directEntries.map((entry) => entry.item),
1610
+ ],
1611
+ {
1612
+ description: buildProjectScopedDescription(node.entries),
1613
+ tooltip: [node.relativePath, buildProjectScopedDescription(node.entries)].filter(Boolean).join('\n'),
1614
+ },
1615
+ ));
1616
+ }
584
1617
 
585
- function sessionTreeLabel(session) {
586
- return session?.branch || sessionDisplayLabel(session);
587
- }
1618
+ const items = materialize(projectRoots);
1619
+ if (rootEntries.length === 0) {
1620
+ return items;
1621
+ }
588
1622
 
589
- function sessionWorktreePath(session) {
590
- return typeof session?.worktreePath === 'string' ? session.worktreePath.trim() : '';
1623
+ const rootLabel = typeof options.rootLabel === 'string' ? options.rootLabel.trim() : '';
1624
+ if (!rootLabel) {
1625
+ items.push(...rootEntries.map((entry) => entry.item));
1626
+ return items;
1627
+ }
1628
+
1629
+ items.push(new FolderItem(
1630
+ rootLabel,
1631
+ '',
1632
+ rootEntries.map((entry) => entry.item),
1633
+ {
1634
+ description: buildProjectScopedDescription(rootEntries),
1635
+ tooltip: rootLabel,
1636
+ },
1637
+ ));
1638
+ return items;
591
1639
  }
592
1640
 
593
1641
  function showSessionMessage(message) {
@@ -622,6 +1670,82 @@ function runSessionTerminalCommand(session, actionLabel, iconId, commandText) {
622
1670
  terminal.sendText(commandText, true);
623
1671
  }
624
1672
 
1673
+ function sessionTerminalLabel(session) {
1674
+ return `GitGuardex Terminal: ${sessionDisplayLabel(session)}`;
1675
+ }
1676
+
1677
+ function listWindowTerminals() {
1678
+ return Array.isArray(vscode.window.terminals) ? vscode.window.terminals : [];
1679
+ }
1680
+
1681
+ function focusTerminal(terminal) {
1682
+ terminal?.show?.(false);
1683
+ }
1684
+
1685
+ async function terminalProcessId(terminal) {
1686
+ if (!terminal?.processId) {
1687
+ return null;
1688
+ }
1689
+
1690
+ try {
1691
+ const pid = await terminal.processId;
1692
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
1693
+ } catch (_error) {
1694
+ return null;
1695
+ }
1696
+ }
1697
+
1698
+ function findFallbackSessionTerminal(session) {
1699
+ const label = sessionTerminalLabel(session);
1700
+ return listWindowTerminals().find((terminal) => terminal?.name === label) || null;
1701
+ }
1702
+
1703
+ async function findSessionTerminal(session) {
1704
+ const pid = Number(session?.pid);
1705
+ if (!Number.isInteger(pid) || pid <= 0) {
1706
+ return null;
1707
+ }
1708
+
1709
+ for (const terminal of listWindowTerminals()) {
1710
+ if (await terminalProcessId(terminal) === pid) {
1711
+ return terminal;
1712
+ }
1713
+ }
1714
+
1715
+ return null;
1716
+ }
1717
+
1718
+ function openFallbackSessionTerminal(session, worktreePath) {
1719
+ const existingTerminal = findFallbackSessionTerminal(session);
1720
+ if (existingTerminal) {
1721
+ focusTerminal(existingTerminal);
1722
+ return existingTerminal;
1723
+ }
1724
+
1725
+ const terminal = vscode.window.createTerminal({
1726
+ name: sessionTerminalLabel(session),
1727
+ cwd: worktreePath,
1728
+ iconPath: new vscode.ThemeIcon('terminal'),
1729
+ });
1730
+ focusTerminal(terminal);
1731
+ return terminal;
1732
+ }
1733
+
1734
+ async function showSessionTerminal(session) {
1735
+ const worktreePath = ensureSessionWorktree(session, 'show terminal');
1736
+ if (!worktreePath) {
1737
+ return;
1738
+ }
1739
+
1740
+ const terminal = await findSessionTerminal(session);
1741
+ if (terminal) {
1742
+ focusTerminal(terminal);
1743
+ return;
1744
+ }
1745
+
1746
+ openFallbackSessionTerminal(session, worktreePath);
1747
+ }
1748
+
625
1749
  function finishSession(session) {
626
1750
  if (!session?.branch) {
627
1751
  showSessionMessage('Cannot finish session: missing branch name.');
@@ -639,6 +1763,13 @@ function syncSession(session) {
639
1763
  runSessionTerminalCommand(session, 'Sync', 'sync', 'gx sync');
640
1764
  }
641
1765
 
1766
+ async function restartActiveAgents(extensionId) {
1767
+ if (extensionId && extensionId !== ACTIVE_AGENTS_EXTENSION_ID) {
1768
+ return;
1769
+ }
1770
+ await vscode.commands.executeCommand(RESTART_EXTENSION_HOST_COMMAND);
1771
+ }
1772
+
642
1773
  function execFileAsync(command, args, options = {}) {
643
1774
  return new Promise((resolve, reject) => {
644
1775
  cp.execFile(command, args, options, (error, stdout = '', stderr = '') => {
@@ -653,6 +1784,14 @@ function execFileAsync(command, args, options = {}) {
653
1784
  });
654
1785
  }
655
1786
 
1787
+ function buildStopSessionCommandText(session, pid) {
1788
+ const parts = ['gx', 'agents', 'stop', '--pid', String(pid)];
1789
+ if (session?.repoRoot) {
1790
+ parts.push('--target', session.repoRoot);
1791
+ }
1792
+ return parts.map(shellQuote).join(' ');
1793
+ }
1794
+
656
1795
  async function stopSession(session, refresh) {
657
1796
  const pid = Number(session?.pid);
658
1797
  if (!Number.isInteger(pid) || pid <= 0) {
@@ -664,15 +1803,29 @@ async function stopSession(session, refresh) {
664
1803
  return;
665
1804
  }
666
1805
 
1806
+ const sessionTerminal = await findSessionTerminal(session);
1807
+ const stopCommandText = buildStopSessionCommandText(session, pid);
667
1808
  const confirmed = await vscode.window.showWarningMessage(
668
1809
  `Stop ${sessionDisplayLabel(session)}?`,
669
- { modal: true, detail: `Run gx agents stop --pid ${pid}.` },
1810
+ {
1811
+ modal: true,
1812
+ detail: sessionTerminal
1813
+ ? 'Send Ctrl+C to the live session terminal.'
1814
+ : `No live session terminal found. Run ${stopCommandText}.`,
1815
+ },
670
1816
  'Stop',
671
1817
  );
672
1818
  if (confirmed !== 'Stop') {
673
1819
  return;
674
1820
  }
675
1821
 
1822
+ if (sessionTerminal) {
1823
+ focusTerminal(sessionTerminal);
1824
+ sessionTerminal.sendText('\u0003', false);
1825
+ refresh();
1826
+ return;
1827
+ }
1828
+
676
1829
  try {
677
1830
  const commandCwd = session?.repoRoot || sessionWorktreePath(session) || process.cwd();
678
1831
  const args = ['agents', 'stop', '--pid', String(pid)];
@@ -692,69 +1845,87 @@ async function stopSession(session, refresh) {
692
1845
  }
693
1846
  }
694
1847
 
695
- function sessionChangedPaths(session) {
696
- const directPaths = Array.isArray(session?.changedPaths)
697
- ? session.changedPaths.map(normalizeRelativePath).filter(Boolean)
698
- : [];
699
- if (directPaths.length > 0) {
700
- return [...new Set(directPaths)];
1848
+ function readGitDirPath(targetPath) {
1849
+ const normalizedTargetPath = typeof targetPath === 'string' ? targetPath.trim() : '';
1850
+ if (!normalizedTargetPath) {
1851
+ return '';
701
1852
  }
702
- if (!session?.repoRoot || !session?.branch) {
703
- return [];
1853
+
1854
+ const gitPath = path.join(path.resolve(normalizedTargetPath), '.git');
1855
+ try {
1856
+ if (fs.statSync(gitPath).isDirectory()) {
1857
+ return gitPath;
1858
+ }
1859
+ } catch (_error) {
1860
+ return '';
704
1861
  }
705
1862
 
706
- const liveSession = readActiveSessions(session.repoRoot)
707
- .find((entry) => sessionSelectionKey(entry) === sessionSelectionKey(session));
708
- return Array.isArray(liveSession?.changedPaths)
709
- ? [...new Set(liveSession.changedPaths.map(normalizeRelativePath).filter(Boolean))]
710
- : [];
1863
+ try {
1864
+ const gitPointer = fs.readFileSync(gitPath, 'utf8');
1865
+ const match = gitPointer.match(/^gitdir:\s*(.+)$/m);
1866
+ if (match?.[1]) {
1867
+ return path.resolve(path.dirname(gitPath), match[1].trim());
1868
+ }
1869
+ } catch (_error) {
1870
+ return '';
1871
+ }
1872
+
1873
+ return '';
711
1874
  }
712
1875
 
713
- async function pickSessionDiffPath(session) {
714
- const changedPaths = sessionChangedPaths(session);
715
- if (changedPaths.length === 0) {
1876
+ function resolveRepoRootFromGitDir(targetPath) {
1877
+ const gitDir = readGitDirPath(targetPath);
1878
+ if (!gitDir) {
716
1879
  return '';
717
1880
  }
718
- if (changedPaths.length === 1 || !vscode.window.showQuickPick) {
719
- return changedPaths[0];
1881
+
1882
+ let commonDir = gitDir;
1883
+ try {
1884
+ const commonDirPath = path.join(gitDir, 'commondir');
1885
+ if (fs.existsSync(commonDirPath)) {
1886
+ const rawCommonDir = fs.readFileSync(commonDirPath, 'utf8').trim();
1887
+ if (rawCommonDir) {
1888
+ commonDir = path.resolve(gitDir, rawCommonDir);
1889
+ }
1890
+ }
1891
+ } catch (_error) {
1892
+ // Fall back to the direct git dir when commondir is unreadable.
720
1893
  }
721
1894
 
722
- const picks = changedPaths.map((relativePath) => ({
723
- label: path.basename(relativePath),
724
- description: relativePath,
725
- relativePath,
726
- }));
727
- const selection = await vscode.window.showQuickPick(picks, {
728
- placeHolder: `Select a changed file for ${sessionDisplayLabel(session)}`,
729
- ignoreFocusOut: true,
730
- });
731
- return selection?.relativePath || '';
1895
+ return path.basename(commonDir) === '.git'
1896
+ ? path.resolve(path.dirname(commonDir))
1897
+ : '';
732
1898
  }
733
1899
 
734
- async function openSessionDiff(session) {
735
- const worktreePath = ensureSessionWorktree(session, 'open diff');
736
- if (!worktreePath) {
737
- return;
1900
+ function readGitTopLevel(targetPath) {
1901
+ try {
1902
+ return cp.execFileSync('git', ['-C', targetPath, 'rev-parse', '--show-toplevel'], {
1903
+ encoding: 'utf8',
1904
+ stdio: ['ignore', 'pipe', 'ignore'],
1905
+ }).trim();
1906
+ } catch (_error) {
1907
+ return '';
738
1908
  }
1909
+ }
739
1910
 
740
- const relativePath = await pickSessionDiffPath(session);
741
- if (!relativePath) {
742
- showSessionMessage(`No changed files to diff for ${sessionDisplayLabel(session)}.`);
743
- return;
1911
+ function resolveWorkspaceFolderRepoRoot(workspacePath) {
1912
+ const normalizedWorkspacePath = typeof workspacePath === 'string' ? workspacePath.trim() : '';
1913
+ if (!normalizedWorkspacePath) {
1914
+ return '';
744
1915
  }
745
1916
 
746
- const repoRoot = session?.repoRoot || worktreePath;
747
- const absolutePath = path.resolve(repoRoot, relativePath);
748
- const resourceUri = vscode.Uri.file(absolutePath);
749
- try {
750
- await vscode.commands.executeCommand('git.openChange', resourceUri);
751
- } catch (error) {
752
- if (fs.existsSync(absolutePath)) {
753
- await vscode.commands.executeCommand('vscode.open', resourceUri);
754
- return;
755
- }
756
- showSessionMessage(`Failed to open diff for ${sessionDisplayLabel(session)}: ${formatGitCommandFailure(error)}`);
1917
+ const absoluteWorkspacePath = path.resolve(normalizedWorkspacePath);
1918
+ const directRepoRoot = resolveRepoRootFromGitDir(absoluteWorkspacePath);
1919
+ if (directRepoRoot) {
1920
+ return directRepoRoot;
757
1921
  }
1922
+
1923
+ const gitTopLevel = readGitTopLevel(absoluteWorkspacePath);
1924
+ if (!gitTopLevel) {
1925
+ return absoluteWorkspacePath;
1926
+ }
1927
+
1928
+ return resolveRepoRootFromGitDir(gitTopLevel) || path.resolve(gitTopLevel);
758
1929
  }
759
1930
 
760
1931
  function repoRootFromSessionFile(filePath) {
@@ -765,6 +1936,10 @@ function repoRootFromWorktreeLockFile(filePath) {
765
1936
  return path.resolve(path.dirname(filePath), '..', '..', '..');
766
1937
  }
767
1938
 
1939
+ function repoRootFromManagedWorktreeGitFile(filePath) {
1940
+ return path.resolve(path.dirname(filePath), '..', '..', '..');
1941
+ }
1942
+
768
1943
  function repoRootFromLockFile(filePath) {
769
1944
  return path.resolve(path.dirname(filePath), '..', '..');
770
1945
  }
@@ -952,22 +2127,33 @@ async function maybeAutoUpdateActiveAgentsExtension(context) {
952
2127
 
953
2128
  function decorateSession(session, lockRegistry) {
954
2129
  const touchedChanges = buildSessionTouchedChanges(session, lockRegistry);
955
- return {
2130
+ const decorated = {
956
2131
  ...session,
957
2132
  lockCount: lockRegistry.countsByBranch.get(session.branch) || 0,
958
2133
  touchedChanges,
959
2134
  conflictCount: touchedChanges.filter((change) => change.hasForeignLock).length,
960
2135
  };
2136
+ decorated.lastActiveAt = sessionLastActiveAt(decorated);
2137
+ decorated.lastActiveLabel = sessionLastActiveLabel(decorated);
2138
+ decorated.freshnessLabel = sessionFreshnessLabel(decorated);
2139
+ decorated.topChangedFiles = buildSessionTopFiles(decorated);
2140
+ decorated.topChangedFilesLabel = summarizeCompactPaths(decorated.topChangedFiles);
2141
+ decorated.recentChangeSummary = buildSessionRecentChangeSummary(decorated);
2142
+ decorated.riskBadges = sessionRiskBadges(decorated);
2143
+ return decorated;
961
2144
  }
962
2145
 
963
2146
  function decorateChange(change, lockRegistry, owningBranch) {
964
2147
  const lockEntry = lockRegistry.entriesByPath.get(normalizeRelativePath(change.relativePath));
965
2148
  const lockOwnerBranch = lockEntry?.branch || '';
966
- return {
2149
+ const decorated = {
967
2150
  ...change,
968
2151
  lockOwnerBranch,
969
2152
  hasForeignLock: Boolean(lockOwnerBranch) && (!owningBranch || lockOwnerBranch !== owningBranch),
2153
+ protectedBranch: isProtectedBranchName(owningBranch),
970
2154
  };
2155
+ decorated.riskBadges = changeRiskBadges(decorated);
2156
+ return decorated;
971
2157
  }
972
2158
 
973
2159
  function buildSessionTouchedChanges(session, lockRegistry) {
@@ -975,7 +2161,6 @@ function buildSessionTouchedChanges(session, lockRegistry) {
975
2161
  ? session.worktreeChangedPaths
976
2162
  : [];
977
2163
  return [...new Set(changedPaths.map(normalizeRelativePath).filter(Boolean))]
978
- .sort((left, right) => left.localeCompare(right))
979
2164
  .map((relativePath) => {
980
2165
  const lockEntry = lockRegistry.entriesByPath.get(relativePath);
981
2166
  const lockOwnerBranch = lockEntry?.branch || '';
@@ -1018,7 +2203,7 @@ function localizeChangeForSession(session, change) {
1018
2203
  }
1019
2204
 
1020
2205
  async function findRepoSessionEntries() {
1021
- const [sessionFiles, worktreeLockFiles] = await Promise.all([
2206
+ const [sessionFiles, worktreeLockFiles, managedWorktreeGitFiles] = await Promise.all([
1022
2207
  vscode.workspace.findFiles(
1023
2208
  ACTIVE_SESSION_FILES_GLOB,
1024
2209
  SESSION_SCAN_EXCLUDE_GLOB,
@@ -1029,22 +2214,49 @@ async function findRepoSessionEntries() {
1029
2214
  WORKTREE_LOCK_SCAN_EXCLUDE_GLOB,
1030
2215
  SESSION_SCAN_LIMIT,
1031
2216
  ),
2217
+ vscode.workspace.findFiles(
2218
+ MANAGED_WORKTREE_GIT_FILES_GLOB,
2219
+ MANAGED_WORKTREE_GIT_SCAN_EXCLUDE_GLOB,
2220
+ SESSION_SCAN_LIMIT,
2221
+ ),
1032
2222
  ]);
1033
2223
 
1034
2224
  const repoRoots = new Set();
2225
+ const addRepoRootCandidate = (repoRoot) => {
2226
+ if (typeof repoRoot !== 'string' || !repoRoot.trim()) {
2227
+ return;
2228
+ }
2229
+
2230
+ const normalizedRepoRoot = path.resolve(repoRoot);
2231
+ const isInsideWorkspaceManagedWorktree = (vscode.workspace.workspaceFolders || [])
2232
+ .map((folder) => (typeof folder?.uri?.fsPath === 'string' ? path.resolve(folder.uri.fsPath) : ''))
2233
+ .filter(Boolean)
2234
+ .some((workspaceRoot) => MANAGED_WORKTREE_RELATIVE_ROOTS.some((relativeRoot) => (
2235
+ isPathWithin(path.join(workspaceRoot, relativeRoot), normalizedRepoRoot)
2236
+ )));
2237
+ if (!isInsideWorkspaceManagedWorktree) {
2238
+ repoRoots.add(normalizedRepoRoot);
2239
+ }
2240
+ };
2241
+
1035
2242
  for (const uri of sessionFiles) {
1036
- repoRoots.add(repoRootFromSessionFile(uri.fsPath));
2243
+ addRepoRootCandidate(repoRootFromSessionFile(uri.fsPath));
1037
2244
  }
1038
2245
  for (const uri of worktreeLockFiles) {
1039
2246
  if (path.basename(uri.fsPath) !== 'AGENT.lock') {
1040
2247
  continue;
1041
2248
  }
1042
- repoRoots.add(repoRootFromWorktreeLockFile(uri.fsPath));
2249
+ addRepoRootCandidate(repoRootFromWorktreeLockFile(uri.fsPath));
1043
2250
  }
1044
-
1045
- if (repoRoots.size === 0) {
1046
- for (const workspaceFolder of vscode.workspace.workspaceFolders || []) {
1047
- repoRoots.add(workspaceFolder.uri.fsPath);
2251
+ for (const uri of managedWorktreeGitFiles) {
2252
+ if (path.basename(uri.fsPath) !== '.git') {
2253
+ continue;
2254
+ }
2255
+ addRepoRootCandidate(repoRootFromManagedWorktreeGitFile(uri.fsPath));
2256
+ }
2257
+ for (const workspaceFolder of vscode.workspace.workspaceFolders || []) {
2258
+ if (workspaceFolder?.uri?.fsPath) {
2259
+ addRepoRootCandidate(resolveWorkspaceFolderRepoRoot(workspaceFolder.uri.fsPath));
1048
2260
  }
1049
2261
  }
1050
2262
 
@@ -1164,10 +2376,6 @@ function buildChangeTreeNodes(changes) {
1164
2376
  return materialize(root);
1165
2377
  }
1166
2378
 
1167
- function countWorkingSessions(sessions) {
1168
- return sessions.filter((session) => session.activityKind === 'working').length;
1169
- }
1170
-
1171
2379
  function countChangedPaths(repoRoot, sessions, changes) {
1172
2380
  const changedKeys = new Set();
1173
2381
 
@@ -1193,6 +2401,20 @@ function countChangedPaths(repoRoot, sessions, changes) {
1193
2401
  return changedKeys.size;
1194
2402
  }
1195
2403
 
2404
+ function buildRepoOverview(sessions, unassignedChanges, lockEntries) {
2405
+ return {
2406
+ sessionCount: sessions.length,
2407
+ workingCount: countWorkingSessions(sessions),
2408
+ idleCount: countIdleSessions(sessions),
2409
+ unassignedChangeCount: (unassignedChanges || []).length,
2410
+ lockedFileCount: Array.isArray(lockEntries) ? lockEntries.length : 0,
2411
+ conflictCount: sessions.reduce(
2412
+ (total, session) => total + (session.conflictCount || 0),
2413
+ 0,
2414
+ ) + (unassignedChanges || []).filter((change) => change.hasForeignLock).length,
2415
+ };
2416
+ }
2417
+
1196
2418
  function groupSessionsByWorktree(sessions) {
1197
2419
  const sessionsByWorktree = new Map();
1198
2420
 
@@ -1223,7 +2445,7 @@ function groupSessionsByWorktree(sessions) {
1223
2445
  });
1224
2446
  }
1225
2447
 
1226
- function buildGroupedChangeTreeNodes(sessions, changes) {
2448
+ function partitionChangesByOwnership(sessions, changes) {
1227
2449
  const changesBySession = new Map();
1228
2450
  const sessionByChangedPath = new Map();
1229
2451
  const repoRootChanges = [];
@@ -1255,22 +2477,40 @@ function buildGroupedChangeTreeNodes(sessions, changes) {
1255
2477
  changesBySession.get(session.branch).push(localizedChange);
1256
2478
  }
1257
2479
 
1258
- const items = groupSessionsByWorktree(
1259
- sessions.filter((session) => (changesBySession.get(session.branch) || []).length > 0),
1260
- ).map(({ worktreePath, sessions: worktreeSessions }) => {
1261
- const sessionItems = worktreeSessions.map((session) => (
1262
- new SessionItem(
1263
- session,
1264
- buildChangeTreeNodes(changesBySession.get(session.branch) || []),
1265
- { label: sessionTreeLabel(session) },
1266
- )
1267
- ));
1268
- const changedCount = worktreeSessions.reduce(
1269
- (total, session) => total + ((changesBySession.get(session.branch) || []).length),
1270
- 0,
1271
- );
1272
- return new WorktreeItem(worktreePath, worktreeSessions, sessionItems, { changedCount });
1273
- });
2480
+ return {
2481
+ changesBySession,
2482
+ repoRootChanges,
2483
+ };
2484
+ }
2485
+
2486
+ function buildGroupedChangeTreeNodes(sessions, changes) {
2487
+ const { changesBySession, repoRootChanges } = partitionChangesByOwnership(sessions, changes);
2488
+
2489
+ const items = buildProjectScopedItems(
2490
+ groupSessionsByWorktree(
2491
+ sessions.filter((session) => (changesBySession.get(session.branch) || []).length > 0),
2492
+ ).map(({ worktreePath, sessions: worktreeSessions }) => {
2493
+ const sessionItems = worktreeSessions.map((session) => (
2494
+ new SessionItem(
2495
+ session,
2496
+ buildChangeTreeNodes(changesBySession.get(session.branch) || []),
2497
+ {
2498
+ label: sessionTreeLabel(session),
2499
+ variant: 'raw',
2500
+ },
2501
+ )
2502
+ ));
2503
+ const changedCount = worktreeSessions.reduce(
2504
+ (total, session) => total + ((changesBySession.get(session.branch) || []).length),
2505
+ 0,
2506
+ );
2507
+ return {
2508
+ projectRelativePath: worktreeProjectRelativePath(worktreeSessions),
2509
+ sessions: worktreeSessions,
2510
+ item: new WorktreeItem(worktreePath, worktreeSessions, sessionItems, { changedCount }),
2511
+ };
2512
+ }),
2513
+ );
1274
2514
 
1275
2515
  if (repoRootChanges.length > 0) {
1276
2516
  items.push(new SectionItem('Repo root', buildChangeTreeNodes(repoRootChanges), {
@@ -1395,23 +2635,127 @@ function commitWorktree(worktreePath, message) {
1395
2635
  runGitCommand(worktreePath, ['commit', '-m', message]);
1396
2636
  }
1397
2637
 
1398
- function buildActiveAgentGroupNodes(sessions) {
2638
+ function buildSessionDetailItems(session) {
2639
+ const provider = resolveSessionProvider(session);
2640
+ const snapshot = sessionSnapshotDisplayName(session);
2641
+ const projectRelativePath = resolveSessionProjectRelativePath(session);
2642
+ const badgeSummary = uniqueStringList([
2643
+ ...(session.riskBadges || []),
2644
+ session.deltaLabel || '',
2645
+ ].filter(Boolean)).join(', ');
2646
+ const sessionHealthSummary = buildSessionHealthSummary(session);
2647
+ const items = [
2648
+ new DetailItem('Recent change', session.recentChangeSummary || 'No recent change summary.', {
2649
+ iconId: 'history',
2650
+ }),
2651
+ new DetailItem('Top files', session.topChangedFilesLabel || 'No tracked file edits.', {
2652
+ iconId: 'list-flat',
2653
+ }),
2654
+ ];
2655
+ if (badgeSummary) {
2656
+ items.push(new DetailItem('Signals', badgeSummary, {
2657
+ iconId: 'warning',
2658
+ }));
2659
+ }
2660
+ if (sessionHealthSummary) {
2661
+ items.push(new DetailItem('Session health', sessionHealthSummary, {
2662
+ iconId: 'pulse',
2663
+ tooltip: buildSessionHealthTooltip(session) || sessionHealthSummary,
2664
+ }));
2665
+ }
2666
+ if (provider?.label) {
2667
+ items.push(new DetailItem('Provider', provider.label, {
2668
+ iconId: 'sparkle',
2669
+ }));
2670
+ }
2671
+ if (snapshot) {
2672
+ items.push(new DetailItem('Snapshot', snapshot, {
2673
+ iconId: 'account',
2674
+ }));
2675
+ }
2676
+ if (projectRelativePath) {
2677
+ items.push(new DetailItem('Project', projectRelativePath, {
2678
+ iconId: 'folder',
2679
+ tooltip: projectRelativePath,
2680
+ }));
2681
+ }
2682
+ items.push(new DetailItem('Branch', session.branch, {
2683
+ iconId: 'git-branch',
2684
+ }));
2685
+ items.push(new DetailItem('Worktree', session.worktreePath, {
2686
+ iconId: 'folder',
2687
+ tooltip: session.worktreePath,
2688
+ }));
2689
+ return items;
2690
+ }
2691
+
2692
+ function buildWorkingNowNodes(sessions) {
2693
+ const sessionEntries = sortSessionsForWorkingNow(
2694
+ sessions.filter((session) => (
2695
+ session.activityKind === 'working' || session.activityKind === 'blocked'
2696
+ )),
2697
+ ).map((session) => ({
2698
+ projectRelativePath: resolveSessionProjectRelativePath(session),
2699
+ sessions: [session],
2700
+ item: new SessionItem(session, buildSessionDetailItems(session)),
2701
+ }));
2702
+ return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' });
2703
+ }
2704
+
2705
+ function buildIdleThinkingNodes(sessions) {
2706
+ const sessionEntries = sortSessionsForIdleThinking(
2707
+ sessions.filter((session) => !(
2708
+ session.activityKind === 'working' || session.activityKind === 'blocked'
2709
+ )),
2710
+ ).map((session) => ({
2711
+ projectRelativePath: resolveSessionProjectRelativePath(session),
2712
+ sessions: [session],
2713
+ item: new SessionItem(session, buildSessionDetailItems(session)),
2714
+ }));
2715
+ return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' });
2716
+ }
2717
+
2718
+ function buildUnassignedChangeNodes(changes) {
2719
+ return sortUnassignedChanges(changes).map((change) => new ChangeItem(change, {
2720
+ label: compactRelativePath(change.relativePath),
2721
+ description: buildUnassignedChangeDescription(change),
2722
+ iconId: changeRiskBadges(change).length > 0 ? 'warning' : undefined,
2723
+ }));
2724
+ }
2725
+
2726
+ function buildRawActiveAgentGroupNodes(sessions) {
1399
2727
  const groups = [];
1400
2728
  for (const group of SESSION_ACTIVITY_GROUPS) {
1401
2729
  const groupSessions = sessions.filter((session) => session.activityKind === group.kind);
1402
- const worktreeItems = groupSessionsByWorktree(groupSessions).map(({ worktreePath, sessions: worktreeSessions }) => (
1403
- new WorktreeItem(
1404
- worktreePath,
1405
- worktreeSessions,
1406
- worktreeSessions.map((session) => new SessionItem(
1407
- session,
1408
- buildChangeTreeNodes(session.touchedChanges || []),
1409
- { label: sessionTreeLabel(session) },
1410
- )),
1411
- )
1412
- ));
2730
+ const worktreeItems = buildProjectScopedItems(
2731
+ groupSessionsByWorktree(groupSessions).map(({ worktreePath, sessions: worktreeSessions }) => ({
2732
+ projectRelativePath: worktreeProjectRelativePath(worktreeSessions),
2733
+ sessions: worktreeSessions,
2734
+ item: new WorktreeItem(
2735
+ worktreePath,
2736
+ worktreeSessions,
2737
+ worktreeSessions.map((session) => new SessionItem(
2738
+ session,
2739
+ buildChangeTreeNodes(session.touchedChanges || []),
2740
+ {
2741
+ label: sessionTreeLabel(session),
2742
+ variant: 'raw',
2743
+ },
2744
+ )),
2745
+ {
2746
+ description: buildWorktreeBranchDescription(worktreeSessions),
2747
+ iconId: 'git-branch',
2748
+ resourceSession: worktreeSessions[0],
2749
+ useSessionDecoration: true,
2750
+ },
2751
+ ),
2752
+ })),
2753
+ { rootLabel: 'Repo root' },
2754
+ );
1413
2755
  if (worktreeItems.length > 0) {
1414
- groups.push(new SectionItem(group.label, worktreeItems));
2756
+ groups.push(new SectionItem(group.label, worktreeItems, {
2757
+ iconId: resolveSessionActivityIconId(group.kind),
2758
+ }));
1415
2759
  }
1416
2760
  }
1417
2761
 
@@ -1431,9 +2775,13 @@ class ActiveAgentsProvider {
1431
2775
  this.viewSummary = {
1432
2776
  sessionCount: 0,
1433
2777
  workingCount: 0,
2778
+ idleCount: 0,
2779
+ unassignedChangeCount: 0,
2780
+ lockedFileCount: 0,
1434
2781
  deadCount: 0,
1435
2782
  conflictCount: 0,
1436
2783
  };
2784
+ this.previousSnapshot = null;
1437
2785
  }
1438
2786
 
1439
2787
  getTreeItem(element) {
@@ -1442,7 +2790,15 @@ class ActiveAgentsProvider {
1442
2790
 
1443
2791
  attachTreeView(treeView) {
1444
2792
  this.treeView = treeView;
1445
- this.updateViewState(0, 0, 0);
2793
+ this.updateViewState({
2794
+ sessionCount: 0,
2795
+ workingCount: 0,
2796
+ idleCount: 0,
2797
+ unassignedChangeCount: 0,
2798
+ lockedFileCount: 0,
2799
+ deadCount: 0,
2800
+ conflictCount: 0,
2801
+ });
1446
2802
  treeView.onDidChangeSelection?.((event) => {
1447
2803
  const sessionItem = event.selection.find((item) => item instanceof SessionItem);
1448
2804
  this.setSelectedSession(sessionItem?.session || null);
@@ -1479,60 +2835,100 @@ class ActiveAgentsProvider {
1479
2835
  this.setSelectedSession(nextSession || null);
1480
2836
  }
1481
2837
 
1482
- updateViewState(sessionCount, workingCount, deadCount, conflictCount = 0) {
2838
+ updateViewState(summary) {
1483
2839
  if (!this.treeView) {
1484
2840
  return;
1485
2841
  }
1486
2842
 
1487
- const activeCount = Math.max(0, sessionCount - deadCount);
1488
- this.viewSummary = {
1489
- sessionCount,
1490
- workingCount,
1491
- deadCount,
1492
- conflictCount,
1493
- };
2843
+ const sessionCount = summary?.sessionCount || 0;
2844
+ const conflictCount = summary?.conflictCount || 0;
2845
+ this.viewSummary = { ...summary };
1494
2846
  void vscode.commands.executeCommand('setContext', 'guardex.hasAgents', sessionCount > 0);
1495
2847
  void vscode.commands.executeCommand('setContext', 'guardex.hasConflicts', conflictCount > 0);
1496
- const badgeTooltipParts = [];
1497
- if (activeCount > 0) {
1498
- badgeTooltipParts.push(`${activeCount} active agent${activeCount === 1 ? '' : 's'}`);
1499
- }
1500
- if (deadCount > 0) {
1501
- badgeTooltipParts.push(`${deadCount} dead`);
1502
- }
1503
- if (workingCount > 0) {
1504
- badgeTooltipParts.push(`${workingCount} working now`);
1505
- }
1506
- if (conflictCount > 0) {
1507
- badgeTooltipParts.push(`${conflictCount} conflict${conflictCount === 1 ? '' : 's'}`);
1508
- }
1509
2848
 
1510
2849
  this.treeView.badge = sessionCount > 0
1511
2850
  ? {
1512
2851
  value: sessionCount,
1513
- tooltip: badgeTooltipParts.join(' · '),
2852
+ tooltip: buildOverviewDescription(summary),
1514
2853
  }
1515
2854
  : undefined;
1516
2855
  this.treeView.message = undefined;
1517
2856
  }
1518
2857
 
2858
+ annotateRepoEntries(repoEntries) {
2859
+ const hasPreviousSnapshot = Boolean(this.previousSnapshot);
2860
+ const nextSnapshot = {
2861
+ sessions: new Map(),
2862
+ changes: new Map(),
2863
+ };
2864
+
2865
+ const annotatedEntries = repoEntries.map((entry) => {
2866
+ const sessions = entry.sessions.map((session) => {
2867
+ const snapshotKey = sessionSnapshotKey(session);
2868
+ nextSnapshot.sessions.set(snapshotKey, buildSessionSnapshot(session));
2869
+ const deltaLabel = hasPreviousSnapshot
2870
+ ? deriveSessionDelta(this.previousSnapshot.sessions.get(snapshotKey), session)
2871
+ : '';
2872
+ return {
2873
+ ...session,
2874
+ deltaLabel,
2875
+ riskBadges: uniqueStringList([
2876
+ ...(session.riskBadges || []),
2877
+ deltaLabel,
2878
+ ].filter(Boolean)),
2879
+ };
2880
+ });
2881
+
2882
+ const changes = entry.changes.map((change) => {
2883
+ const snapshotKey = changeSnapshotKey(entry.repoRoot, change);
2884
+ nextSnapshot.changes.set(snapshotKey, buildChangeSnapshot(change));
2885
+ const deltaLabel = hasPreviousSnapshot
2886
+ ? deriveChangeDelta(this.previousSnapshot.changes.get(snapshotKey), change)
2887
+ : '';
2888
+ return {
2889
+ ...change,
2890
+ deltaLabel,
2891
+ riskBadges: changeRiskBadges({
2892
+ ...change,
2893
+ deltaLabel,
2894
+ }),
2895
+ };
2896
+ });
2897
+
2898
+ const { repoRootChanges } = partitionChangesByOwnership(sessions, changes);
2899
+ const unassignedChanges = sortUnassignedChanges(repoRootChanges);
2900
+ return {
2901
+ ...entry,
2902
+ sessions,
2903
+ changes,
2904
+ unassignedChanges,
2905
+ overview: buildRepoOverview(sessions, unassignedChanges, entry.lockEntries),
2906
+ };
2907
+ });
2908
+
2909
+ this.previousSnapshot = nextSnapshot;
2910
+ return annotatedEntries;
2911
+ }
2912
+
1519
2913
  async syncRepoEntries() {
1520
- const repoEntries = await this.loadRepoEntries();
1521
- const sessionCount = repoEntries.reduce((total, entry) => total + entry.sessions.length, 0);
1522
- const workingCount = repoEntries.reduce(
1523
- (total, entry) => total + countWorkingSessions(entry.sessions),
1524
- 0,
1525
- );
1526
- const deadCount = repoEntries.reduce(
1527
- (total, entry) => total + countSessionsByActivityKind(entry.sessions, 'dead'),
1528
- 0,
1529
- );
1530
- const conflictCount = repoEntries.reduce(
1531
- (total, entry) => total + countEntryConflicts(entry),
1532
- 0,
1533
- );
2914
+ const repoEntries = this.annotateRepoEntries(await this.loadRepoEntries());
2915
+ const summary = {
2916
+ sessionCount: repoEntries.reduce((total, entry) => total + entry.sessions.length, 0),
2917
+ workingCount: repoEntries.reduce((total, entry) => total + entry.overview.workingCount, 0),
2918
+ idleCount: repoEntries.reduce((total, entry) => total + entry.overview.idleCount, 0),
2919
+ unassignedChangeCount: repoEntries.reduce(
2920
+ (total, entry) => total + entry.overview.unassignedChangeCount,
2921
+ 0,
2922
+ ),
2923
+ lockedFileCount: repoEntries.reduce((total, entry) => total + entry.overview.lockedFileCount, 0),
2924
+ deadCount: repoEntries.reduce(
2925
+ (total, entry) => total + countSessionsByActivityKind(entry.sessions, 'dead'),
2926
+ 0,
2927
+ ),
2928
+ conflictCount: repoEntries.reduce((total, entry) => total + entry.overview.conflictCount, 0),
2929
+ };
1534
2930
 
1535
- this.updateViewState(sessionCount, workingCount, deadCount, conflictCount);
2931
+ this.updateViewState(summary);
1536
2932
  this.decorationProvider?.updateSessions(repoEntries.flatMap((entry) => entry.sessions));
1537
2933
  this.decorationProvider?.updateLockEntries(repoEntries);
1538
2934
  return repoEntries;
@@ -1561,13 +2957,62 @@ class ActiveAgentsProvider {
1561
2957
  async getChildren(element) {
1562
2958
  if (element instanceof RepoItem) {
1563
2959
  const sectionItems = [
1564
- new SectionItem('ACTIVE AGENTS', buildActiveAgentGroupNodes(element.sessions), {
1565
- description: String(element.sessions.length),
2960
+ new SectionItem('Overview', [
2961
+ new DetailItem('Summary', buildOverviewDescription(element.overview), {
2962
+ iconId: 'graph',
2963
+ tooltip: buildRepoTooltip(element.repoRoot, element.overview),
2964
+ }),
2965
+ ], {
2966
+ description: '1',
1566
2967
  }),
1567
2968
  ];
1568
- if (element.changes.length > 0) {
1569
- sectionItems.push(new SectionItem('CHANGES', buildGroupedChangeTreeNodes(element.sessions, element.changes), {
2969
+
2970
+ const workingNowItems = buildWorkingNowNodes(element.sessions);
2971
+ if (workingNowItems.length > 0) {
2972
+ sectionItems.push(new SectionItem('Working now', workingNowItems, {
2973
+ description: String(workingNowItems.length),
2974
+ iconId: 'loading~spin',
2975
+ }));
2976
+ }
2977
+
2978
+ const idleThinkingItems = buildIdleThinkingNodes(element.sessions);
2979
+ if (idleThinkingItems.length > 0) {
2980
+ sectionItems.push(new SectionItem('Idle / thinking', idleThinkingItems, {
2981
+ description: String(idleThinkingItems.length),
2982
+ collapsedState: vscode.TreeItemCollapsibleState.Collapsed,
2983
+ iconId: 'comment-discussion',
2984
+ }));
2985
+ }
2986
+
2987
+ if (element.unassignedChanges.length > 0) {
2988
+ sectionItems.push(new SectionItem('Unassigned changes', buildUnassignedChangeNodes(element.unassignedChanges), {
2989
+ description: String(element.unassignedChanges.length),
2990
+ iconId: 'warning',
2991
+ }));
2992
+ }
2993
+
2994
+ const advancedItems = [];
2995
+ const rawActiveAgents = buildRawActiveAgentGroupNodes(element.sessions);
2996
+ if (rawActiveAgents.length > 0) {
2997
+ advancedItems.push(new SectionItem('Active agent tree', rawActiveAgents, {
2998
+ description: String(element.sessions.length),
2999
+ collapsedState: vscode.TreeItemCollapsibleState.Collapsed,
3000
+ iconId: 'git-branch',
3001
+ }));
3002
+ }
3003
+ const rawChangeTree = buildGroupedChangeTreeNodes(element.sessions, element.changes);
3004
+ if (rawChangeTree.length > 0) {
3005
+ advancedItems.push(new SectionItem('Raw path tree', rawChangeTree, {
1570
3006
  description: String(element.changes.length),
3007
+ collapsedState: vscode.TreeItemCollapsibleState.Collapsed,
3008
+ iconId: 'list-tree',
3009
+ }));
3010
+ }
3011
+ if (advancedItems.length > 0) {
3012
+ sectionItems.push(new SectionItem('Advanced details', advancedItems, {
3013
+ description: String(advancedItems.length),
3014
+ collapsedState: vscode.TreeItemCollapsibleState.Collapsed,
3015
+ iconId: 'list-tree',
1571
3016
  }));
1572
3017
  }
1573
3018
  return sectionItems;
@@ -1584,7 +3029,12 @@ class ActiveAgentsProvider {
1584
3029
  return [new InfoItem('No active Guardex agents', 'Open or start a sandbox session.')];
1585
3030
  }
1586
3031
 
1587
- return repoEntries.map((entry) => new RepoItem(entry.repoRoot, entry.sessions, entry.changes));
3032
+ return repoEntries.map((entry) => new RepoItem(entry.repoRoot, entry.sessions, entry.changes, {
3033
+ label: repoEntryDisplayLabel(entry.repoRoot, entry.sessions),
3034
+ overview: entry.overview,
3035
+ unassignedChanges: entry.unassignedChanges,
3036
+ lockEntries: entry.lockEntries,
3037
+ }));
1588
3038
  }
1589
3039
 
1590
3040
  async loadRepoEntries() {
@@ -1777,10 +3227,15 @@ function activate(context) {
1777
3227
  activeAgentsStatusItem.command = 'gitguardex.activeAgents.focus';
1778
3228
  provider.attachTreeView(treeView);
1779
3229
  const scheduleRefresh = () => refreshController.scheduleRefresh();
3230
+ const handleWorkspaceFoldersChanged = () => {
3231
+ scheduleRefresh();
3232
+ void ensureManagedRepoScanIgnores();
3233
+ };
1780
3234
  const refresh = () => void refreshController.refreshNow();
1781
3235
  const activeSessionsWatcher = vscode.workspace.createFileSystemWatcher(ACTIVE_SESSION_FILES_GLOB);
1782
3236
  const lockWatcher = vscode.workspace.createFileSystemWatcher(AGENT_FILE_LOCKS_GLOB);
1783
3237
  const worktreeLockWatcher = vscode.workspace.createFileSystemWatcher(WORKTREE_AGENT_LOCKS_GLOB);
3238
+ const managedWorktreeGitWatcher = vscode.workspace.createFileSystemWatcher(MANAGED_WORKTREE_GIT_FILES_GLOB);
1784
3239
  const logWatcher = vscode.workspace.createFileSystemWatcher(AGENT_LOG_FILES_GLOB);
1785
3240
  const updateCommitInput = (session) => {
1786
3241
  sourceControl.inputBox.enabled = true;
@@ -1868,8 +3323,9 @@ function activate(context) {
1868
3323
  vscode.window.registerFileDecorationProvider(decorationProvider),
1869
3324
  vscode.commands.registerCommand('gitguardex.activeAgents.startAgent', () => startAgentFromPrompt(refresh)),
1870
3325
  vscode.commands.registerCommand('gitguardex.activeAgents.refresh', refresh),
3326
+ vscode.commands.registerCommand('gitguardex.activeAgents.restart', restartActiveAgents),
1871
3327
  vscode.commands.registerCommand('gitguardex.activeAgents.focus', async () => {
1872
- await vscode.commands.executeCommand('workbench.view.scm');
3328
+ await vscode.commands.executeCommand('workbench.view.extension.gitguardex.activeAgentsContainer');
1873
3329
  }),
1874
3330
  vscode.commands.registerCommand('gitguardex.activeAgents.commitSelectedSession', commitSelectedSession),
1875
3331
  vscode.commands.registerCommand('gitguardex.activeAgents.openWorktree', async (session) => {
@@ -1898,14 +3354,15 @@ function activate(context) {
1898
3354
  vscode.commands.registerCommand('gitguardex.activeAgents.inspect', (session) => {
1899
3355
  inspectPanelManager.open(session || provider.getSelectedSession());
1900
3356
  }),
3357
+ vscode.commands.registerCommand('gitguardex.activeAgents.showSessionTerminal', showSessionTerminal),
1901
3358
  vscode.commands.registerCommand('gitguardex.activeAgents.finishSession', finishSession),
1902
3359
  vscode.commands.registerCommand('gitguardex.activeAgents.syncSession', syncSession),
1903
3360
  vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => stopSession(session, refresh)),
1904
- vscode.commands.registerCommand('gitguardex.activeAgents.openSessionDiff', openSessionDiff),
1905
- vscode.workspace.onDidChangeWorkspaceFolders(scheduleRefresh),
3361
+ vscode.workspace.onDidChangeWorkspaceFolders(handleWorkspaceFoldersChanged),
1906
3362
  activeSessionsWatcher,
1907
3363
  lockWatcher,
1908
3364
  worktreeLockWatcher,
3365
+ managedWorktreeGitWatcher,
1909
3366
  logWatcher,
1910
3367
  { dispose: () => clearInterval(interval) },
1911
3368
  );
@@ -1914,8 +3371,10 @@ function activate(context) {
1914
3371
  ...bindRefreshWatcher(activeSessionsWatcher, scheduleRefresh),
1915
3372
  ...bindRefreshWatcher(lockWatcher, refreshLockRegistry),
1916
3373
  ...bindRefreshWatcher(worktreeLockWatcher, scheduleRefresh),
3374
+ ...bindRefreshWatcher(managedWorktreeGitWatcher, scheduleRefresh),
1917
3375
  ...bindRefreshWatcher(logWatcher, scheduleRefresh),
1918
3376
  );
3377
+ void ensureManagedRepoScanIgnores();
1919
3378
  void refreshController.refreshNow();
1920
3379
  void maybeAutoUpdateActiveAgentsExtension(context);
1921
3380
  }