From da465dd878f9fe2eb30aa091cb59292996451f47 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 22 Apr 2026 11:05:19 +0200 Subject: [PATCH] Add inline session controls to the Active Agents tree Operators need finish, sync, stop, and diff actions directly on session rows, so the extension now wires those commands into the tree view, keeps the shipped bundle and install template aligned, and covers the behavior in the focused extension suite. Constraint: Keep the checked-in extension bundle and installer template behavior identical Rejected: Launch gx commands as hidden child processes | visible terminals preserve operator control and existing CLI behavior Confidence: high Scope-risk: narrow Reversibility: clean Directive: Mirror any future Active Agents command changes across vscode/ and templates/vscode/ or installed copies will drift Tested: node --test test/vscode-active-agents-session-state.test.js Tested: node --check vscode/guardex-active-agents/extension.js Tested: node --check templates/vscode/guardex-active-agents/extension.js Tested: diff -u vscode/guardex-active-agents/extension.js templates/vscode/guardex-active-agents/extension.js Tested: diff -u vscode/guardex-active-agents/package.json templates/vscode/guardex-active-agents/package.json Not-tested: Live VS Code rendering of the inline codicon actions Not-tested: Full npm test remained noisy because concurrent install-flow tests were already active in this checkout --- .../vscode/guardex-active-agents/extension.js | 122 +++++++++++ .../vscode/guardex-active-agents/package.json | 40 ++++ ...vscode-active-agents-session-state.test.js | 190 +++++++++++++++++- vscode/guardex-active-agents/extension.js | 122 +++++++++++ vscode/guardex-active-agents/package.json | 40 ++++ 5 files changed, 511 insertions(+), 3 deletions(-) diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index a375c52..a0e3fbc 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -1,5 +1,6 @@ const fs = require('node:fs'); const path = require('node:path'); +const cp = require('node:child_process'); const vscode = require('vscode'); const { formatElapsedFrom, readActiveSessions, readRepoChanges } = require('./session-schema.js'); @@ -110,6 +111,123 @@ class ChangeItem extends vscode.TreeItem { } } +function shellQuote(value) { + const normalized = String(value || ''); + return `'${normalized.replace(/'/g, "'\"'\"'")}'`; +} + +function sessionDisplayLabel(session) { + return session?.taskName || session?.label || session?.branch || path.basename(session?.worktreePath || '') || 'session'; +} + +function sessionWorktreePath(session) { + return typeof session?.worktreePath === 'string' ? session.worktreePath.trim() : ''; +} + +function showSessionMessage(message) { + vscode.window.showInformationMessage?.(message); +} + +function ensureSessionWorktree(session, actionLabel) { + const worktreePath = sessionWorktreePath(session); + if (!worktreePath) { + showSessionMessage(`Cannot ${actionLabel}: missing worktree path.`); + return ''; + } + if (!fs.existsSync(worktreePath)) { + showSessionMessage(`Cannot ${actionLabel}: worktree is no longer on disk: ${worktreePath}`); + return ''; + } + return worktreePath; +} + +function runSessionTerminalCommand(session, actionLabel, iconId, commandText) { + const worktreePath = ensureSessionWorktree(session, actionLabel.toLowerCase()); + if (!worktreePath) { + return; + } + + const terminal = vscode.window.createTerminal({ + name: `GitGuardex ${actionLabel}: ${sessionDisplayLabel(session)}`, + cwd: worktreePath, + iconPath: new vscode.ThemeIcon(iconId), + }); + terminal.show(); + terminal.sendText(commandText, true); +} + +function finishSession(session) { + if (!session?.branch) { + showSessionMessage('Cannot finish session: missing branch name.'); + return; + } + runSessionTerminalCommand( + session, + 'Finish', + 'check', + `gx branch finish --branch ${shellQuote(session.branch)}`, + ); +} + +function syncSession(session) { + runSessionTerminalCommand(session, 'Sync', 'sync', 'gx sync'); +} + +async function stopSession(session, refresh) { + const pid = Number(session?.pid); + if (!Number.isInteger(pid) || pid <= 0) { + showSessionMessage('Cannot stop session: missing pid.'); + return; + } + + const confirmed = await vscode.window.showWarningMessage( + `Stop ${sessionDisplayLabel(session)}?`, + { modal: true, detail: `Send SIGTERM to pid ${pid}.` }, + 'Stop', + ); + if (confirmed !== 'Stop') { + return; + } + + try { + process.kill(pid, 'SIGTERM'); + refresh(); + } catch (error) { + showSessionMessage( + `Failed to stop session ${sessionDisplayLabel(session)}: ${error?.message || String(error)}`, + ); + } +} + +async function openSessionDiff(session) { + const worktreePath = ensureSessionWorktree(session, 'open diff'); + if (!worktreePath) { + return; + } + + let diffOutput = ''; + try { + diffOutput = cp.execFileSync('git', ['-C', worktreePath, 'diff'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + const detail = [ + error?.stdout, + error?.stderr, + error?.message, + ].find((value) => typeof value === 'string' && value.trim().length > 0) || 'git diff failed.'; + showSessionMessage(`Failed to open diff for ${sessionDisplayLabel(session)}: ${detail.trim()}`); + return; + } + + const document = await vscode.workspace.openTextDocument({ + language: 'diff', + content: diffOutput, + }); + await vscode.window.showTextDocument(document, { preview: false }); +} + function repoRootFromSessionFile(filePath) { return path.resolve(path.dirname(filePath), '..', '..', '..'); } @@ -339,6 +457,10 @@ function activate(context) { await vscode.commands.executeCommand('vscode.open', vscode.Uri.file(change.absolutePath)); }), + vscode.commands.registerCommand('gitguardex.activeAgents.finishSession', finishSession), + vscode.commands.registerCommand('gitguardex.activeAgents.syncSession', syncSession), + vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => stopSession(session, refresh)), + vscode.commands.registerCommand('gitguardex.activeAgents.openSessionDiff', openSessionDiff), vscode.workspace.onDidChangeWorkspaceFolders(refresh), watcher, { dispose: () => clearInterval(interval) }, diff --git a/templates/vscode/guardex-active-agents/package.json b/templates/vscode/guardex-active-agents/package.json index da83ad5..d38c39e 100644 --- a/templates/vscode/guardex-active-agents/package.json +++ b/templates/vscode/guardex-active-agents/package.json @@ -26,6 +26,26 @@ { "command": "gitguardex.activeAgents.openWorktree", "title": "Open Agent Worktree" + }, + { + "command": "gitguardex.activeAgents.finishSession", + "title": "Finish", + "icon": "$(check)" + }, + { + "command": "gitguardex.activeAgents.syncSession", + "title": "Sync", + "icon": "$(sync)" + }, + { + "command": "gitguardex.activeAgents.stopSession", + "title": "Stop", + "icon": "$(debug-stop)" + }, + { + "command": "gitguardex.activeAgents.openSessionDiff", + "title": "Open Diff", + "icon": "$(diff)" } ], "views": { @@ -50,6 +70,26 @@ "command": "gitguardex.activeAgents.openWorktree", "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", "group": "inline" + }, + { + "command": "gitguardex.activeAgents.finishSession", + "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", + "group": "inline" + }, + { + "command": "gitguardex.activeAgents.syncSession", + "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", + "group": "inline" + }, + { + "command": "gitguardex.activeAgents.stopSession", + "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", + "group": "inline" + }, + { + "command": "gitguardex.activeAgents.openSessionDiff", + "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", + "group": "inline" } ] } diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index 2687e65..87ca74d 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -63,6 +63,13 @@ function createMockVscode(tempRoot) { const registrations = { providers: [], treeViews: [], + commands: new Map(), + executedCommands: [], + terminals: [], + openedDocuments: [], + shownDocuments: [], + infoMessages: [], + warningMessages: [], }; class TreeItem { @@ -105,14 +112,46 @@ function createMockVscode(tempRoot) { Expanded: 1, }, commands: { - executeCommand: async () => {}, - registerCommand: () => disposable(), + executeCommand: async (...args) => { + registrations.executedCommands.push(args); + }, + registerCommand: (command, handler) => { + registrations.commands.set(command, handler); + return disposable(); + }, }, Uri: { file: (fsPath) => ({ fsPath }), }, window: { - showInformationMessage: async () => {}, + showInformationMessage: async (...args) => { + registrations.infoMessages.push(args); + return undefined; + }, + showWarningMessage: async (...args) => { + registrations.warningMessages.push(args); + return undefined; + }, + createTerminal: (options) => { + const terminal = { + options, + shown: false, + sentTexts: [], + show() { + this.shown = true; + }, + sendText(text, addNewLine) { + this.sentTexts.push({ text, addNewLine }); + }, + dispose() {}, + }; + registrations.terminals.push(terminal); + return terminal; + }, + showTextDocument: async (document, options) => { + registrations.shownDocuments.push({ document, options }); + return { document }; + }, createTreeView: (viewId, options) => { const treeView = { viewId, @@ -131,6 +170,14 @@ function createMockVscode(tempRoot) { }, }, workspace: { + openTextDocument: async (options) => { + const document = { + ...options, + uri: { scheme: 'untitled' }, + }; + registrations.openedDocuments.push(document); + return document; + }, createFileSystemWatcher: () => fileWatcher, findFiles: async () => [], onDidChangeWorkspaceFolders: () => disposable(), @@ -529,3 +576,140 @@ test('active-agents extension splits working and thinking sessions into separate 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-')); + initGitRepo(worktreePath); + fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(worktreePath, ['add', 'tracked.txt']); + runGit(worktreePath, ['commit', '-m', 'baseline']); + + const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, 'agent/codex/live-task'); + fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); + fs.writeFileSync( + sessionPath, + `${JSON.stringify(sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/live-task', + taskName: 'live-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 [thinkingSection] = await provider.getChildren(agentsSection); + const [sessionItem] = await provider.getChildren(thinkingSection); + + await registrations.commands.get('gitguardex.activeAgents.finishSession')(sessionItem.session); + await registrations.commands.get('gitguardex.activeAgents.syncSession')(sessionItem.session); + + assert.equal(registrations.terminals.length, 2); + assert.equal(registrations.terminals[0].options.cwd, worktreePath); + assert.equal(registrations.terminals[0].options.iconPath.id, 'check'); + assert.match(registrations.terminals[0].options.name, /GitGuardex Finish: live-task/); + assert.deepEqual(registrations.terminals[0].sentTexts, [ + { text: "gx branch finish --branch 'agent/codex/live-task'", addNewLine: true }, + ]); + assert.equal(registrations.terminals[0].shown, true); + + assert.equal(registrations.terminals[1].options.cwd, worktreePath); + assert.equal(registrations.terminals[1].options.iconPath.id, 'sync'); + assert.match(registrations.terminals[1].options.name, /GitGuardex Sync: live-task/); + assert.deepEqual(registrations.terminals[1].sentTexts, [ + { text: 'gx sync', addNewLine: true }, + ]); + assert.equal(registrations.terminals[1].shown, true); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + +test('active-agents extension confirms stop and sends SIGTERM to the session pid', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-stop-session-')); + const { registrations, vscode } = createMockVscode(tempRoot); + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + let refreshCount = 0; + let killed = null; + const originalKill = process.kill; + + vscode.window.showWarningMessage = async (...args) => { + registrations.warningMessages.push(args); + return 'Stop'; + }; + process.kill = (pid, signal) => { + killed = { pid, signal }; + }; + + try { + extension.activate(context); + const provider = registrations.providers[0].provider; + const originalRefresh = provider.refresh.bind(provider); + provider.refresh = () => { + refreshCount += 1; + return originalRefresh(); + }; + + await registrations.commands.get('gitguardex.activeAgents.stopSession')({ + label: 'live-task', + pid: 4242, + }); + } finally { + process.kill = originalKill; + } + + assert.deepEqual(killed, { pid: 4242, signal: 'SIGTERM' }); + assert.equal(refreshCount, 1); + assert.equal(registrations.warningMessages.length, 1); + assert.match(registrations.warningMessages[0][0], /Stop live-task\?/); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + +test('active-agents extension opens git diff output in an untitled editor', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-open-diff-')); + const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-open-diff-worktree-')); + 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'); + + const { registrations, vscode } = createMockVscode(tempRoot); + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + extension.activate(context); + + await registrations.commands.get('gitguardex.activeAgents.openSessionDiff')({ + label: 'live-task', + worktreePath, + }); + + assert.equal(registrations.openedDocuments.length, 1); + assert.equal(registrations.openedDocuments[0].language, 'diff'); + assert.match(registrations.openedDocuments[0].content, /^diff --git /); + assert.equal(registrations.shownDocuments.length, 1); + assert.equal(registrations.shownDocuments[0].document.uri.scheme, 'untitled'); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index a375c52..a0e3fbc 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -1,5 +1,6 @@ const fs = require('node:fs'); const path = require('node:path'); +const cp = require('node:child_process'); const vscode = require('vscode'); const { formatElapsedFrom, readActiveSessions, readRepoChanges } = require('./session-schema.js'); @@ -110,6 +111,123 @@ class ChangeItem extends vscode.TreeItem { } } +function shellQuote(value) { + const normalized = String(value || ''); + return `'${normalized.replace(/'/g, "'\"'\"'")}'`; +} + +function sessionDisplayLabel(session) { + return session?.taskName || session?.label || session?.branch || path.basename(session?.worktreePath || '') || 'session'; +} + +function sessionWorktreePath(session) { + return typeof session?.worktreePath === 'string' ? session.worktreePath.trim() : ''; +} + +function showSessionMessage(message) { + vscode.window.showInformationMessage?.(message); +} + +function ensureSessionWorktree(session, actionLabel) { + const worktreePath = sessionWorktreePath(session); + if (!worktreePath) { + showSessionMessage(`Cannot ${actionLabel}: missing worktree path.`); + return ''; + } + if (!fs.existsSync(worktreePath)) { + showSessionMessage(`Cannot ${actionLabel}: worktree is no longer on disk: ${worktreePath}`); + return ''; + } + return worktreePath; +} + +function runSessionTerminalCommand(session, actionLabel, iconId, commandText) { + const worktreePath = ensureSessionWorktree(session, actionLabel.toLowerCase()); + if (!worktreePath) { + return; + } + + const terminal = vscode.window.createTerminal({ + name: `GitGuardex ${actionLabel}: ${sessionDisplayLabel(session)}`, + cwd: worktreePath, + iconPath: new vscode.ThemeIcon(iconId), + }); + terminal.show(); + terminal.sendText(commandText, true); +} + +function finishSession(session) { + if (!session?.branch) { + showSessionMessage('Cannot finish session: missing branch name.'); + return; + } + runSessionTerminalCommand( + session, + 'Finish', + 'check', + `gx branch finish --branch ${shellQuote(session.branch)}`, + ); +} + +function syncSession(session) { + runSessionTerminalCommand(session, 'Sync', 'sync', 'gx sync'); +} + +async function stopSession(session, refresh) { + const pid = Number(session?.pid); + if (!Number.isInteger(pid) || pid <= 0) { + showSessionMessage('Cannot stop session: missing pid.'); + return; + } + + const confirmed = await vscode.window.showWarningMessage( + `Stop ${sessionDisplayLabel(session)}?`, + { modal: true, detail: `Send SIGTERM to pid ${pid}.` }, + 'Stop', + ); + if (confirmed !== 'Stop') { + return; + } + + try { + process.kill(pid, 'SIGTERM'); + refresh(); + } catch (error) { + showSessionMessage( + `Failed to stop session ${sessionDisplayLabel(session)}: ${error?.message || String(error)}`, + ); + } +} + +async function openSessionDiff(session) { + const worktreePath = ensureSessionWorktree(session, 'open diff'); + if (!worktreePath) { + return; + } + + let diffOutput = ''; + try { + diffOutput = cp.execFileSync('git', ['-C', worktreePath, 'diff'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + const detail = [ + error?.stdout, + error?.stderr, + error?.message, + ].find((value) => typeof value === 'string' && value.trim().length > 0) || 'git diff failed.'; + showSessionMessage(`Failed to open diff for ${sessionDisplayLabel(session)}: ${detail.trim()}`); + return; + } + + const document = await vscode.workspace.openTextDocument({ + language: 'diff', + content: diffOutput, + }); + await vscode.window.showTextDocument(document, { preview: false }); +} + function repoRootFromSessionFile(filePath) { return path.resolve(path.dirname(filePath), '..', '..', '..'); } @@ -339,6 +457,10 @@ function activate(context) { await vscode.commands.executeCommand('vscode.open', vscode.Uri.file(change.absolutePath)); }), + vscode.commands.registerCommand('gitguardex.activeAgents.finishSession', finishSession), + vscode.commands.registerCommand('gitguardex.activeAgents.syncSession', syncSession), + vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => stopSession(session, refresh)), + vscode.commands.registerCommand('gitguardex.activeAgents.openSessionDiff', openSessionDiff), vscode.workspace.onDidChangeWorkspaceFolders(refresh), watcher, { dispose: () => clearInterval(interval) }, diff --git a/vscode/guardex-active-agents/package.json b/vscode/guardex-active-agents/package.json index da83ad5..d38c39e 100644 --- a/vscode/guardex-active-agents/package.json +++ b/vscode/guardex-active-agents/package.json @@ -26,6 +26,26 @@ { "command": "gitguardex.activeAgents.openWorktree", "title": "Open Agent Worktree" + }, + { + "command": "gitguardex.activeAgents.finishSession", + "title": "Finish", + "icon": "$(check)" + }, + { + "command": "gitguardex.activeAgents.syncSession", + "title": "Sync", + "icon": "$(sync)" + }, + { + "command": "gitguardex.activeAgents.stopSession", + "title": "Stop", + "icon": "$(debug-stop)" + }, + { + "command": "gitguardex.activeAgents.openSessionDiff", + "title": "Open Diff", + "icon": "$(diff)" } ], "views": { @@ -50,6 +70,26 @@ "command": "gitguardex.activeAgents.openWorktree", "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", "group": "inline" + }, + { + "command": "gitguardex.activeAgents.finishSession", + "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", + "group": "inline" + }, + { + "command": "gitguardex.activeAgents.syncSession", + "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", + "group": "inline" + }, + { + "command": "gitguardex.activeAgents.stopSession", + "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", + "group": "inline" + }, + { + "command": "gitguardex.activeAgents.openSessionDiff", + "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", + "group": "inline" } ] }