diff --git a/openspec/changes/agent-codex-vscode-active-agents-scm-commit-input-2026-04-22-10-55/proposal.md b/openspec/changes/agent-codex-vscode-active-agents-scm-commit-input-2026-04-22-10-55/proposal.md new file mode 100644 index 0000000..44b11f4 --- /dev/null +++ b/openspec/changes/agent-codex-vscode-active-agents-scm-commit-input-2026-04-22-10-55/proposal.md @@ -0,0 +1,16 @@ +## Why + +- Operators can already see live Guardex sandboxes inside the `gitguardex.activeAgents` Source Control companion, but they cannot commit the selected sandbox without dropping back to the terminal. +- The reference UX already exposes a compact header commit affordance; this view should use the same pattern instead of forcing a second workflow. + +## What Changes + +- Track the currently selected Active Agents session in the VS Code companion. +- Add a native SCM commit input plus header commit command that targets the selected session's `worktreePath`. +- Stage with `git add -A` while excluding `.omx/state/agent-file-locks.json`, then run `git commit -m ` when the user accepts the input or clicks the header affordance. +- Show an information message if the user tries to commit without selecting a session first. + +## Impact + +- Scope stays inside the VS Code companion bundle plus its focused regression tests. +- The commit flow shells out to `git`, so failure paths must surface clear VS Code messages instead of failing silently. diff --git a/openspec/changes/agent-codex-vscode-active-agents-scm-commit-input-2026-04-22-10-55/specs/vscode-active-agents-scm-commit-input/spec.md b/openspec/changes/agent-codex-vscode-active-agents-scm-commit-input-2026-04-22-10-55/specs/vscode-active-agents-scm-commit-input/spec.md new file mode 100644 index 0000000..695b00a --- /dev/null +++ b/openspec/changes/agent-codex-vscode-active-agents-scm-commit-input-2026-04-22-10-55/specs/vscode-active-agents-scm-commit-input/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Active Agents SCM commit box targets the selected sandbox +The Guardex Active Agents VS Code companion SHALL expose a native SCM commit input that targets the currently selected `gitguardex.activeAgents` session worktree. + +#### Scenario: Accept input commits the selected session worktree +- **WHEN** the operator selects a live Active Agents session and accepts the SCM input +- **THEN** the companion stages the selected session worktree with `git add -A` +- **AND** it excludes `.omx/state/agent-file-locks.json` from that stage operation +- **AND** it runs `git commit -m ` against the selected session's `worktreePath`. + +#### Scenario: Header commit affordance uses the same selected session +- **WHEN** the operator activates the view-header commit command while a live session is selected +- **THEN** the companion uses the same SCM input message +- **AND** it commits the same selected session worktree instead of prompting for a different target. + +#### Scenario: Missing selection degrades safely +- **WHEN** the operator accepts the SCM input or clicks the header commit affordance without a selected session +- **THEN** the companion does not run any git command +- **AND** it shows an information message telling the operator to pick a session first. diff --git a/openspec/changes/agent-codex-vscode-active-agents-scm-commit-input-2026-04-22-10-55/tasks.md b/openspec/changes/agent-codex-vscode-active-agents-scm-commit-input-2026-04-22-10-55/tasks.md new file mode 100644 index 0000000..a4962f2 --- /dev/null +++ b/openspec/changes/agent-codex-vscode-active-agents-scm-commit-input-2026-04-22-10-55/tasks.md @@ -0,0 +1,35 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +## Handoff + +- Handoff: change=`agent-codex-vscode-active-agents-scm-commit-input-2026-04-22-10-55`; branch=`agent/codex/vscode-active-agents-scm-commit-input-2026-04-22-10-55`; scope=`templates/vscode/guardex-active-agents/*`, `vscode/guardex-active-agents/*`, `test/vscode-active-agents-session-state.test.js`; action=`add a selected-session SCM commit input and header affordance to the Active Agents companion`. +- Copy prompt: Continue `agent-codex-vscode-active-agents-scm-commit-input-2026-04-22-10-55` on branch `agent/codex/vscode-active-agents-scm-commit-input-2026-04-22-10-55`. Work inside the existing sandbox, review `openspec/changes/agent-codex-vscode-active-agents-scm-commit-input-2026-04-22-10-55/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/vscode-active-agents-scm-commit-input-2026-04-22-10-55 --base main --via-pr --wait-for-merge --cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-vscode-active-agents-scm-commit-input-2026-04-22-10-55`. +- [x] 1.2 Define normative requirements in `specs/vscode-active-agents-scm-commit-input/spec.md`. + +## 2. Implementation + +- [x] 2.1 Track the currently selected Active Agents session and surface the native SCM commit box/header affordance for that selection. +- [x] 2.2 Stage and commit the selected worktree with the agent lock-file exclusion and a no-selection information message. +- [x] 2.3 Keep the source and template extension bundles in sync. + +## 3. Verification + +- [x] 3.1 Run `node --test test/vscode-active-agents-session-state.test.js`. +- [x] 3.2 Run `openspec validate agent-codex-vscode-active-agents-scm-commit-input-2026-04-22-10-55 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/codex/vscode-active-agents-scm-commit-input-2026-04-22-10-55 --base main --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch). diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index 1abba6a..623f107 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -454,6 +454,7 @@ function countWorkingSessions(sessions) { return sessions.filter((session) => session.activityKind === 'working').length; } +<<<<<<< HEAD function shellQuote(value) { return `'${String(value).replace(/'/g, `'\\''`)}'`; } @@ -529,6 +530,38 @@ async function startAgentFromPrompt(refresh) { true, ); refresh(); +======= +function sessionSelectionKey(session) { + if (!session?.repoRoot || !session?.branch) { + return ''; + } + + return `${session.repoRoot}::${session.branch}`; +} + +function formatGitCommandFailure(error) { + for (const value of [error?.stderr, error?.stdout, error?.message]) { + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + } + return 'Git command failed.'; +} + +function runGitCommand(worktreePath, args) { + return cp.execFileSync('git', ['-C', worktreePath, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function stageWorktreeForCommit(worktreePath) { + runGitCommand(worktreePath, ['add', '-A', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`]); +} + +function commitWorktree(worktreePath, message) { + runGitCommand(worktreePath, ['commit', '-m', message]); +>>>>>>> 60c38c6 (Let operators commit the selected sandbox from the Active Agents SCM view) } function buildActiveAgentGroupNodes(sessions) { @@ -555,8 +588,11 @@ class ActiveAgentsProvider { this.decorationProvider = decorationProvider; this.onDidChangeTreeDataEmitter = new vscode.EventEmitter(); this.onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; + this.onDidChangeSelectedSessionEmitter = new vscode.EventEmitter(); + this.onDidChangeSelectedSession = this.onDidChangeSelectedSessionEmitter.event; this.treeView = null; this.lockRegistryByRepoRoot = new Map(); + this.selectedSession = null; } getTreeItem(element) { @@ -566,6 +602,35 @@ class ActiveAgentsProvider { attachTreeView(treeView) { this.treeView = treeView; this.updateViewState(0, 0); + treeView.onDidChangeSelection?.((event) => { + const sessionItem = event.selection.find((item) => item instanceof SessionItem); + this.setSelectedSession(sessionItem?.session || null); + }); + } + + setSelectedSession(session) { + const nextSession = session?.worktreePath ? { ...session } : null; + const currentKey = sessionSelectionKey(this.selectedSession); + const nextKey = sessionSelectionKey(nextSession); + this.selectedSession = nextSession; + if (currentKey !== nextKey) { + this.onDidChangeSelectedSessionEmitter.fire(this.selectedSession); + } + } + + getSelectedSession() { + return this.selectedSession ? { ...this.selectedSession } : null; + } + + syncSelectedSession(repoEntries) { + if (!this.selectedSession) { + return; + } + + const nextSession = repoEntries + .flatMap((entry) => entry.sessions) + .find((session) => sessionSelectionKey(session) === sessionSelectionKey(this.selectedSession)); + this.setSelectedSession(nextSession || null); } updateViewState(sessionCount, workingCount) { @@ -634,6 +699,7 @@ class ActiveAgentsProvider { } const repoEntries = await this.syncRepoEntries(); + this.syncSelectedSession(repoEntries); if (repoEntries.length === 0) { return []; @@ -688,12 +754,62 @@ function activate(context) { treeDataProvider: provider, showCollapseAll: true, }); + const sourceControl = vscode.scm.createSourceControl( + 'gitguardex.activeAgents.commitInput', + 'Active Agents Commit', + ); provider.attachTreeView(treeView); const refresh = () => { void provider.refresh(); }; const sessionWatcher = vscode.workspace.createFileSystemWatcher('**/.omx/state/active-sessions/*.json'); const lockWatcher = vscode.workspace.createFileSystemWatcher('**/.omx/state/agent-file-locks.json'); + const updateCommitInput = (session) => { + sourceControl.inputBox.enabled = true; + sourceControl.inputBox.visible = true; + sourceControl.inputBox.placeholder = session?.label + ? `Commit ${session.label} (Ctrl+Enter)` + : 'Pick an Active Agents session to commit its worktree.'; + }; + updateCommitInput(null); + const commitSelectedSession = async () => { + const selectedSession = provider.getSelectedSession(); + if (!selectedSession?.worktreePath) { + vscode.window.showInformationMessage?.('Pick an Active Agents session first.'); + return; + } + + const message = String(sourceControl.inputBox.value || '').trim(); + if (!message) { + vscode.window.showInformationMessage?.('Enter a commit message first.'); + return; + } + + if (!fs.existsSync(selectedSession.worktreePath)) { + vscode.window.showInformationMessage?.( + `Selected session worktree is no longer on disk: ${selectedSession.worktreePath}`, + ); + return; + } + + try { + stageWorktreeForCommit(selectedSession.worktreePath); + commitWorktree(selectedSession.worktreePath, message); + sourceControl.inputBox.value = ''; + refresh(); + } catch (error) { + const failure = formatGitCommandFailure(error); + if (/nothing to commit|no changes added to commit/i.test(failure)) { + vscode.window.showInformationMessage?.(`No changes to commit in ${selectedSession.label}.`); + return; + } + vscode.window.showErrorMessage?.(`Active Agents commit failed: ${failure}`); + } + }; + sourceControl.acceptInputCommand = { + command: 'gitguardex.activeAgents.commitSelectedSession', + title: 'Commit Selected Session', + }; const interval = setInterval(refresh, 5_000); const refreshLockRegistry = (uri) => { if (uri?.fsPath) { @@ -702,11 +818,15 @@ function activate(context) { refresh(); }; + provider.onDidChangeSelectedSession(updateCommitInput); + context.subscriptions.push( treeView, + sourceControl, vscode.window.registerFileDecorationProvider(decorationProvider), vscode.commands.registerCommand('gitguardex.activeAgents.startAgent', () => startAgentFromPrompt(refresh)), vscode.commands.registerCommand('gitguardex.activeAgents.refresh', refresh), + vscode.commands.registerCommand('gitguardex.activeAgents.commitSelectedSession', commitSelectedSession), vscode.commands.registerCommand('gitguardex.activeAgents.openWorktree', async (session) => { if (!session?.worktreePath) { return; diff --git a/templates/vscode/guardex-active-agents/package.json b/templates/vscode/guardex-active-agents/package.json index 7d89c87..8352ac8 100644 --- a/templates/vscode/guardex-active-agents/package.json +++ b/templates/vscode/guardex-active-agents/package.json @@ -27,6 +27,11 @@ "command": "gitguardex.activeAgents.refresh", "title": "Refresh Active Agents" }, + { + "command": "gitguardex.activeAgents.commitSelectedSession", + "title": "Commit Selected Session", + "icon": "$(check)" + }, { "command": "gitguardex.activeAgents.openWorktree", "title": "Open Agent Worktree" @@ -69,10 +74,15 @@ ], "menus": { "view/title": [ + { + "command": "gitguardex.activeAgents.commitSelectedSession", + "when": "view == gitguardex.activeAgents", + "group": "navigation@1" + }, { "command": "gitguardex.activeAgents.refresh", "when": "view == gitguardex.activeAgents", - "group": "navigation" + "group": "navigation@9" } ], "view/item/context": [ diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index ac2dc7f..a244554 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -66,13 +66,19 @@ function createMockVscode(tempRoot) { treeViews: [], commands: new Map(), executedCommands: [], + sourceControls: [], terminals: [], openedDocuments: [], shownDocuments: [], infoMessages: [], +<<<<<<< HEAD inputResponses: [], quickPickCalls: [], quickPickResponse: undefined, +======= + informationMessages: [], + errorMessages: [], +>>>>>>> 60c38c6 (Let operators commit the selected sandbox from the Active Agents SCM view) warningMessages: [], watchers: [], }; @@ -171,12 +177,42 @@ function createMockVscode(tempRoot) { Expanded: 1, }, commands: { - executeCommand: async (...args) => { - registrations.executedCommands.push(args); + executeCommand: async (command, ...args) => { + registrations.executedCommands.push({ command, args }); + if (command === 'setContext') { + return undefined; + } + const handler = registrations.commands.get(command); + if (handler) { + return handler(...args); + } + return undefined; }, registerCommand: (command, handler) => { registrations.commands.set(command, handler); - return disposable(); + return { + dispose() { + registrations.commands.delete(command); + }, + }; + }, + }, + scm: { + createSourceControl: (id, label) => { + const sourceControl = { + id, + label, + inputBox: { + value: '', + placeholder: '', + enabled: true, + visible: true, + }, + acceptInputCommand: undefined, + dispose() {}, + }; + registrations.sourceControls.push(sourceControl); + return sourceControl; }, }, Uri: { @@ -203,6 +239,13 @@ function createMockVscode(tempRoot) { window: { showInformationMessage: async (...args) => { registrations.infoMessages.push(args); + if (typeof args[0] === 'string') { + registrations.informationMessages.push(args[0]); + } + return undefined; + }, + showErrorMessage: async (message) => { + registrations.errorMessages.push(message); return undefined; }, showWarningMessage: async (...args) => { @@ -235,11 +278,21 @@ function createMockVscode(tempRoot) { return { document }; }, createTreeView: (viewId, options) => { + const selectionListeners = []; const treeView = { viewId, options, badge: undefined, message: undefined, + onDidChangeSelection(listener) { + selectionListeners.push(listener); + return disposable(); + }, + fireSelection(selection) { + for (const listener of selectionListeners) { + listener({ selection }); + } + }, dispose() {}, }; registrations.treeViews.push(treeView); @@ -449,7 +502,13 @@ test('active-agents extension registers tree and decoration providers', async () extension.activate(context); assert.equal(registrations.treeViews.length, 1); + assert.equal(registrations.sourceControls.length, 1); assert.equal(registrations.treeViews[0].viewId, 'gitguardex.activeAgents'); + assert.equal(registrations.sourceControls[0].label, 'Active Agents Commit'); + assert.equal( + registrations.sourceControls[0].inputBox.placeholder, + 'Pick an Active Agents session to commit its worktree.', + ); assert.equal(registrations.providers.length, 1); assert.equal(registrations.providers[0].viewId, 'gitguardex.activeAgents'); assert.equal(registrations.decorationProviders.length, 1); @@ -1022,6 +1081,94 @@ test('active-agents extension splits working and thinking sessions into separate } }); +test('active-agents extension commits the selected session worktree from the SCM input', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-commit-view-')); + const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-commit-session-')); + initGitRepo(worktreePath); + fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(worktreePath, ['add', 'tracked.txt']); + runGit(worktreePath, ['commit', '-m', 'baseline']); + fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\nchanged\n', 'utf8'); + fs.mkdirSync(path.join(worktreePath, '.omx', 'state'), { recursive: true }); + fs.writeFileSync( + path.join(worktreePath, '.omx', 'state', 'agent-file-locks.json'), + '{"owner":"codex"}\n', + 'utf8', + ); + + const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, 'agent/codex/commit-task'); + fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); + fs.writeFileSync( + sessionPath, + `${JSON.stringify(sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/commit-task', + taskName: 'commit-task', + agentName: 'codex', + worktreePath, + pid: process.pid, + cliName: 'codex', + }), null, 2)}\n`, + 'utf8', + ); + + const { registrations, vscode } = createMockVscode(tempRoot); + vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }]; + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + extension.activate(context); + + const provider = registrations.providers[0].provider; + const [repoItem] = await provider.getChildren(); + const [agentsSection] = await provider.getChildren(repoItem); + const [workingSection] = await provider.getChildren(agentsSection); + const [sessionItem] = await provider.getChildren(workingSection); + registrations.treeViews[0].fireSelection([sessionItem]); + + assert.equal( + registrations.sourceControls[0].inputBox.placeholder, + `Commit ${sessionItem.session.label} (Ctrl+Enter)`, + ); + registrations.sourceControls[0].inputBox.value = 'Ship the selected sandbox'; + + await vscode.commands.executeCommand('gitguardex.activeAgents.commitSelectedSession'); + + const commitMessage = runGit(worktreePath, ['log', '-1', '--pretty=%s']).stdout.trim(); + assert.equal(commitMessage, 'Ship the selected sandbox'); + assert.equal(runGit(worktreePath, ['status', '--short', '--', 'tracked.txt']).stdout.trim(), ''); + assert.equal( + runGit(worktreePath, ['status', '--short', '--', '.omx/state/agent-file-locks.json']).stdout.trim(), + '?? .omx/state/agent-file-locks.json', + ); + assert.equal(registrations.sourceControls[0].inputBox.value, ''); + assert.deepEqual(registrations.informationMessages, []); + assert.deepEqual(registrations.errorMessages, []); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + +test('active-agents extension asks for a session before committing', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-no-selection-')); + const { registrations, vscode } = createMockVscode(tempRoot); + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + extension.activate(context); + registrations.sourceControls[0].inputBox.value = 'Commit without a selection'; + + await vscode.commands.executeCommand('gitguardex.activeAgents.commitSelectedSession'); + + assert.deepEqual(registrations.informationMessages, ['Pick an Active Agents session first.']); + assert.deepEqual(registrations.errorMessages, []); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + test('active-agents extension launches finish and sync commands in session terminals', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-inline-actions-')); const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-inline-worktree-')); diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index 1abba6a..623f107 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -454,6 +454,7 @@ function countWorkingSessions(sessions) { return sessions.filter((session) => session.activityKind === 'working').length; } +<<<<<<< HEAD function shellQuote(value) { return `'${String(value).replace(/'/g, `'\\''`)}'`; } @@ -529,6 +530,38 @@ async function startAgentFromPrompt(refresh) { true, ); refresh(); +======= +function sessionSelectionKey(session) { + if (!session?.repoRoot || !session?.branch) { + return ''; + } + + return `${session.repoRoot}::${session.branch}`; +} + +function formatGitCommandFailure(error) { + for (const value of [error?.stderr, error?.stdout, error?.message]) { + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + } + return 'Git command failed.'; +} + +function runGitCommand(worktreePath, args) { + return cp.execFileSync('git', ['-C', worktreePath, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function stageWorktreeForCommit(worktreePath) { + runGitCommand(worktreePath, ['add', '-A', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`]); +} + +function commitWorktree(worktreePath, message) { + runGitCommand(worktreePath, ['commit', '-m', message]); +>>>>>>> 60c38c6 (Let operators commit the selected sandbox from the Active Agents SCM view) } function buildActiveAgentGroupNodes(sessions) { @@ -555,8 +588,11 @@ class ActiveAgentsProvider { this.decorationProvider = decorationProvider; this.onDidChangeTreeDataEmitter = new vscode.EventEmitter(); this.onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; + this.onDidChangeSelectedSessionEmitter = new vscode.EventEmitter(); + this.onDidChangeSelectedSession = this.onDidChangeSelectedSessionEmitter.event; this.treeView = null; this.lockRegistryByRepoRoot = new Map(); + this.selectedSession = null; } getTreeItem(element) { @@ -566,6 +602,35 @@ class ActiveAgentsProvider { attachTreeView(treeView) { this.treeView = treeView; this.updateViewState(0, 0); + treeView.onDidChangeSelection?.((event) => { + const sessionItem = event.selection.find((item) => item instanceof SessionItem); + this.setSelectedSession(sessionItem?.session || null); + }); + } + + setSelectedSession(session) { + const nextSession = session?.worktreePath ? { ...session } : null; + const currentKey = sessionSelectionKey(this.selectedSession); + const nextKey = sessionSelectionKey(nextSession); + this.selectedSession = nextSession; + if (currentKey !== nextKey) { + this.onDidChangeSelectedSessionEmitter.fire(this.selectedSession); + } + } + + getSelectedSession() { + return this.selectedSession ? { ...this.selectedSession } : null; + } + + syncSelectedSession(repoEntries) { + if (!this.selectedSession) { + return; + } + + const nextSession = repoEntries + .flatMap((entry) => entry.sessions) + .find((session) => sessionSelectionKey(session) === sessionSelectionKey(this.selectedSession)); + this.setSelectedSession(nextSession || null); } updateViewState(sessionCount, workingCount) { @@ -634,6 +699,7 @@ class ActiveAgentsProvider { } const repoEntries = await this.syncRepoEntries(); + this.syncSelectedSession(repoEntries); if (repoEntries.length === 0) { return []; @@ -688,12 +754,62 @@ function activate(context) { treeDataProvider: provider, showCollapseAll: true, }); + const sourceControl = vscode.scm.createSourceControl( + 'gitguardex.activeAgents.commitInput', + 'Active Agents Commit', + ); provider.attachTreeView(treeView); const refresh = () => { void provider.refresh(); }; const sessionWatcher = vscode.workspace.createFileSystemWatcher('**/.omx/state/active-sessions/*.json'); const lockWatcher = vscode.workspace.createFileSystemWatcher('**/.omx/state/agent-file-locks.json'); + const updateCommitInput = (session) => { + sourceControl.inputBox.enabled = true; + sourceControl.inputBox.visible = true; + sourceControl.inputBox.placeholder = session?.label + ? `Commit ${session.label} (Ctrl+Enter)` + : 'Pick an Active Agents session to commit its worktree.'; + }; + updateCommitInput(null); + const commitSelectedSession = async () => { + const selectedSession = provider.getSelectedSession(); + if (!selectedSession?.worktreePath) { + vscode.window.showInformationMessage?.('Pick an Active Agents session first.'); + return; + } + + const message = String(sourceControl.inputBox.value || '').trim(); + if (!message) { + vscode.window.showInformationMessage?.('Enter a commit message first.'); + return; + } + + if (!fs.existsSync(selectedSession.worktreePath)) { + vscode.window.showInformationMessage?.( + `Selected session worktree is no longer on disk: ${selectedSession.worktreePath}`, + ); + return; + } + + try { + stageWorktreeForCommit(selectedSession.worktreePath); + commitWorktree(selectedSession.worktreePath, message); + sourceControl.inputBox.value = ''; + refresh(); + } catch (error) { + const failure = formatGitCommandFailure(error); + if (/nothing to commit|no changes added to commit/i.test(failure)) { + vscode.window.showInformationMessage?.(`No changes to commit in ${selectedSession.label}.`); + return; + } + vscode.window.showErrorMessage?.(`Active Agents commit failed: ${failure}`); + } + }; + sourceControl.acceptInputCommand = { + command: 'gitguardex.activeAgents.commitSelectedSession', + title: 'Commit Selected Session', + }; const interval = setInterval(refresh, 5_000); const refreshLockRegistry = (uri) => { if (uri?.fsPath) { @@ -702,11 +818,15 @@ function activate(context) { refresh(); }; + provider.onDidChangeSelectedSession(updateCommitInput); + context.subscriptions.push( treeView, + sourceControl, vscode.window.registerFileDecorationProvider(decorationProvider), vscode.commands.registerCommand('gitguardex.activeAgents.startAgent', () => startAgentFromPrompt(refresh)), vscode.commands.registerCommand('gitguardex.activeAgents.refresh', refresh), + vscode.commands.registerCommand('gitguardex.activeAgents.commitSelectedSession', commitSelectedSession), vscode.commands.registerCommand('gitguardex.activeAgents.openWorktree', async (session) => { if (!session?.worktreePath) { return; diff --git a/vscode/guardex-active-agents/package.json b/vscode/guardex-active-agents/package.json index 7d89c87..8352ac8 100644 --- a/vscode/guardex-active-agents/package.json +++ b/vscode/guardex-active-agents/package.json @@ -27,6 +27,11 @@ "command": "gitguardex.activeAgents.refresh", "title": "Refresh Active Agents" }, + { + "command": "gitguardex.activeAgents.commitSelectedSession", + "title": "Commit Selected Session", + "icon": "$(check)" + }, { "command": "gitguardex.activeAgents.openWorktree", "title": "Open Agent Worktree" @@ -69,10 +74,15 @@ ], "menus": { "view/title": [ + { + "command": "gitguardex.activeAgents.commitSelectedSession", + "when": "view == gitguardex.activeAgents", + "group": "navigation@1" + }, { "command": "gitguardex.activeAgents.refresh", "when": "view == gitguardex.activeAgents", - "group": "navigation" + "group": "navigation@9" } ], "view/item/context": [