@imdeadpool/guardex 7.0.19 → 7.0.20

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.
@@ -1,7 +1,122 @@
1
1
  const fs = require('node:fs');
2
2
  const path = require('node:path');
3
+ const cp = require('node:child_process');
3
4
  const vscode = require('vscode');
4
- const { formatElapsedFrom, readActiveSessions, readRepoChanges } = require('./session-schema.js');
5
+ const {
6
+ formatElapsedFrom,
7
+ readActiveSessions,
8
+ readRepoChanges,
9
+ sanitizeBranchForFile,
10
+ } = require('./session-schema.js');
11
+
12
+ const SESSION_DECORATION_SCHEME = 'gitguardex-agent';
13
+ const IDLE_WARNING_MS = 10 * 60 * 1000;
14
+ const IDLE_ERROR_MS = 30 * 60 * 1000;
15
+ const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json');
16
+ const ACTIVE_SESSION_FILES_GLOB = '**/.omx/state/active-sessions/*.json';
17
+ const AGENT_FILE_LOCKS_GLOB = '**/.omx/state/agent-file-locks.json';
18
+ const SESSION_SCAN_EXCLUDE_GLOB = '**/{node_modules,.git,.omx/agent-worktrees,.omc/agent-worktrees}/**';
19
+ const SESSION_SCAN_LIMIT = 200;
20
+ const REFRESH_DEBOUNCE_MS = 250;
21
+ const SESSION_ACTIVITY_GROUPS = [
22
+ { kind: 'blocked', label: 'BLOCKED' },
23
+ { kind: 'working', label: 'WORKING NOW' },
24
+ { kind: 'idle', label: 'IDLE' },
25
+ { kind: 'stalled', label: 'STALLED' },
26
+ { kind: 'dead', label: 'DEAD' },
27
+ ];
28
+ const SESSION_ACTIVITY_ICON_IDS = {
29
+ blocked: 'warning',
30
+ working: 'edit',
31
+ idle: 'loading~spin',
32
+ stalled: 'clock',
33
+ dead: 'error',
34
+ };
35
+
36
+ function sessionDecorationUri(branch) {
37
+ return vscode.Uri.parse(`${SESSION_DECORATION_SCHEME}://${sanitizeBranchForFile(branch)}`);
38
+ }
39
+
40
+ function sessionIdleDecoration(session, now = Date.now()) {
41
+ if (!session) {
42
+ return undefined;
43
+ }
44
+
45
+ if (session.activityKind === 'blocked') {
46
+ return {
47
+ badge: '!',
48
+ tooltip: 'blocked',
49
+ color: new vscode.ThemeColor('list.warningForeground'),
50
+ };
51
+ }
52
+ if (session.activityKind === 'dead') {
53
+ return {
54
+ badge: 'x',
55
+ tooltip: 'dead',
56
+ color: new vscode.ThemeColor('list.errorForeground'),
57
+ };
58
+ }
59
+ if (session.activityKind === 'stalled') {
60
+ return {
61
+ badge: '!',
62
+ tooltip: 'stalled',
63
+ color: new vscode.ThemeColor('list.errorForeground'),
64
+ };
65
+ }
66
+ if (session.activityKind === 'working') {
67
+ return undefined;
68
+ }
69
+
70
+ const startedAtMs = Date.parse(session.startedAt);
71
+ if (!Number.isFinite(startedAtMs)) {
72
+ return undefined;
73
+ }
74
+
75
+ const elapsedMs = now - startedAtMs;
76
+ if (elapsedMs > IDLE_ERROR_MS) {
77
+ return {
78
+ badge: '30m+',
79
+ tooltip: 'idle 30m+',
80
+ color: new vscode.ThemeColor('list.errorForeground'),
81
+ };
82
+ }
83
+ if (elapsedMs > IDLE_WARNING_MS) {
84
+ return {
85
+ badge: '10m+',
86
+ tooltip: 'idle 10m+',
87
+ color: new vscode.ThemeColor('list.warningForeground'),
88
+ };
89
+ }
90
+
91
+ return undefined;
92
+ }
93
+
94
+ class SessionDecorationProvider {
95
+ constructor(nowProvider = () => Date.now()) {
96
+ this.nowProvider = nowProvider;
97
+ this.sessionsByUri = new Map();
98
+ this.onDidChangeFileDecorationsEmitter = new vscode.EventEmitter();
99
+ this.onDidChangeFileDecorations = this.onDidChangeFileDecorationsEmitter.event;
100
+ }
101
+
102
+ updateSessions(sessions) {
103
+ this.sessionsByUri = new Map(
104
+ sessions.map((session) => [sessionDecorationUri(session.branch).toString(), session]),
105
+ );
106
+ }
107
+
108
+ refresh() {
109
+ this.onDidChangeFileDecorationsEmitter.fire();
110
+ }
111
+
112
+ provideFileDecoration(uri) {
113
+ if (!uri || uri.scheme !== SESSION_DECORATION_SCHEME) {
114
+ return undefined;
115
+ }
116
+
117
+ return sessionIdleDecoration(this.sessionsByUri.get(uri.toString()), this.nowProvider());
118
+ }
119
+ }
5
120
 
6
121
  class InfoItem extends vscode.TreeItem {
7
122
  constructor(label, description = '') {
@@ -17,8 +132,16 @@ class RepoItem extends vscode.TreeItem {
17
132
  this.repoRoot = repoRoot;
18
133
  this.sessions = sessions;
19
134
  this.changes = changes;
20
- const descriptionParts = [`${sessions.length} active`];
135
+ const descriptionParts = [];
136
+ const activeCount = countActiveSessions(sessions);
137
+ const deadCount = countSessionsByActivityKind(sessions, 'dead');
21
138
  const workingCount = countWorkingSessions(sessions);
139
+ if (activeCount > 0) {
140
+ descriptionParts.push(`${activeCount} active`);
141
+ }
142
+ if (deadCount > 0) {
143
+ descriptionParts.push(`${deadCount} dead`);
144
+ }
22
145
  if (workingCount > 0) {
23
146
  descriptionParts.push(`${workingCount} working`);
24
147
  }
@@ -46,9 +169,15 @@ class SectionItem extends vscode.TreeItem {
46
169
  }
47
170
 
48
171
  class SessionItem extends vscode.TreeItem {
49
- constructor(session) {
50
- super(session.label, vscode.TreeItemCollapsibleState.None);
172
+ constructor(session, items = []) {
173
+ const lockCount = Number.isFinite(session.lockCount) ? session.lockCount : 0;
174
+ super(
175
+ `${session.label} 🔒 ${lockCount}`,
176
+ items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None,
177
+ );
51
178
  this.session = session;
179
+ this.items = items;
180
+ this.resourceUri = sessionDecorationUri(session.branch);
52
181
  const descriptionParts = [session.activityLabel || 'thinking'];
53
182
  if (session.activityCountLabel) {
54
183
  descriptionParts.push(session.activityCountLabel);
@@ -62,13 +191,14 @@ class SessionItem extends vscode.TreeItem {
62
191
  session.changeCount > 0
63
192
  ? `Changed ${session.activityCountLabel}: ${session.activitySummary}`
64
193
  : session.activitySummary,
194
+ `Locks ${lockCount}`,
195
+ session.pidAlive === false ? `PID ${session.pid} not alive` : `PID ${session.pid} alive`,
196
+ session.lastFileActivityAt ? `Last file activity ${session.lastFileActivityAt}` : '',
65
197
  `Started ${session.startedAt}`,
66
198
  session.worktreePath,
67
199
  ];
68
200
  this.tooltip = tooltipLines.filter(Boolean).join('\n');
69
- this.iconPath = session.activityKind === 'working'
70
- ? new vscode.ThemeIcon('edit')
71
- : new vscode.ThemeIcon('loading~spin');
201
+ this.iconPath = new vscode.ThemeIcon(resolveSessionActivityIconId(session.activityKind));
72
202
  this.contextValue = 'gitguardex.session';
73
203
  this.command = {
74
204
  command: 'gitguardex.activeAgents.openWorktree',
@@ -98,9 +228,13 @@ class ChangeItem extends vscode.TreeItem {
98
228
  change.relativePath,
99
229
  `Status ${change.statusText}`,
100
230
  change.originalPath ? `Renamed from ${change.originalPath}` : '',
231
+ change.hasForeignLock ? `Locked by ${change.lockOwnerBranch}` : '',
101
232
  change.absolutePath,
102
233
  ].filter(Boolean).join('\n');
103
234
  this.resourceUri = vscode.Uri.file(change.absolutePath);
235
+ if (change.hasForeignLock) {
236
+ this.iconPath = new vscode.ThemeIcon('warning');
237
+ }
104
238
  this.contextValue = 'gitguardex.change';
105
239
  this.command = {
106
240
  command: 'gitguardex.activeAgents.openChange',
@@ -110,10 +244,313 @@ class ChangeItem extends vscode.TreeItem {
110
244
  }
111
245
  }
112
246
 
247
+ function shellQuote(value) {
248
+ const normalized = String(value || '');
249
+ return `'${normalized.replace(/'/g, "'\"'\"'")}'`;
250
+ }
251
+
252
+ function sessionDisplayLabel(session) {
253
+ return session?.taskName || session?.label || session?.branch || path.basename(session?.worktreePath || '') || 'session';
254
+ }
255
+
256
+ function sessionWorktreePath(session) {
257
+ return typeof session?.worktreePath === 'string' ? session.worktreePath.trim() : '';
258
+ }
259
+
260
+ function showSessionMessage(message) {
261
+ vscode.window.showInformationMessage?.(message);
262
+ }
263
+
264
+ function ensureSessionWorktree(session, actionLabel) {
265
+ const worktreePath = sessionWorktreePath(session);
266
+ if (!worktreePath) {
267
+ showSessionMessage(`Cannot ${actionLabel}: missing worktree path.`);
268
+ return '';
269
+ }
270
+ if (!fs.existsSync(worktreePath)) {
271
+ showSessionMessage(`Cannot ${actionLabel}: worktree is no longer on disk: ${worktreePath}`);
272
+ return '';
273
+ }
274
+ return worktreePath;
275
+ }
276
+
277
+ function runSessionTerminalCommand(session, actionLabel, iconId, commandText) {
278
+ const worktreePath = ensureSessionWorktree(session, actionLabel.toLowerCase());
279
+ if (!worktreePath) {
280
+ return;
281
+ }
282
+
283
+ const terminal = vscode.window.createTerminal({
284
+ name: `GitGuardex ${actionLabel}: ${sessionDisplayLabel(session)}`,
285
+ cwd: worktreePath,
286
+ iconPath: new vscode.ThemeIcon(iconId),
287
+ });
288
+ terminal.show();
289
+ terminal.sendText(commandText, true);
290
+ }
291
+
292
+ function finishSession(session) {
293
+ if (!session?.branch) {
294
+ showSessionMessage('Cannot finish session: missing branch name.');
295
+ return;
296
+ }
297
+ runSessionTerminalCommand(
298
+ session,
299
+ 'Finish',
300
+ 'check',
301
+ `gx branch finish --branch ${shellQuote(session.branch)}`,
302
+ );
303
+ }
304
+
305
+ function syncSession(session) {
306
+ runSessionTerminalCommand(session, 'Sync', 'sync', 'gx sync');
307
+ }
308
+
309
+ async function stopSession(session, refresh) {
310
+ const pid = Number(session?.pid);
311
+ if (!Number.isInteger(pid) || pid <= 0) {
312
+ showSessionMessage('Cannot stop session: missing pid.');
313
+ return;
314
+ }
315
+
316
+ const confirmed = await vscode.window.showWarningMessage(
317
+ `Stop ${sessionDisplayLabel(session)}?`,
318
+ { modal: true, detail: `Send SIGTERM to pid ${pid}.` },
319
+ 'Stop',
320
+ );
321
+ if (confirmed !== 'Stop') {
322
+ return;
323
+ }
324
+
325
+ try {
326
+ process.kill(pid, 'SIGTERM');
327
+ refresh();
328
+ } catch (error) {
329
+ showSessionMessage(
330
+ `Failed to stop session ${sessionDisplayLabel(session)}: ${error?.message || String(error)}`,
331
+ );
332
+ }
333
+ }
334
+
335
+ async function openSessionDiff(session) {
336
+ const worktreePath = ensureSessionWorktree(session, 'open diff');
337
+ if (!worktreePath) {
338
+ return;
339
+ }
340
+
341
+ let diffOutput = '';
342
+ try {
343
+ diffOutput = cp.execFileSync('git', ['-C', worktreePath, 'diff'], {
344
+ encoding: 'utf8',
345
+ stdio: ['ignore', 'pipe', 'pipe'],
346
+ });
347
+ } catch (error) {
348
+ const detail = [
349
+ error?.stdout,
350
+ error?.stderr,
351
+ error?.message,
352
+ ].find((value) => typeof value === 'string' && value.trim().length > 0) || 'git diff failed.';
353
+ showSessionMessage(`Failed to open diff for ${sessionDisplayLabel(session)}: ${detail.trim()}`);
354
+ return;
355
+ }
356
+
357
+ const document = await vscode.workspace.openTextDocument({
358
+ language: 'diff',
359
+ content: diffOutput,
360
+ });
361
+ await vscode.window.showTextDocument(document, { preview: false });
362
+ }
363
+
113
364
  function repoRootFromSessionFile(filePath) {
114
365
  return path.resolve(path.dirname(filePath), '..', '..', '..');
115
366
  }
116
367
 
368
+ function repoRootFromLockFile(filePath) {
369
+ return path.resolve(path.dirname(filePath), '..', '..');
370
+ }
371
+
372
+ function normalizeRelativePath(relativePath) {
373
+ return String(relativePath || '').replace(/\\/g, '/').replace(/^\.\//, '');
374
+ }
375
+
376
+ function emptyLockRegistry() {
377
+ return {
378
+ entriesByPath: new Map(),
379
+ countsByBranch: new Map(),
380
+ };
381
+ }
382
+
383
+ function readLockRegistry(repoRoot) {
384
+ const lockPath = path.join(repoRoot, LOCK_FILE_RELATIVE);
385
+ if (!fs.existsSync(lockPath)) {
386
+ return emptyLockRegistry();
387
+ }
388
+
389
+ let parsed;
390
+ try {
391
+ parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
392
+ } catch (_error) {
393
+ return emptyLockRegistry();
394
+ }
395
+
396
+ const locks = parsed?.locks;
397
+ if (!locks || typeof locks !== 'object' || Array.isArray(locks)) {
398
+ return emptyLockRegistry();
399
+ }
400
+
401
+ const entriesByPath = new Map();
402
+ const countsByBranch = new Map();
403
+ for (const [rawRelativePath, entry] of Object.entries(locks)) {
404
+ if (!entry || typeof entry !== 'object') {
405
+ continue;
406
+ }
407
+
408
+ const relativePath = normalizeRelativePath(rawRelativePath);
409
+ const branch = typeof entry.branch === 'string' ? entry.branch.trim() : '';
410
+ if (!relativePath || !branch) {
411
+ continue;
412
+ }
413
+
414
+ entriesByPath.set(relativePath, {
415
+ branch,
416
+ claimedAt: typeof entry.claimed_at === 'string' ? entry.claimed_at : '',
417
+ allowDelete: Boolean(entry.allow_delete),
418
+ });
419
+ countsByBranch.set(branch, (countsByBranch.get(branch) || 0) + 1);
420
+ }
421
+
422
+ return {
423
+ entriesByPath,
424
+ countsByBranch,
425
+ };
426
+ }
427
+
428
+ function readCurrentBranch(repoRoot) {
429
+ try {
430
+ return cp.execFileSync('git', ['-C', repoRoot, 'rev-parse', '--abbrev-ref', 'HEAD'], {
431
+ encoding: 'utf8',
432
+ stdio: ['ignore', 'pipe', 'ignore'],
433
+ }).trim();
434
+ } catch (_error) {
435
+ return '';
436
+ }
437
+ }
438
+
439
+ function decorateSession(session, lockRegistry) {
440
+ return {
441
+ ...session,
442
+ lockCount: lockRegistry.countsByBranch.get(session.branch) || 0,
443
+ };
444
+ }
445
+
446
+ function decorateChange(change, lockRegistry, owningBranch) {
447
+ const lockEntry = lockRegistry.entriesByPath.get(normalizeRelativePath(change.relativePath));
448
+ const lockOwnerBranch = lockEntry?.branch || '';
449
+ return {
450
+ ...change,
451
+ lockOwnerBranch,
452
+ hasForeignLock: Boolean(lockOwnerBranch) && (!owningBranch || lockOwnerBranch !== owningBranch),
453
+ };
454
+ }
455
+
456
+ function isPathWithin(parentPath, targetPath) {
457
+ const relativePath = path.relative(parentPath, targetPath);
458
+ return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
459
+ }
460
+
461
+ function localizeChangeForSession(session, change) {
462
+ if (!change?.absolutePath || !isPathWithin(session.worktreePath, change.absolutePath)) {
463
+ return null;
464
+ }
465
+
466
+ let originalPath = change.originalPath;
467
+ if (originalPath) {
468
+ const originalAbsolutePath = path.join(session.repoRoot, originalPath);
469
+ if (isPathWithin(session.worktreePath, originalAbsolutePath)) {
470
+ originalPath = normalizeRelativePath(path.relative(session.worktreePath, originalAbsolutePath));
471
+ }
472
+ }
473
+
474
+ return {
475
+ ...change,
476
+ relativePath: normalizeRelativePath(path.relative(session.worktreePath, change.absolutePath)),
477
+ originalPath,
478
+ };
479
+ }
480
+
481
+ async function findRepoSessionEntries() {
482
+ const sessionFiles = await vscode.workspace.findFiles(
483
+ ACTIVE_SESSION_FILES_GLOB,
484
+ SESSION_SCAN_EXCLUDE_GLOB,
485
+ SESSION_SCAN_LIMIT,
486
+ );
487
+
488
+ const repoRoots = new Set();
489
+ for (const uri of sessionFiles) {
490
+ repoRoots.add(repoRootFromSessionFile(uri.fsPath));
491
+ }
492
+
493
+ if (repoRoots.size === 0) {
494
+ for (const workspaceFolder of vscode.workspace.workspaceFolders || []) {
495
+ repoRoots.add(workspaceFolder.uri.fsPath);
496
+ }
497
+ }
498
+
499
+ const repoEntries = [];
500
+ for (const repoRoot of repoRoots) {
501
+ const sessions = readActiveSessions(repoRoot, { includeStale: true });
502
+ if (sessions.length > 0) {
503
+ repoEntries.push({ repoRoot, sessions });
504
+ }
505
+ }
506
+
507
+ repoEntries.sort((left, right) => left.repoRoot.localeCompare(right.repoRoot));
508
+ return repoEntries;
509
+ }
510
+
511
+ function resolveSessionWatcherKey(session) {
512
+ return `${path.resolve(session.repoRoot)}::${session.branch}::${path.resolve(session.worktreePath)}`;
513
+ }
514
+
515
+ function resolveSessionGitIndexPath(worktreePath) {
516
+ const gitPath = path.join(worktreePath, '.git');
517
+ const defaultIndexPath = path.join(gitPath, 'index');
518
+
519
+ try {
520
+ if (fs.statSync(gitPath).isDirectory()) {
521
+ return defaultIndexPath;
522
+ }
523
+ } catch (_error) {
524
+ return defaultIndexPath;
525
+ }
526
+
527
+ try {
528
+ const gitPointer = fs.readFileSync(gitPath, 'utf8');
529
+ const match = gitPointer.match(/^gitdir:\s*(.+)$/m);
530
+ if (match?.[1]) {
531
+ return path.resolve(worktreePath, match[1].trim(), 'index');
532
+ }
533
+ } catch (_error) {
534
+ return defaultIndexPath;
535
+ }
536
+
537
+ return defaultIndexPath;
538
+ }
539
+
540
+ function bindRefreshWatcher(watcher, refresh) {
541
+ return [
542
+ watcher.onDidCreate(refresh),
543
+ watcher.onDidChange(refresh),
544
+ watcher.onDidDelete(refresh),
545
+ ];
546
+ }
547
+
548
+ function disposeAll(disposables) {
549
+ for (const disposable of disposables) {
550
+ disposable?.dispose?.();
551
+ }
552
+ }
553
+
117
554
  function buildChangeTreeNodes(changes) {
118
555
  const root = [];
119
556
 
@@ -179,30 +616,198 @@ function countWorkingSessions(sessions) {
179
616
  return sessions.filter((session) => session.activityKind === 'working').length;
180
617
  }
181
618
 
182
- function buildActiveAgentGroupNodes(sessions) {
183
- const workingSessions = sessions
184
- .filter((session) => session.activityKind === 'working')
185
- .map((session) => new SessionItem(session));
186
- const thinkingSessions = sessions
187
- .filter((session) => session.activityKind !== 'working')
188
- .map((session) => new SessionItem(session));
189
- const groups = [];
619
+ function buildGroupedChangeTreeNodes(sessions, changes) {
620
+ const changesBySession = new Map();
621
+ const sessionByChangedPath = new Map();
622
+ const repoRootChanges = [];
623
+
624
+ for (const session of sessions) {
625
+ changesBySession.set(session.branch, []);
626
+ for (const changedPath of session.changedPaths || []) {
627
+ if (!sessionByChangedPath.has(changedPath)) {
628
+ sessionByChangedPath.set(changedPath, session);
629
+ }
630
+ }
631
+ }
632
+
633
+ for (const change of changes) {
634
+ const normalizedRelativePath = normalizeRelativePath(change.relativePath);
635
+ const session = sessionByChangedPath.get(normalizedRelativePath)
636
+ || sessions.find((candidate) => isPathWithin(candidate.worktreePath, change.absolutePath));
637
+ if (!session) {
638
+ repoRootChanges.push(change);
639
+ continue;
640
+ }
641
+
642
+ const localizedChange = localizeChangeForSession(session, change);
643
+ if (!localizedChange) {
644
+ repoRootChanges.push(change);
645
+ continue;
646
+ }
647
+
648
+ changesBySession.get(session.branch).push(localizedChange);
649
+ }
650
+
651
+ const items = sessions
652
+ .map((session) => {
653
+ const sessionChanges = changesBySession.get(session.branch) || [];
654
+ if (sessionChanges.length === 0) {
655
+ return null;
656
+ }
657
+ return new SessionItem(session, buildChangeTreeNodes(sessionChanges));
658
+ })
659
+ .filter(Boolean);
660
+
661
+ if (repoRootChanges.length > 0) {
662
+ items.push(new SectionItem('Repo root', buildChangeTreeNodes(repoRootChanges), {
663
+ description: String(repoRootChanges.length),
664
+ }));
665
+ }
666
+
667
+ return items;
668
+ }
669
+
670
+ function countActiveSessions(sessions) {
671
+ return sessions.filter((session) => session.activityKind !== 'dead').length;
672
+ }
673
+
674
+ function countSessionsByActivityKind(sessions, activityKind) {
675
+ return sessions.filter((session) => session.activityKind === activityKind).length;
676
+ }
190
677
 
191
- if (workingSessions.length > 0) {
192
- groups.push(new SectionItem('WORKING NOW', workingSessions));
678
+ function resolveSessionActivityIconId(activityKind) {
679
+ return SESSION_ACTIVITY_ICON_IDS[activityKind] || 'loading~spin';
680
+ }
681
+
682
+ async function pickRepoRoot() {
683
+ const workspaceFolders = vscode.workspace.workspaceFolders || [];
684
+ if (workspaceFolders.length === 0) {
685
+ vscode.window.showInformationMessage?.('Open a Guardex workspace folder to start an agent.');
686
+ return null;
193
687
  }
194
- if (thinkingSessions.length > 0) {
195
- groups.push(new SectionItem('THINKING', thinkingSessions));
688
+
689
+ if (workspaceFolders.length === 1) {
690
+ return workspaceFolders[0].uri.fsPath;
691
+ }
692
+
693
+ const picks = workspaceFolders.map((folder) => ({
694
+ label: path.basename(folder.uri.fsPath),
695
+ description: folder.uri.fsPath,
696
+ repoRoot: folder.uri.fsPath,
697
+ }));
698
+ const selection = await vscode.window.showQuickPick?.(picks, {
699
+ placeHolder: 'Select the Guardex repo where gx branch start should run.',
700
+ });
701
+ return selection?.repoRoot || null;
702
+ }
703
+
704
+ async function promptStartAgentDetails() {
705
+ const taskName = await vscode.window.showInputBox?.({
706
+ prompt: 'Task for gx branch start',
707
+ placeHolder: 'vscode active agents welcome view',
708
+ ignoreFocusOut: true,
709
+ validateInput: (value) => value.trim() ? undefined : 'Task is required.',
710
+ });
711
+ if (!taskName) {
712
+ return null;
713
+ }
714
+
715
+ const agentName = await vscode.window.showInputBox?.({
716
+ prompt: 'Agent name for gx branch start',
717
+ placeHolder: 'codex',
718
+ value: 'codex',
719
+ ignoreFocusOut: true,
720
+ validateInput: (value) => value.trim() ? undefined : 'Agent name is required.',
721
+ });
722
+ if (!agentName) {
723
+ return null;
724
+ }
725
+
726
+ return {
727
+ taskName: taskName.trim(),
728
+ agentName: agentName.trim(),
729
+ };
730
+ }
731
+
732
+ async function startAgentFromPrompt(refresh) {
733
+ const repoRoot = await pickRepoRoot();
734
+ if (!repoRoot) {
735
+ return;
736
+ }
737
+
738
+ const details = await promptStartAgentDetails();
739
+ if (!details) {
740
+ return;
741
+ }
742
+
743
+ const terminal = vscode.window.createTerminal?.({
744
+ name: `GitGuardex: ${path.basename(repoRoot)}`,
745
+ cwd: repoRoot,
746
+ });
747
+ terminal?.show(true);
748
+ terminal?.sendText(
749
+ `gx branch start ${shellQuote(details.taskName)} ${shellQuote(details.agentName)}`,
750
+ true,
751
+ );
752
+ refresh();
753
+ }
754
+
755
+ function sessionSelectionKey(session) {
756
+ if (!session?.repoRoot || !session?.branch) {
757
+ return '';
758
+ }
759
+
760
+ return `${session.repoRoot}::${session.branch}`;
761
+ }
762
+
763
+ function formatGitCommandFailure(error) {
764
+ for (const value of [error?.stderr, error?.stdout, error?.message]) {
765
+ if (typeof value === 'string' && value.trim().length > 0) {
766
+ return value.trim();
767
+ }
768
+ }
769
+ return 'Git command failed.';
770
+ }
771
+
772
+ function runGitCommand(worktreePath, args) {
773
+ return cp.execFileSync('git', ['-C', worktreePath, ...args], {
774
+ encoding: 'utf8',
775
+ stdio: ['ignore', 'pipe', 'pipe'],
776
+ });
777
+ }
778
+
779
+ function stageWorktreeForCommit(worktreePath) {
780
+ runGitCommand(worktreePath, ['add', '-A', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`]);
781
+ }
782
+
783
+ function commitWorktree(worktreePath, message) {
784
+ runGitCommand(worktreePath, ['commit', '-m', message]);
785
+ }
786
+
787
+ function buildActiveAgentGroupNodes(sessions) {
788
+ const groups = [];
789
+ for (const group of SESSION_ACTIVITY_GROUPS) {
790
+ const groupSessions = sessions
791
+ .filter((session) => session.activityKind === group.kind)
792
+ .map((session) => new SessionItem(session));
793
+ if (groupSessions.length > 0) {
794
+ groups.push(new SectionItem(group.label, groupSessions));
795
+ }
196
796
  }
197
797
 
198
798
  return groups;
199
799
  }
200
800
 
201
801
  class ActiveAgentsProvider {
202
- constructor() {
802
+ constructor(decorationProvider) {
803
+ this.decorationProvider = decorationProvider;
203
804
  this.onDidChangeTreeDataEmitter = new vscode.EventEmitter();
204
805
  this.onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event;
806
+ this.onDidChangeSelectedSessionEmitter = new vscode.EventEmitter();
807
+ this.onDidChangeSelectedSession = this.onDidChangeSelectedSessionEmitter.event;
205
808
  this.treeView = null;
809
+ this.lockRegistryByRepoRoot = new Map();
810
+ this.selectedSession = null;
206
811
  }
207
812
 
208
813
  getTreeItem(element) {
@@ -211,19 +816,59 @@ class ActiveAgentsProvider {
211
816
 
212
817
  attachTreeView(treeView) {
213
818
  this.treeView = treeView;
214
- this.updateViewState(0, 0);
819
+ this.updateViewState(0, 0, 0);
820
+ treeView.onDidChangeSelection?.((event) => {
821
+ const sessionItem = event.selection.find((item) => item instanceof SessionItem);
822
+ this.setSelectedSession(sessionItem?.session || null);
823
+ });
215
824
  }
216
825
 
217
- updateViewState(sessionCount, workingCount) {
826
+ setSelectedSession(session) {
827
+ const nextSession = session?.worktreePath ? { ...session } : null;
828
+ const currentKey = sessionSelectionKey(this.selectedSession);
829
+ const nextKey = sessionSelectionKey(nextSession);
830
+ this.selectedSession = nextSession;
831
+ if (currentKey !== nextKey) {
832
+ this.onDidChangeSelectedSessionEmitter.fire(this.selectedSession);
833
+ }
834
+ }
835
+
836
+ getSelectedSession() {
837
+ return this.selectedSession ? { ...this.selectedSession } : null;
838
+ }
839
+
840
+ syncSelectedSession(repoEntries) {
841
+ if (!this.selectedSession) {
842
+ return;
843
+ }
844
+
845
+ const nextSession = repoEntries
846
+ .flatMap((entry) => entry.sessions)
847
+ .find((session) => sessionSelectionKey(session) === sessionSelectionKey(this.selectedSession));
848
+ this.setSelectedSession(nextSession || null);
849
+ }
850
+
851
+ updateViewState(sessionCount, workingCount, deadCount) {
218
852
  if (!this.treeView) {
219
853
  return;
220
854
  }
221
855
 
856
+ const activeCount = Math.max(0, sessionCount - deadCount);
857
+ const badgeTooltipParts = [];
858
+ if (activeCount > 0) {
859
+ badgeTooltipParts.push(`${activeCount} active agent${activeCount === 1 ? '' : 's'}`);
860
+ }
861
+ if (deadCount > 0) {
862
+ badgeTooltipParts.push(`${deadCount} dead`);
863
+ }
864
+ if (workingCount > 0) {
865
+ badgeTooltipParts.push(`${workingCount} working now`);
866
+ }
867
+
222
868
  this.treeView.badge = sessionCount > 0
223
869
  ? {
224
870
  value: sessionCount,
225
- tooltip: `${sessionCount} active agent${sessionCount === 1 ? '' : 's'}`
226
- + (workingCount > 0 ? ` · ${workingCount} working now` : ''),
871
+ tooltip: badgeTooltipParts.join(' · '),
227
872
  }
228
873
  : undefined;
229
874
  this.treeView.message = sessionCount > 0
@@ -231,8 +876,55 @@ class ActiveAgentsProvider {
231
876
  : 'Start a sandbox session to populate this view.';
232
877
  }
233
878
 
234
- refresh() {
879
+ async syncRepoEntries() {
880
+ const repoEntries = await this.loadRepoEntries();
881
+ const sessionCount = repoEntries.reduce((total, entry) => total + entry.sessions.length, 0);
882
+ const workingCount = repoEntries.reduce(
883
+ (total, entry) => total + countWorkingSessions(entry.sessions),
884
+ 0,
885
+ );
886
+ const deadCount = repoEntries.reduce(
887
+ (total, entry) => total + countSessionsByActivityKind(entry.sessions, 'dead'),
888
+ 0,
889
+ );
890
+
891
+ this.updateViewState(sessionCount, workingCount, deadCount);
892
+ this.decorationProvider?.updateSessions(repoEntries.flatMap((entry) => entry.sessions));
893
+ return repoEntries;
894
+ }
895
+
896
+ async refresh() {
897
+ await this.syncRepoEntries();
235
898
  this.onDidChangeTreeDataEmitter.fire();
899
+ this.decorationProvider?.refresh();
900
+ }
901
+
902
+ readLockRegistryForRepo(repoRoot) {
903
+ const lockRegistry = readLockRegistry(repoRoot);
904
+ this.lockRegistryByRepoRoot.set(repoRoot, lockRegistry);
905
+ return lockRegistry;
906
+ }
907
+
908
+ getLockRegistryForRepo(repoRoot) {
909
+ return this.lockRegistryByRepoRoot.get(repoRoot) || this.readLockRegistryForRepo(repoRoot);
910
+ }
911
+
912
+ refreshLockRegistryForFile(filePath) {
913
+ this.readLockRegistryForRepo(repoRootFromLockFile(filePath));
914
+ }
915
+
916
+ readLockRegistryForRepo(repoRoot) {
917
+ const lockRegistry = readLockRegistry(repoRoot);
918
+ this.lockRegistryByRepoRoot.set(repoRoot, lockRegistry);
919
+ return lockRegistry;
920
+ }
921
+
922
+ getLockRegistryForRepo(repoRoot) {
923
+ return this.lockRegistryByRepoRoot.get(repoRoot) || this.readLockRegistryForRepo(repoRoot);
924
+ }
925
+
926
+ refreshLockRegistryForFile(filePath) {
927
+ this.readLockRegistryForRepo(repoRootFromLockFile(filePath));
236
928
  }
237
929
 
238
930
  async getChildren(element) {
@@ -243,22 +935,19 @@ class ActiveAgentsProvider {
243
935
  }),
244
936
  ];
245
937
  if (element.changes.length > 0) {
246
- sectionItems.push(new SectionItem('CHANGES', buildChangeTreeNodes(element.changes)));
938
+ sectionItems.push(new SectionItem('CHANGES', buildGroupedChangeTreeNodes(element.sessions, element.changes), {
939
+ description: String(element.changes.length),
940
+ }));
247
941
  }
248
942
  return sectionItems;
249
943
  }
250
944
 
251
- if (element instanceof SectionItem || element instanceof FolderItem) {
945
+ if (element instanceof SectionItem || element instanceof FolderItem || element instanceof SessionItem) {
252
946
  return element.items;
253
947
  }
254
948
 
255
- const repoEntries = await this.loadRepoEntries();
256
- const sessionCount = repoEntries.reduce((total, entry) => total + entry.sessions.length, 0);
257
- const workingCount = repoEntries.reduce(
258
- (total, entry) => total + countWorkingSessions(entry.sessions),
259
- 0,
260
- );
261
- this.updateViewState(sessionCount, workingCount);
949
+ const repoEntries = await this.syncRepoEntries();
950
+ this.syncSelectedSession(repoEntries);
262
951
 
263
952
  if (repoEntries.length === 0) {
264
953
  return [new InfoItem('No active Guardex agents', 'Open or start a sandbox session.')];
@@ -268,54 +957,170 @@ class ActiveAgentsProvider {
268
957
  }
269
958
 
270
959
  async loadRepoEntries() {
271
- const sessionFiles = await vscode.workspace.findFiles(
272
- '**/.omx/state/active-sessions/*.json',
273
- '**/{node_modules,.git,.omx/agent-worktrees,.omc/agent-worktrees}/**',
274
- 200,
275
- );
960
+ const repoEntries = await findRepoSessionEntries();
961
+ return repoEntries.map((entry) => {
962
+ const repoRoot = entry.repoRoot;
963
+ const lockRegistry = this.getLockRegistryForRepo(repoRoot);
964
+ const currentBranch = readCurrentBranch(repoRoot);
965
+ return {
966
+ repoRoot,
967
+ sessions: entry.sessions.map((session) => decorateSession(session, lockRegistry)),
968
+ changes: readRepoChanges(repoRoot).map((change) => (
969
+ decorateChange(change, lockRegistry, currentBranch)
970
+ )),
971
+ };
972
+ });
973
+ }
974
+ }
276
975
 
277
- const repoRoots = new Set();
278
- for (const uri of sessionFiles) {
279
- repoRoots.add(repoRootFromSessionFile(uri.fsPath));
976
+ class ActiveAgentsRefreshController {
977
+ constructor(provider) {
978
+ this.provider = provider;
979
+ this.refreshTimer = null;
980
+ this.sessionWatchers = new Map();
981
+ }
982
+
983
+ scheduleRefresh() {
984
+ if (this.refreshTimer) {
985
+ clearTimeout(this.refreshTimer);
280
986
  }
987
+ this.refreshTimer = setTimeout(() => {
988
+ this.refreshTimer = null;
989
+ void this.refreshNow();
990
+ }, REFRESH_DEBOUNCE_MS);
991
+ }
992
+
993
+ async refreshNow() {
994
+ await this.syncSessionWatchers();
995
+ await this.provider.refresh();
996
+ }
281
997
 
282
- if (repoRoots.size === 0) {
283
- for (const workspaceFolder of vscode.workspace.workspaceFolders || []) {
284
- repoRoots.add(workspaceFolder.uri.fsPath);
998
+ async syncSessionWatchers() {
999
+ const repoEntries = await findRepoSessionEntries();
1000
+ const liveSessionKeys = new Set();
1001
+
1002
+ for (const entry of repoEntries) {
1003
+ for (const session of entry.sessions) {
1004
+ const sessionKey = resolveSessionWatcherKey(session);
1005
+ liveSessionKeys.add(sessionKey);
1006
+ if (this.sessionWatchers.has(sessionKey)) {
1007
+ continue;
1008
+ }
1009
+
1010
+ const watcher = vscode.workspace.createFileSystemWatcher(
1011
+ resolveSessionGitIndexPath(session.worktreePath),
1012
+ );
1013
+ const disposables = bindRefreshWatcher(watcher, () => this.scheduleRefresh());
1014
+ this.sessionWatchers.set(sessionKey, { watcher, disposables });
285
1015
  }
286
1016
  }
287
1017
 
288
- const repoEntries = [];
289
- for (const repoRoot of repoRoots) {
290
- const sessions = readActiveSessions(repoRoot);
291
- if (sessions.length > 0) {
292
- repoEntries.push({
293
- repoRoot,
294
- sessions,
295
- changes: readRepoChanges(repoRoot),
296
- });
1018
+ for (const [sessionKey, entry] of this.sessionWatchers) {
1019
+ if (liveSessionKeys.has(sessionKey)) {
1020
+ continue;
297
1021
  }
1022
+
1023
+ disposeAll(entry.disposables);
1024
+ entry.watcher.dispose();
1025
+ this.sessionWatchers.delete(sessionKey);
298
1026
  }
1027
+ }
299
1028
 
300
- repoEntries.sort((left, right) => left.repoRoot.localeCompare(right.repoRoot));
301
- return repoEntries;
1029
+ dispose() {
1030
+ if (this.refreshTimer) {
1031
+ clearTimeout(this.refreshTimer);
1032
+ this.refreshTimer = null;
1033
+ }
1034
+
1035
+ for (const entry of this.sessionWatchers.values()) {
1036
+ disposeAll(entry.disposables);
1037
+ entry.watcher.dispose();
1038
+ }
1039
+ this.sessionWatchers.clear();
302
1040
  }
303
1041
  }
304
1042
 
305
1043
  function activate(context) {
306
- const provider = new ActiveAgentsProvider();
1044
+ const decorationProvider = new SessionDecorationProvider();
1045
+ const provider = new ActiveAgentsProvider(decorationProvider);
1046
+ const refreshController = new ActiveAgentsRefreshController(provider);
307
1047
  const treeView = vscode.window.createTreeView('gitguardex.activeAgents', {
308
1048
  treeDataProvider: provider,
309
1049
  showCollapseAll: true,
310
1050
  });
1051
+ const sourceControl = vscode.scm.createSourceControl(
1052
+ 'gitguardex.activeAgents.commitInput',
1053
+ 'Active Agents Commit',
1054
+ );
311
1055
  provider.attachTreeView(treeView);
312
- const refresh = () => provider.refresh();
313
- const watcher = vscode.workspace.createFileSystemWatcher('**/.omx/state/active-sessions/*.json');
1056
+ const scheduleRefresh = () => refreshController.scheduleRefresh();
1057
+ const refresh = () => void refreshController.refreshNow();
1058
+ const activeSessionsWatcher = vscode.workspace.createFileSystemWatcher(ACTIVE_SESSION_FILES_GLOB);
1059
+ const lockWatcher = vscode.workspace.createFileSystemWatcher(AGENT_FILE_LOCKS_GLOB);
1060
+ const updateCommitInput = (session) => {
1061
+ sourceControl.inputBox.enabled = true;
1062
+ sourceControl.inputBox.visible = true;
1063
+ sourceControl.inputBox.placeholder = session?.label
1064
+ ? `Commit ${session.label} (Ctrl+Enter)`
1065
+ : 'Pick an Active Agents session to commit its worktree.';
1066
+ };
1067
+ updateCommitInput(null);
1068
+ const commitSelectedSession = async () => {
1069
+ const selectedSession = provider.getSelectedSession();
1070
+ if (!selectedSession?.worktreePath) {
1071
+ vscode.window.showInformationMessage?.('Pick an Active Agents session first.');
1072
+ return;
1073
+ }
1074
+
1075
+ const message = String(sourceControl.inputBox.value || '').trim();
1076
+ if (!message) {
1077
+ vscode.window.showInformationMessage?.('Enter a commit message first.');
1078
+ return;
1079
+ }
1080
+
1081
+ if (!fs.existsSync(selectedSession.worktreePath)) {
1082
+ vscode.window.showInformationMessage?.(
1083
+ `Selected session worktree is no longer on disk: ${selectedSession.worktreePath}`,
1084
+ );
1085
+ return;
1086
+ }
1087
+
1088
+ try {
1089
+ stageWorktreeForCommit(selectedSession.worktreePath);
1090
+ commitWorktree(selectedSession.worktreePath, message);
1091
+ sourceControl.inputBox.value = '';
1092
+ refresh();
1093
+ } catch (error) {
1094
+ const failure = formatGitCommandFailure(error);
1095
+ if (/nothing to commit|no changes added to commit/i.test(failure)) {
1096
+ vscode.window.showInformationMessage?.(`No changes to commit in ${selectedSession.label}.`);
1097
+ return;
1098
+ }
1099
+ vscode.window.showErrorMessage?.(`Active Agents commit failed: ${failure}`);
1100
+ }
1101
+ };
1102
+ sourceControl.acceptInputCommand = {
1103
+ command: 'gitguardex.activeAgents.commitSelectedSession',
1104
+ title: 'Commit Selected Session',
1105
+ };
314
1106
  const interval = setInterval(refresh, 5_000);
1107
+ const refreshLockRegistry = (uri) => {
1108
+ if (uri?.fsPath) {
1109
+ provider.refreshLockRegistryForFile(uri.fsPath);
1110
+ }
1111
+ scheduleRefresh();
1112
+ };
1113
+
1114
+ provider.onDidChangeSelectedSession(updateCommitInput);
315
1115
 
316
1116
  context.subscriptions.push(
317
1117
  treeView,
1118
+ sourceControl,
1119
+ refreshController,
1120
+ vscode.window.registerFileDecorationProvider(decorationProvider),
1121
+ vscode.commands.registerCommand('gitguardex.activeAgents.startAgent', () => startAgentFromPrompt(refresh)),
318
1122
  vscode.commands.registerCommand('gitguardex.activeAgents.refresh', refresh),
1123
+ vscode.commands.registerCommand('gitguardex.activeAgents.commitSelectedSession', commitSelectedSession),
319
1124
  vscode.commands.registerCommand('gitguardex.activeAgents.openWorktree', async (session) => {
320
1125
  if (!session?.worktreePath) {
321
1126
  return;
@@ -339,14 +1144,21 @@ function activate(context) {
339
1144
 
340
1145
  await vscode.commands.executeCommand('vscode.open', vscode.Uri.file(change.absolutePath));
341
1146
  }),
342
- vscode.workspace.onDidChangeWorkspaceFolders(refresh),
343
- watcher,
1147
+ vscode.commands.registerCommand('gitguardex.activeAgents.finishSession', finishSession),
1148
+ vscode.commands.registerCommand('gitguardex.activeAgents.syncSession', syncSession),
1149
+ vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => stopSession(session, refresh)),
1150
+ vscode.commands.registerCommand('gitguardex.activeAgents.openSessionDiff', openSessionDiff),
1151
+ vscode.workspace.onDidChangeWorkspaceFolders(scheduleRefresh),
1152
+ activeSessionsWatcher,
1153
+ lockWatcher,
344
1154
  { dispose: () => clearInterval(interval) },
345
1155
  );
346
1156
 
347
- watcher.onDidCreate(refresh, undefined, context.subscriptions);
348
- watcher.onDidChange(refresh, undefined, context.subscriptions);
349
- watcher.onDidDelete(refresh, undefined, context.subscriptions);
1157
+ context.subscriptions.push(
1158
+ ...bindRefreshWatcher(activeSessionsWatcher, scheduleRefresh),
1159
+ ...bindRefreshWatcher(lockWatcher, refreshLockRegistry),
1160
+ );
1161
+ void refreshController.refreshNow();
350
1162
  }
351
1163
 
352
1164
  function deactivate() {}