diff --git a/openspec/changes/agent-codex-clarify-finished-active-agents-cleanup-s-2026-04-25-16-06/.openspec.yaml b/openspec/changes/agent-codex-clarify-finished-active-agents-cleanup-s-2026-04-25-16-06/.openspec.yaml new file mode 100644 index 0000000..1b75776 --- /dev/null +++ b/openspec/changes/agent-codex-clarify-finished-active-agents-cleanup-s-2026-04-25-16-06/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-25 diff --git a/openspec/changes/agent-codex-clarify-finished-active-agents-cleanup-s-2026-04-25-16-06/notes.md b/openspec/changes/agent-codex-clarify-finished-active-agents-cleanup-s-2026-04-25-16-06/notes.md new file mode 100644 index 0000000..1dfae57 --- /dev/null +++ b/openspec/changes/agent-codex-clarify-finished-active-agents-cleanup-s-2026-04-25-16-06/notes.md @@ -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`. diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index 463dd80..d96ae0b 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -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' }, @@ -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(' · ')}`; @@ -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'), @@ -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'; @@ -711,7 +711,7 @@ function sessionStatusLabel(session) { case 'working': return 'Working'; case 'finished': - return 'Finished'; + return 'Needs cleanup'; case 'idle': return 'Idle'; case 'stalled': @@ -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') @@ -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), @@ -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), @@ -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, { diff --git a/templates/vscode/guardex-active-agents/package.json b/templates/vscode/guardex-active-agents/package.json index dcf89ba..8c009a2 100644 --- a/templates/vscode/guardex-active-agents/package.json +++ b/templates/vscode/guardex-active-agents/package.json @@ -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": { diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index ce990fd..de76793 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -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', @@ -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'); @@ -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); @@ -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', @@ -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', @@ -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); @@ -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); @@ -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'); @@ -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', diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index 463dd80..d96ae0b 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -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' }, @@ -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(' · ')}`; @@ -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'), @@ -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'; @@ -711,7 +711,7 @@ function sessionStatusLabel(session) { case 'working': return 'Working'; case 'finished': - return 'Finished'; + return 'Needs cleanup'; case 'idle': return 'Idle'; case 'stalled': @@ -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') @@ -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), @@ -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), @@ -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, { diff --git a/vscode/guardex-active-agents/package.json b/vscode/guardex-active-agents/package.json index dcf89ba..8c009a2 100644 --- a/vscode/guardex-active-agents/package.json +++ b/vscode/guardex-active-agents/package.json @@ -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": {