Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-25
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Notes

- Rename the Active Agents `finished` UI state to `Needs cleanup` for idle dirty worktrees.
- Keep the underlying `activityKind: "finished"` contract unchanged so existing state derivation remains stable.
- Split needs-cleanup sessions out of `Idle / thinking` so the sidebar explains why the worktree is still visible.
- Sync the shipped VS Code extension template with the canonical extension source.
- Bump the Active Agents extension manifest from `0.0.19` to `0.0.20`.
36 changes: 29 additions & 7 deletions templates/vscode/guardex-active-agents/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ const MANAGED_REPO_SCAN_IGNORED_FOLDERS = [
const SESSION_ACTIVITY_GROUPS = [
{ kind: 'blocked', label: 'BLOCKED' },
{ kind: 'working', label: 'WORKING NOW' },
{ kind: 'finished', label: 'FINISHED' },
{ kind: 'finished', label: 'NEEDS CLEANUP' },
{ kind: 'idle', label: 'THINKING' },
{ kind: 'stalled', label: 'STALLED' },
{ kind: 'dead', label: 'DEAD' },
Expand Down Expand Up @@ -571,7 +571,7 @@ function buildActiveAgentsStatusSummary(summary) {
if (workingCount > 0 || finishedCount > 0 || idleCount > 0) {
const parts = [`${workingCount} working`];
if (finishedCount > 0) {
parts.push(`${finishedCount} finished`);
parts.push(`${finishedCount} needs cleanup`);
}
parts.push(`${idleCount} idle`);
return `$(git-branch) ${parts.join(' · ')}`;
Expand All @@ -594,7 +594,7 @@ function buildActiveAgentsStatusTooltip(selectedSession, summary) {
return [
formatCountLabel(activeCount, 'active agent'),
formatCountLabel(summary?.workingCount || 0, 'working now session', 'working now sessions'),
formatCountLabel(summary?.finishedCount || 0, 'finished session'),
formatCountLabel(summary?.finishedCount || 0, 'needs cleanup session'),
formatCountLabel(summary?.idleCount || 0, 'idle session'),
formatCountLabel(summary?.unassignedChangeCount || 0, 'unassigned change'),
formatCountLabel(summary?.lockedFileCount || 0, 'locked file'),
Expand Down Expand Up @@ -681,7 +681,7 @@ function sessionFreshnessLabel(session, now = Date.now()) {
return 'Needs attention';
}
if (session.activityKind === 'finished') {
return 'Finished';
return 'Needs cleanup';
}
if (session.activityKind === 'stalled') {
return 'Possibly stale';
Expand Down Expand Up @@ -711,7 +711,7 @@ function sessionStatusLabel(session) {
case 'working':
return 'Working';
case 'finished':
return 'Finished';
return 'Needs cleanup';
case 'idle':
return 'Idle';
case 'stalled':
Expand Down Expand Up @@ -918,7 +918,7 @@ function buildWorktreeBranchDescription(sessions) {
function buildOverviewDescription(summary) {
return [
formatCountLabel(summary?.workingCount || 0, 'working agent'),
formatCountLabel(summary?.finishedCount || 0, 'finished agent'),
formatCountLabel(summary?.finishedCount || 0, 'needs cleanup agent'),
formatCountLabel(summary?.idleCount || 0, 'idle agent'),
summary?.colonyTaskCount
? formatCountLabel(summary.colonyTaskCount, 'colony task')
Expand Down Expand Up @@ -2925,7 +2925,9 @@ function buildWorkingNowNodes(sessions) {
function buildIdleThinkingNodes(sessions) {
const sessionEntries = sortSessionsForIdleThinking(
sessions.filter((session) => !(
session.activityKind === 'working' || session.activityKind === 'blocked'
session.activityKind === 'working'
|| session.activityKind === 'blocked'
|| session.activityKind === 'finished'
)),
).map((session) => ({
projectRelativePath: resolveSessionProjectRelativePath(session),
Expand All @@ -2935,6 +2937,17 @@ function buildIdleThinkingNodes(sessions) {
return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' });
}

function buildNeedsCleanupNodes(sessions) {
const sessionEntries = sessions
.filter((session) => session.activityKind === 'finished')
.map((session) => ({
projectRelativePath: resolveSessionProjectRelativePath(session),
sessions: [session],
item: new SessionItem(session, buildSessionDetailItems(session)),
}));
return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' });
}

function buildUnassignedChangeNodes(changes) {
return sortUnassignedChanges(changes).map((change) => new ChangeItem(change, {
label: compactRelativePath(change.relativePath),
Expand Down Expand Up @@ -3205,6 +3218,15 @@ class ActiveAgentsProvider {
}));
}

const needsCleanupItems = buildNeedsCleanupNodes(element.sessions);
if (needsCleanupItems.length > 0) {
sectionItems.push(new SectionItem('Needs cleanup', needsCleanupItems, {
description: String(needsCleanupItems.length),
collapsedState: vscode.TreeItemCollapsibleState.Collapsed,
iconId: 'pass-filled',
}));
}

const idleThinkingItems = buildIdleThinkingNodes(element.sessions);
if (idleThinkingItems.length > 0) {
sectionItems.push(new SectionItem('Idle / thinking', idleThinkingItems, {
Expand Down
2 changes: 1 addition & 1 deletion templates/vscode/guardex-active-agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"displayName": "GitGuardex Active Agents",
"description": "Shows live Guardex sandbox sessions and repo changes in a dedicated VS Code Active Agents sidebar.",
"publisher": "Recodee",
"version": "0.0.19",
"version": "0.0.20",
"license": "MIT",
"icon": "icon.png",
"engines": {
Expand Down
80 changes: 72 additions & 8 deletions test/vscode-active-agents-session-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1738,7 +1738,7 @@ test('active-agents extension groups live sessions under a repo node', async ()
const provider = registrations.providers[0].provider;
const [repoItem] = await provider.getChildren();
assert.equal(repoItem.label, path.basename(tempRoot));
assert.equal(repoItem.description, '0 working agents · 0 finished agents · 1 idle agent · 0 unassigned changes · 0 locked files · 0 conflicts');
assert.equal(repoItem.description, '0 working agents · 0 needs cleanup agents · 1 idle agent · 0 unassigned changes · 0 locked files · 0 conflicts');

assert.deepEqual((await provider.getChildren(repoItem)).map((item) => item.label), [
'Overview',
Expand Down Expand Up @@ -1783,6 +1783,70 @@ test('active-agents extension groups live sessions under a repo node', async ()
}
});

test('active-agents extension labels idle dirty finished worktrees as needing cleanup', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-cleanup-label-'));
initGitRepo(tempRoot);
fs.writeFileSync(path.join(tempRoot, 'tracked.txt'), 'base\n', 'utf8');
runGit(tempRoot, ['add', 'tracked.txt']);
runGit(tempRoot, ['commit', '-m', 'baseline']);

const worktreePath = path.join(tempRoot, '.omx', 'agent-worktrees', 'finished-task');
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
runGit(tempRoot, [
'worktree',
'add',
'-b',
'agent/codex/finished-task',
worktreePath,
'HEAD',
]);
const changedPath = path.join(worktreePath, 'tracked.txt');
fs.writeFileSync(changedPath, 'base\nleftover cleanup\n', 'utf8');
setPathMtime(changedPath, Date.now() - (20 * 60 * 1000));

const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({
repoRoot: tempRoot,
branch: 'agent/codex/finished-task',
taskName: 'finished-task',
agentName: 'codex',
worktreePath,
pid: process.pid,
cliName: 'codex',
}));

const { registrations, vscode } = createMockVscode(tempRoot);
vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }];
const extension = loadExtensionWithMockVscode(vscode);
const context = { subscriptions: [] };

extension.activate(context);
await flushAsyncWork();

const provider = registrations.providers[0].provider;
const [repoItem] = await provider.getChildren();
assert.equal(repoItem.description, '0 working agents · 1 needs cleanup agent · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts');
assert.deepEqual((await provider.getChildren(repoItem)).map((item) => item.label), [
'Overview',
'Needs cleanup',
'Advanced details',
]);

const cleanupSection = await getSectionByLabel(provider, repoItem, 'Needs cleanup');
const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, cleanupSection);
assert.equal(worktreeItem, null);
assert.equal(sessionItem.label, 'finished-task');
assert.match(sessionItem.description, /^Needs cleanup: codex · via OpenAI · 1 changed file/);
assert.equal(sessionItem.iconPath.id, 'pass-filled');
assert.deepEqual(registrations.treeViews[0].badge, {
value: 1,
tooltip: repoItem.description,
});

for (const subscription of context.subscriptions) {
subscription.dispose?.();
}
});

test('active-agents extension discovers nested managed-worktree subprojects under workspace roots', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-subprojects-'));
const nestedRepoRoot = path.join(tempRoot, 'gitguardex');
Expand Down Expand Up @@ -1827,7 +1891,7 @@ test('active-agents extension discovers nested managed-worktree subprojects unde
const [repoItem] = await provider.getChildren();
assert.equal(repoItem.label, `${path.basename(tempRoot)}/gitguardex`);
assert.equal(repoItem.repoRoot, nestedRepoRoot);
assert.equal(repoItem.description, '1 working agent · 0 finished agents · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts');
assert.equal(repoItem.description, '1 working agent · 0 needs cleanup agents · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts');

const workingSection = await getSectionByLabel(provider, repoItem, 'Working now');
const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection);
Expand Down Expand Up @@ -2122,7 +2186,7 @@ test('active-agents extension shows grouped repo changes beside active agents',

const provider = registrations.providers[0].provider;
const [repoItem] = await provider.getChildren();
assert.equal(repoItem.description, '1 working agent · 0 finished agents · 0 idle agents · 1 unassigned change · 0 locked files · 0 conflicts');
assert.equal(repoItem.description, '1 working agent · 0 needs cleanup agents · 0 idle agents · 1 unassigned change · 0 locked files · 0 conflicts');
assert.deepEqual((await provider.getChildren(repoItem)).map((item) => item.label), [
'Overview',
'Working now',
Expand Down Expand Up @@ -2258,7 +2322,7 @@ test('active-agents extension surfaces live managed worktrees from AGENT.lock fa
const provider = registrations.providers[0].provider;
const [repoItem] = await provider.getChildren();
assert.equal(repoItem.label, `${path.basename(tempRoot)}/gitguardex`);
assert.equal(repoItem.description, '1 working agent · 0 finished agents · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts');
assert.equal(repoItem.description, '1 working agent · 0 needs cleanup agents · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts');

assert.deepEqual((await provider.getChildren(repoItem)).map((item) => item.label), [
'Overview',
Expand Down Expand Up @@ -2476,7 +2540,7 @@ test('active-agents extension surfaces plain managed worktrees from workspace fa

const provider = registrations.providers[0].provider;
const [repoItem] = await provider.getChildren();
assert.equal(repoItem.description, '1 working agent · 0 finished agents · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts');
assert.equal(repoItem.description, '1 working agent · 0 needs cleanup agents · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts');

const workingSection = await getSectionByLabel(provider, repoItem, 'Working now');
const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection);
Expand Down Expand Up @@ -2531,7 +2595,7 @@ test('active-agents extension resolves owning repo sessions when the window is o
const provider = registrations.providers[0].provider;
const [repoItem] = await provider.getChildren();
assert.equal(repoItem.label, path.basename(tempRoot));
assert.equal(repoItem.description, '1 working agent · 0 finished agents · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts');
assert.equal(repoItem.description, '1 working agent · 0 needs cleanup agents · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts');

const workingSection = await getSectionByLabel(provider, repoItem, 'Working now');
const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection);
Expand Down Expand Up @@ -2610,7 +2674,7 @@ test('active-agents extension decorates sessions and repo changes from the lock

const provider = registrations.providers[0].provider;
const [repoItem] = await provider.getChildren();
assert.equal(repoItem.description, '1 working agent · 0 finished agents · 0 idle agents · 1 unassigned change · 3 locked files · 2 conflicts');
assert.equal(repoItem.description, '1 working agent · 0 needs cleanup agents · 0 idle agents · 1 unassigned change · 3 locked files · 2 conflicts');
const workingSection = await getSectionByLabel(provider, repoItem, 'Working now');
const unassignedSection = await getSectionByLabel(provider, repoItem, 'Unassigned changes');
const advancedSection = await getSectionByLabel(provider, repoItem, 'Advanced details');
Expand Down Expand Up @@ -2861,7 +2925,7 @@ test('active-agents extension groups blocked, working, idle, stalled, and dead s

const provider = registrations.providers[0].provider;
const [repoItem] = await provider.getChildren();
assert.equal(repoItem.description, '2 working agents · 0 finished agents · 2 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts');
assert.equal(repoItem.description, '2 working agents · 0 needs cleanup agents · 2 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts');

assert.deepEqual((await provider.getChildren(repoItem)).map((item) => item.label), [
'Overview',
Expand Down
36 changes: 29 additions & 7 deletions vscode/guardex-active-agents/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ const MANAGED_REPO_SCAN_IGNORED_FOLDERS = [
const SESSION_ACTIVITY_GROUPS = [
{ kind: 'blocked', label: 'BLOCKED' },
{ kind: 'working', label: 'WORKING NOW' },
{ kind: 'finished', label: 'FINISHED' },
{ kind: 'finished', label: 'NEEDS CLEANUP' },
{ kind: 'idle', label: 'THINKING' },
{ kind: 'stalled', label: 'STALLED' },
{ kind: 'dead', label: 'DEAD' },
Expand Down Expand Up @@ -571,7 +571,7 @@ function buildActiveAgentsStatusSummary(summary) {
if (workingCount > 0 || finishedCount > 0 || idleCount > 0) {
const parts = [`${workingCount} working`];
if (finishedCount > 0) {
parts.push(`${finishedCount} finished`);
parts.push(`${finishedCount} needs cleanup`);
}
parts.push(`${idleCount} idle`);
return `$(git-branch) ${parts.join(' · ')}`;
Expand All @@ -594,7 +594,7 @@ function buildActiveAgentsStatusTooltip(selectedSession, summary) {
return [
formatCountLabel(activeCount, 'active agent'),
formatCountLabel(summary?.workingCount || 0, 'working now session', 'working now sessions'),
formatCountLabel(summary?.finishedCount || 0, 'finished session'),
formatCountLabel(summary?.finishedCount || 0, 'needs cleanup session'),
formatCountLabel(summary?.idleCount || 0, 'idle session'),
formatCountLabel(summary?.unassignedChangeCount || 0, 'unassigned change'),
formatCountLabel(summary?.lockedFileCount || 0, 'locked file'),
Expand Down Expand Up @@ -681,7 +681,7 @@ function sessionFreshnessLabel(session, now = Date.now()) {
return 'Needs attention';
}
if (session.activityKind === 'finished') {
return 'Finished';
return 'Needs cleanup';
}
if (session.activityKind === 'stalled') {
return 'Possibly stale';
Expand Down Expand Up @@ -711,7 +711,7 @@ function sessionStatusLabel(session) {
case 'working':
return 'Working';
case 'finished':
return 'Finished';
return 'Needs cleanup';
case 'idle':
return 'Idle';
case 'stalled':
Expand Down Expand Up @@ -918,7 +918,7 @@ function buildWorktreeBranchDescription(sessions) {
function buildOverviewDescription(summary) {
return [
formatCountLabel(summary?.workingCount || 0, 'working agent'),
formatCountLabel(summary?.finishedCount || 0, 'finished agent'),
formatCountLabel(summary?.finishedCount || 0, 'needs cleanup agent'),
formatCountLabel(summary?.idleCount || 0, 'idle agent'),
summary?.colonyTaskCount
? formatCountLabel(summary.colonyTaskCount, 'colony task')
Expand Down Expand Up @@ -2925,7 +2925,9 @@ function buildWorkingNowNodes(sessions) {
function buildIdleThinkingNodes(sessions) {
const sessionEntries = sortSessionsForIdleThinking(
sessions.filter((session) => !(
session.activityKind === 'working' || session.activityKind === 'blocked'
session.activityKind === 'working'
|| session.activityKind === 'blocked'
|| session.activityKind === 'finished'
)),
).map((session) => ({
projectRelativePath: resolveSessionProjectRelativePath(session),
Expand All @@ -2935,6 +2937,17 @@ function buildIdleThinkingNodes(sessions) {
return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' });
}

function buildNeedsCleanupNodes(sessions) {
const sessionEntries = sessions
.filter((session) => session.activityKind === 'finished')
.map((session) => ({
projectRelativePath: resolveSessionProjectRelativePath(session),
sessions: [session],
item: new SessionItem(session, buildSessionDetailItems(session)),
}));
return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' });
}

function buildUnassignedChangeNodes(changes) {
return sortUnassignedChanges(changes).map((change) => new ChangeItem(change, {
label: compactRelativePath(change.relativePath),
Expand Down Expand Up @@ -3205,6 +3218,15 @@ class ActiveAgentsProvider {
}));
}

const needsCleanupItems = buildNeedsCleanupNodes(element.sessions);
if (needsCleanupItems.length > 0) {
sectionItems.push(new SectionItem('Needs cleanup', needsCleanupItems, {
description: String(needsCleanupItems.length),
collapsedState: vscode.TreeItemCollapsibleState.Collapsed,
iconId: 'pass-filled',
}));
}

const idleThinkingItems = buildIdleThinkingNodes(element.sessions);
if (idleThinkingItems.length > 0) {
sectionItems.push(new SectionItem('Idle / thinking', idleThinkingItems, {
Expand Down
2 changes: 1 addition & 1 deletion vscode/guardex-active-agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"displayName": "GitGuardex Active Agents",
"description": "Shows live Guardex sandbox sessions and repo changes in a dedicated VS Code Active Agents sidebar.",
"publisher": "Recodee",
"version": "0.0.19",
"version": "0.0.20",
"license": "MIT",
"icon": "icon.png",
"engines": {
Expand Down
Loading