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" } ] }