s9n-devops-agent 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,6 +23,7 @@ An intelligent Git automation system with multi-agent support, session managemen
23
23
  - **⚙️ VS Code Integration**: Seamlessly integrates with VS Code tasks
24
24
  - **🔐 Safe Concurrent Development**: Multiple agents can work simultaneously without conflicts
25
25
  - **🏷️ Smart Branching**: Automatic branch creation with configurable naming patterns
26
+ - **🐋 Docker Auto-Restart**: Automatically restart Docker containers after code push (v1.2.0)
26
27
 
27
28
  ## Installation 📦
28
29
 
@@ -96,9 +97,9 @@ s9n-devops-agent start
96
97
  s9n-devops-agent create --task "implement-api"
97
98
  ```
98
99
 
99
- ### Working with AI Development Agents
100
+ ### Working with AI Assistants
100
101
 
101
- When you start a session, you'll receive instructions to paste into your AI development agent (Claude, Cursor, Cline, GitHub Copilot, etc.):
102
+ When you start a session, you'll receive instructions to paste into your AI assistant:
102
103
 
103
104
  ```
104
105
  I'm working in a DevOps-managed session with the following setup:
@@ -186,9 +187,9 @@ Configuration is stored in:
186
187
  # Enter task: implement-authentication
187
188
  ```
188
189
 
189
- 2. **Copy Instructions to AI Development Agent**:
190
- - The tool provides instructions to paste into your AI agent (Claude, Cursor, Cline, etc.)
191
- - The AI agent works in the isolated worktree
190
+ 2. **Copy Instructions to AI Assistant**:
191
+ - The tool provides instructions to paste into Claude/Copilot
192
+ - The AI works in the isolated worktree
192
193
 
193
194
  3. **AI Makes Changes**:
194
195
  - AI writes code in the worktree directory
@@ -226,6 +227,30 @@ Add to `.vscode/tasks.json`:
226
227
 
227
228
  ## Advanced Features 🔬
228
229
 
230
+ ### Docker Container Auto-Restart (v1.2.0+)
231
+
232
+ Automatically restart Docker containers after pushing code changes:
233
+
234
+ ```bash
235
+ # During session creation, if docker-compose is detected:
236
+ s9n-devops-agent start
237
+ # -> Auto-restart Docker containers after push? (y/N): y
238
+ # -> Rebuild containers on restart? (y/N): n
239
+ # -> Specific service to restart (leave empty for all): app
240
+ ```
241
+
242
+ **Features:**
243
+ - Detects docker-compose files automatically
244
+ - Optional container rebuild on restart
245
+ - Target specific services or restart all
246
+ - Non-blocking: Docker failures don't affect git workflow
247
+ - Works with Docker Compose v1 and v2
248
+
249
+ **Supported docker-compose files:**
250
+ - docker-compose.yml / docker-compose.yaml
251
+ - compose.yml / compose.yaml
252
+ - docker-compose.dev.yml / docker-compose.local.yml
253
+
229
254
  ### Multi-Agent Collaboration
230
255
 
231
256
  Multiple AI assistants can work on different features simultaneously:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s9n-devops-agent",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "CS_DevOpsAgent - Intelligent Git Automation System with multi-agent support and session management",
5
5
  "type": "module",
6
6
  "main": "src/cs-devops-agent-worker.js",
@@ -113,6 +113,8 @@ import chokidar from "chokidar";
113
113
  import { execa } from "execa";
114
114
  import readline from "node:readline";
115
115
  import { stdin as input, stdout as output } from 'node:process';
116
+ import { execSync } from 'child_process';
117
+ import { restartDockerContainers } from './docker-utils.js';
116
118
 
117
119
  // ============================================================================
118
120
  // CONFIGURATION SECTION - All settings can be overridden via environment vars
@@ -762,7 +764,12 @@ async function commitOnce(repoRoot, msgPath) {
762
764
  if (PUSH) {
763
765
  const ok = await pushBranch(BRANCH);
764
766
  log(`push ${ok ? "ok" : "failed"}`);
765
- if (ok && CLEAR_MSG_WHEN === "push") clearMsgFile(msgPath);
767
+ if (ok) {
768
+ if (CLEAR_MSG_WHEN === "push") clearMsgFile(msgPath);
769
+
770
+ // Handle Docker restart if configured
771
+ await handleDockerRestart();
772
+ }
766
773
  }
767
774
  } finally {
768
775
  busy = false;
@@ -782,6 +789,52 @@ function schedule(repoRoot, msgPath) {
782
789
  }, QUIET_MS);
783
790
  }
784
791
 
792
+ // ============================================================================
793
+ // DOCKER CONTAINER MANAGEMENT
794
+ // ============================================================================
795
+
796
+ /**
797
+ * Handle Docker container restart if configured in session
798
+ */
799
+ async function handleDockerRestart() {
800
+ // Check if we're in a session with Docker configuration
801
+ const sessionConfigPath = path.join(process.cwd(), '.devops-session.json');
802
+ if (!fs.existsSync(sessionConfigPath)) {
803
+ return; // Not in a managed session
804
+ }
805
+
806
+ try {
807
+ const sessionConfig = JSON.parse(fs.readFileSync(sessionConfigPath, 'utf8'));
808
+
809
+ // Check if Docker restart is enabled
810
+ if (!sessionConfig.dockerConfig || !sessionConfig.dockerConfig.enabled) {
811
+ return;
812
+ }
813
+
814
+ log('🐋 Docker restart configured - initiating container restart...');
815
+
816
+ const dockerOptions = {
817
+ projectPath: process.cwd(),
818
+ composeFile: sessionConfig.dockerConfig.composeFile,
819
+ serviceName: sessionConfig.dockerConfig.service,
820
+ rebuild: sessionConfig.dockerConfig.rebuild,
821
+ forceRecreate: sessionConfig.dockerConfig.forceRecreate || false,
822
+ detach: true
823
+ };
824
+
825
+ const result = await restartDockerContainers(dockerOptions);
826
+
827
+ if (result.success) {
828
+ log('✅ Docker containers restarted successfully');
829
+ } else {
830
+ log(`⚠️ Docker restart failed: ${result.error}`);
831
+ }
832
+ } catch (error) {
833
+ // Don't fail the commit/push if Docker restart fails
834
+ dlog('Docker restart error:', error.message);
835
+ }
836
+ }
837
+
785
838
  // ============================================================================
786
839
  // INFRASTRUCTURE CHANGE DETECTION
787
840
  // ============================================================================
@@ -1604,17 +1657,71 @@ console.log();
1604
1657
  fs.unlinkSync(msgPath);
1605
1658
  }
1606
1659
 
1607
- // Create cleanup marker if in a session
1660
+ // Handle worktree cleanup if in a session
1608
1661
  if (sessionId) {
1609
- const cleanupMarker = path.join(process.cwd(), '.session-cleanup-requested');
1610
- fs.writeFileSync(cleanupMarker, JSON.stringify({
1611
- sessionId: sessionId,
1612
- timestamp: new Date().toISOString(),
1613
- branch: await currentBranch(),
1614
- worktree: process.cwd()
1615
- }, null, 2));
1616
- console.log("\n✓ Session cleanup complete.");
1617
- console.log("Run 'npm run devops:close' from the main repo to remove the worktree.");
1662
+ const currentDir = process.cwd();
1663
+ const isWorktree = currentDir.includes('local_deploy/worktrees');
1664
+
1665
+ if (isWorktree) {
1666
+ console.log("\n" + "=".repeat(60));
1667
+ console.log("WORKTREE CLEANUP");
1668
+ console.log("=".repeat(60));
1669
+ console.log("\nThis session is running in a worktree:");
1670
+ console.log(` ${currentDir}`);
1671
+ console.log("\nWould you like to remove this worktree now?");
1672
+ console.log(" y/yes - Remove worktree and close session");
1673
+ console.log(" n/no - Keep worktree for later use");
1674
+ console.log(" (You can remove it later with: node src/session-coordinator.js close " + sessionId + ")");
1675
+
1676
+ rl.prompt();
1677
+ const cleanupAnswer = await new Promise(resolve => {
1678
+ rl.once('line', resolve);
1679
+ });
1680
+
1681
+ if (cleanupAnswer.toLowerCase() === 'y' || cleanupAnswer.toLowerCase() === 'yes') {
1682
+ console.log("\nRemoving worktree...");
1683
+
1684
+ // Get the main repo root (parent of worktree)
1685
+ const repoRoot = path.resolve(currentDir, '../../../');
1686
+
1687
+ try {
1688
+ // Switch to main repo directory first
1689
+ process.chdir(repoRoot);
1690
+
1691
+ // Remove the worktree
1692
+ execSync(`git worktree remove "${currentDir}" --force`, { stdio: 'inherit' });
1693
+ console.log("✓ Worktree removed successfully");
1694
+
1695
+ // Remove session lock file
1696
+ const lockFile = path.join(repoRoot, 'local_deploy', 'session-locks', `${sessionId}.lock`);
1697
+ if (fs.existsSync(lockFile)) {
1698
+ fs.unlinkSync(lockFile);
1699
+ console.log("✓ Session closed");
1700
+ }
1701
+ } catch (err) {
1702
+ console.error("\nCould not remove worktree automatically.");
1703
+ console.error("Error: " + err.message);
1704
+ console.log("\nTo remove it manually, run from the main repo:");
1705
+ console.log(` git worktree remove "${currentDir}" --force`);
1706
+ console.log(" OR");
1707
+ console.log(` node src/session-coordinator.js close ${sessionId}`);
1708
+ }
1709
+ } else {
1710
+ // Create cleanup marker for later
1711
+ const cleanupMarker = path.join(currentDir, '.session-cleanup-requested');
1712
+ fs.writeFileSync(cleanupMarker, JSON.stringify({
1713
+ sessionId: sessionId,
1714
+ timestamp: new Date().toISOString(),
1715
+ branch: await currentBranch(),
1716
+ worktree: currentDir
1717
+ }, null, 2));
1718
+ console.log("\n✓ Session marked for later cleanup.");
1719
+ console.log("To remove the worktree later, run from the main repo:");
1720
+ console.log(` node src/session-coordinator.js close ${sessionId}`);
1721
+ }
1722
+ } else {
1723
+ console.log("\n✓ Session cleanup complete.");
1724
+ }
1618
1725
  }
1619
1726
 
1620
1727
  console.log("\nGoodbye!");
@@ -0,0 +1,290 @@
1
+ /**
2
+ * ============================================================================
3
+ * DOCKER UTILITIES FOR DEVOPS AGENT
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Provides Docker container management functionality for automatic restarts
8
+ * after code pushes. Integrates with the DevOps agent session workflow.
9
+ *
10
+ * ============================================================================
11
+ */
12
+
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+ import { execa } from 'execa';
16
+
17
+ /**
18
+ * Check if Docker is installed and running
19
+ */
20
+ export async function isDockerAvailable() {
21
+ try {
22
+ await execa('docker', ['version'], { stdio: 'pipe' });
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Check if docker-compose is available
31
+ */
32
+ export async function isDockerComposeAvailable() {
33
+ try {
34
+ // Try docker compose (v2)
35
+ await execa('docker', ['compose', 'version'], { stdio: 'pipe' });
36
+ return 'docker compose';
37
+ } catch {
38
+ try {
39
+ // Try docker-compose (v1)
40
+ await execa('docker-compose', ['version'], { stdio: 'pipe' });
41
+ return 'docker-compose';
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Find docker-compose files in the project
50
+ */
51
+ export function findDockerComposeFiles(projectPath) {
52
+ const possibleFiles = [
53
+ 'docker-compose.yml',
54
+ 'docker-compose.yaml',
55
+ 'compose.yml',
56
+ 'compose.yaml',
57
+ 'docker-compose.dev.yml',
58
+ 'docker-compose.dev.yaml',
59
+ 'docker-compose.local.yml',
60
+ 'docker-compose.local.yaml'
61
+ ];
62
+
63
+ const found = [];
64
+ for (const file of possibleFiles) {
65
+ const filePath = path.join(projectPath, file);
66
+ if (fs.existsSync(filePath)) {
67
+ found.push({
68
+ name: file,
69
+ path: filePath
70
+ });
71
+ }
72
+ }
73
+
74
+ return found;
75
+ }
76
+
77
+ /**
78
+ * Check if project has Docker configuration
79
+ */
80
+ export function hasDockerConfiguration(projectPath) {
81
+ const composeFiles = findDockerComposeFiles(projectPath);
82
+ const dockerfilePath = path.join(projectPath, 'Dockerfile');
83
+
84
+ return {
85
+ hasCompose: composeFiles.length > 0,
86
+ hasDockerfile: fs.existsSync(dockerfilePath),
87
+ composeFiles: composeFiles
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Restart Docker containers
93
+ */
94
+ export async function restartDockerContainers(options = {}) {
95
+ const {
96
+ projectPath = process.cwd(),
97
+ composeFile = null,
98
+ serviceName = null,
99
+ forceRecreate = false,
100
+ rebuild = false,
101
+ detach = true
102
+ } = options;
103
+
104
+ console.log('\n🐋 Docker Container Restart');
105
+ console.log('━'.repeat(50));
106
+
107
+ try {
108
+ // Check if Docker is available
109
+ const dockerCmd = await isDockerComposeAvailable();
110
+ if (!dockerCmd) {
111
+ throw new Error('Docker Compose is not available');
112
+ }
113
+
114
+ // Determine compose file to use
115
+ let composeFilePath = composeFile;
116
+ if (!composeFilePath) {
117
+ const dockerConfig = hasDockerConfiguration(projectPath);
118
+ if (dockerConfig.hasCompose && dockerConfig.composeFiles.length > 0) {
119
+ composeFilePath = dockerConfig.composeFiles[0].path;
120
+ } else {
121
+ throw new Error('No docker-compose file found');
122
+ }
123
+ }
124
+
125
+ // Build command arguments
126
+ const isV2 = dockerCmd === 'docker compose';
127
+ const baseCmd = isV2 ? ['docker', 'compose'] : ['docker-compose'];
128
+ const fileArgs = ['-f', composeFilePath];
129
+
130
+ // Stop containers
131
+ console.log('📦 Stopping containers...');
132
+ const stopArgs = [...fileArgs, 'stop'];
133
+ if (serviceName) stopArgs.push(serviceName);
134
+
135
+ const stopResult = await execa(baseCmd[0], [...baseCmd.slice(1), ...stopArgs], {
136
+ cwd: projectPath,
137
+ stdio: 'inherit'
138
+ });
139
+
140
+ // Optionally rebuild
141
+ if (rebuild) {
142
+ console.log('🔨 Rebuilding containers...');
143
+ const buildArgs = [...fileArgs, 'build'];
144
+ if (serviceName) buildArgs.push(serviceName);
145
+
146
+ await execa(baseCmd[0], [...baseCmd.slice(1), ...buildArgs], {
147
+ cwd: projectPath,
148
+ stdio: 'inherit'
149
+ });
150
+ }
151
+
152
+ // Start containers
153
+ console.log('🚀 Starting containers...');
154
+ const upArgs = [...fileArgs, 'up'];
155
+ if (forceRecreate) upArgs.push('--force-recreate');
156
+ if (detach) upArgs.push('-d');
157
+ if (serviceName) upArgs.push(serviceName);
158
+
159
+ await execa(baseCmd[0], [...baseCmd.slice(1), ...upArgs], {
160
+ cwd: projectPath,
161
+ stdio: 'inherit'
162
+ });
163
+
164
+ console.log('✅ Docker containers restarted successfully');
165
+ console.log('━'.repeat(50));
166
+ return { success: true };
167
+
168
+ } catch (error) {
169
+ console.error('❌ Failed to restart Docker containers:', error.message);
170
+ console.log('━'.repeat(50));
171
+ return { success: false, error: error.message };
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Get Docker container status
177
+ */
178
+ export async function getDockerStatus(projectPath = process.cwd(), composeFile = null) {
179
+ try {
180
+ const dockerCmd = await isDockerComposeAvailable();
181
+ if (!dockerCmd) {
182
+ return { running: false, services: [] };
183
+ }
184
+
185
+ let composeFilePath = composeFile;
186
+ if (!composeFilePath) {
187
+ const dockerConfig = hasDockerConfiguration(projectPath);
188
+ if (dockerConfig.hasCompose && dockerConfig.composeFiles.length > 0) {
189
+ composeFilePath = dockerConfig.composeFiles[0].path;
190
+ } else {
191
+ return { running: false, services: [] };
192
+ }
193
+ }
194
+
195
+ const isV2 = dockerCmd === 'docker compose';
196
+ const baseCmd = isV2 ? ['docker', 'compose'] : ['docker-compose'];
197
+ const psArgs = ['-f', composeFilePath, 'ps', '--format', 'json'];
198
+
199
+ const result = await execa(baseCmd[0], [...baseCmd.slice(1), ...psArgs], {
200
+ cwd: projectPath,
201
+ stdio: 'pipe'
202
+ });
203
+
204
+ // Parse the JSON output
205
+ const services = [];
206
+ if (result.stdout) {
207
+ const lines = result.stdout.split('\n').filter(line => line.trim());
208
+ for (const line of lines) {
209
+ try {
210
+ const service = JSON.parse(line);
211
+ services.push({
212
+ name: service.Service || service.Name,
213
+ state: service.State,
214
+ status: service.Status,
215
+ ports: service.Ports
216
+ });
217
+ } catch {
218
+ // Skip unparseable lines
219
+ }
220
+ }
221
+ }
222
+
223
+ return {
224
+ running: services.some(s => s.state === 'running'),
225
+ services: services
226
+ };
227
+
228
+ } catch (error) {
229
+ return { running: false, services: [], error: error.message };
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Monitor Docker logs
235
+ */
236
+ export async function tailDockerLogs(options = {}) {
237
+ const {
238
+ projectPath = process.cwd(),
239
+ composeFile = null,
240
+ serviceName = null,
241
+ lines = 50,
242
+ follow = true
243
+ } = options;
244
+
245
+ try {
246
+ const dockerCmd = await isDockerComposeAvailable();
247
+ if (!dockerCmd) {
248
+ throw new Error('Docker Compose is not available');
249
+ }
250
+
251
+ let composeFilePath = composeFile;
252
+ if (!composeFilePath) {
253
+ const dockerConfig = hasDockerConfiguration(projectPath);
254
+ if (dockerConfig.hasCompose && dockerConfig.composeFiles.length > 0) {
255
+ composeFilePath = dockerConfig.composeFiles[0].path;
256
+ } else {
257
+ throw new Error('No docker-compose file found');
258
+ }
259
+ }
260
+
261
+ const isV2 = dockerCmd === 'docker compose';
262
+ const baseCmd = isV2 ? ['docker', 'compose'] : ['docker-compose'];
263
+ const logsArgs = ['-f', composeFilePath, 'logs'];
264
+
265
+ if (lines) logsArgs.push('--tail', lines.toString());
266
+ if (follow) logsArgs.push('-f');
267
+ if (serviceName) logsArgs.push(serviceName);
268
+
269
+ const proc = execa(baseCmd[0], [...baseCmd.slice(1), ...logsArgs], {
270
+ cwd: projectPath,
271
+ stdio: 'inherit'
272
+ });
273
+
274
+ return proc;
275
+
276
+ } catch (error) {
277
+ console.error('Failed to tail Docker logs:', error.message);
278
+ return null;
279
+ }
280
+ }
281
+
282
+ export default {
283
+ isDockerAvailable,
284
+ isDockerComposeAvailable,
285
+ findDockerComposeFiles,
286
+ hasDockerConfiguration,
287
+ restartDockerContainers,
288
+ getDockerStatus,
289
+ tailDockerLogs
290
+ };
@@ -19,11 +19,12 @@
19
19
 
20
20
  import fs from 'fs';
21
21
  import path from 'path';
22
- import { execSync, spawn, fork } from 'child_process';
23
22
  import { fileURLToPath } from 'url';
24
23
  import { dirname } from 'path';
24
+ import { execSync, spawn, fork } from 'child_process';
25
25
  import crypto from 'crypto';
26
26
  import readline from 'readline';
27
+ import { hasDockerConfiguration } from './docker-utils.js';
27
28
 
28
29
  const __filename = fileURLToPath(import.meta.url);
29
30
  const __dirname = dirname(__filename);
@@ -367,7 +368,91 @@ class SessionCoordinator {
367
368
  }
368
369
 
369
370
  /**
370
- * Prompt for merge configuration
371
+ * Prompt for Docker restart configuration
372
+ */
373
+ async promptForDockerConfig() {
374
+ const rl = readline.createInterface({
375
+ input: process.stdin,
376
+ output: process.stdout
377
+ });
378
+
379
+ // Ask if they want automatic Docker restarts
380
+ const autoRestart = await new Promise((resolve) => {
381
+ rl.question('\nAuto-restart Docker containers after push? (y/N): ', (answer) => {
382
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
383
+ });
384
+ });
385
+
386
+ if (!autoRestart) {
387
+ rl.close();
388
+ return { enabled: false };
389
+ }
390
+
391
+ // Ask which compose file to use if multiple
392
+ const dockerInfo = hasDockerConfiguration(process.cwd());
393
+ let selectedComposeFile = null;
394
+
395
+ if (dockerInfo.composeFiles.length > 1) {
396
+ console.log(`\n${CONFIG.colors.bright}Select docker-compose file:${CONFIG.colors.reset}`);
397
+ dockerInfo.composeFiles.forEach((file, index) => {
398
+ console.log(` ${index + 1}) ${file.name}`);
399
+ });
400
+
401
+ const fileChoice = await new Promise((resolve) => {
402
+ rl.question(`Choose file (1-${dockerInfo.composeFiles.length}) [1]: `, (answer) => {
403
+ const choice = parseInt(answer) || 1;
404
+ if (choice >= 1 && choice <= dockerInfo.composeFiles.length) {
405
+ resolve(dockerInfo.composeFiles[choice - 1]);
406
+ } else {
407
+ resolve(dockerInfo.composeFiles[0]);
408
+ }
409
+ });
410
+ });
411
+
412
+ selectedComposeFile = fileChoice.path;
413
+ } else if (dockerInfo.composeFiles.length === 1) {
414
+ selectedComposeFile = dockerInfo.composeFiles[0].path;
415
+ }
416
+
417
+ // Ask about rebuild preference
418
+ const rebuild = await new Promise((resolve) => {
419
+ rl.question('\nRebuild containers on restart? (y/N): ', (answer) => {
420
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
421
+ });
422
+ });
423
+
424
+ // Ask about specific service
425
+ const specificService = await new Promise((resolve) => {
426
+ rl.question('\nSpecific service to restart (leave empty for all): ', (answer) => {
427
+ resolve(answer.trim() || null);
428
+ });
429
+ });
430
+
431
+ rl.close();
432
+
433
+ const config = {
434
+ enabled: true,
435
+ composeFile: selectedComposeFile,
436
+ rebuild: rebuild,
437
+ service: specificService,
438
+ forceRecreate: false
439
+ };
440
+
441
+ console.log(`\n${CONFIG.colors.green}✓${CONFIG.colors.reset} Docker restart configuration:`);
442
+ console.log(` ${CONFIG.colors.bright}Auto-restart:${CONFIG.colors.reset} Enabled`);
443
+ if (selectedComposeFile) {
444
+ console.log(` ${CONFIG.colors.bright}Compose file:${CONFIG.colors.reset} ${path.basename(selectedComposeFile)}`);
445
+ }
446
+ console.log(` ${CONFIG.colors.bright}Rebuild:${CONFIG.colors.reset} ${rebuild ? 'Yes' : 'No'}`);
447
+ if (specificService) {
448
+ console.log(` ${CONFIG.colors.bright}Service:${CONFIG.colors.reset} ${specificService}`);
449
+ }
450
+
451
+ return config;
452
+ }
453
+
454
+ /**
455
+ * Prompt for auto-merge configuration
371
456
  */
372
457
  async promptForMergeConfig() {
373
458
  const rl = readline.createInterface({
@@ -593,6 +678,27 @@ class SessionCoordinator {
593
678
  // Ask for auto-merge configuration
594
679
  const mergeConfig = await this.promptForMergeConfig();
595
680
 
681
+ // Check for Docker configuration and ask about restart preference
682
+ let dockerConfig = null;
683
+ const dockerInfo = hasDockerConfiguration(process.cwd());
684
+
685
+ if (dockerInfo.hasCompose || dockerInfo.hasDockerfile) {
686
+ console.log(`\n${CONFIG.colors.yellow}Docker Configuration Detected${CONFIG.colors.reset}`);
687
+
688
+ if (dockerInfo.hasCompose) {
689
+ console.log(`${CONFIG.colors.dim}Found docker-compose files:${CONFIG.colors.reset}`);
690
+ dockerInfo.composeFiles.forEach(file => {
691
+ console.log(` • ${file.name}`);
692
+ });
693
+ }
694
+
695
+ if (dockerInfo.hasDockerfile) {
696
+ console.log(`${CONFIG.colors.dim}Found Dockerfile${CONFIG.colors.reset}`);
697
+ }
698
+
699
+ dockerConfig = await this.promptForDockerConfig();
700
+ }
701
+
596
702
  // Create worktree with developer initials in the name
597
703
  const worktreeName = `${agentType}-${devInitials}-${sessionId}-${task.replace(/\s+/g, '-')}`;
598
704
  const worktreePath = path.join(this.worktreesPath, worktreeName);
@@ -615,7 +721,8 @@ class SessionCoordinator {
615
721
  status: 'active',
616
722
  pid: process.pid,
617
723
  developerInitials: devInitials,
618
- mergeConfig: mergeConfig
724
+ mergeConfig: mergeConfig,
725
+ dockerConfig: dockerConfig
619
726
  };
620
727
 
621
728
  const lockFile = path.join(this.locksPath, `${sessionId}.lock`);
@@ -629,7 +736,7 @@ class SessionCoordinator {
629
736
  fs.writeFileSync(instructionsFile, instructions.markdown);
630
737
 
631
738
  // Display instructions
632
- this.displayInstructions(instructions, sessionId, task, agentType);
739
+ this.displayInstructions(instructions, sessionId, task);
633
740
 
634
741
  // Create session config in worktree
635
742
  this.createWorktreeConfig(worktreePath, lockData);
@@ -650,7 +757,7 @@ class SessionCoordinator {
650
757
  }
651
758
 
652
759
  /**
653
- * Generate instructions for the specified AI development agent
760
+ * Generate instructions for Claude/Cline
654
761
  */
655
762
  generateClaudeInstructions(sessionData) {
656
763
  const { sessionId, worktreePath, branchName, task } = sessionData;
@@ -677,7 +784,7 @@ INSTRUCTIONS:
677
784
  - **Worktree Path:** \`${worktreePath}\`
678
785
  - **Branch:** \`${branchName}\`
679
786
 
680
- ## Instructions for ${sessionData.agentType.charAt(0).toUpperCase() + sessionData.agentType.slice(1)}
787
+ ## Instructions for Claude/Cline
681
788
 
682
789
  ### Step 1: Navigate to Your Worktree
683
790
  \`\`\`bash
@@ -731,13 +838,12 @@ The DevOps agent will automatically:
731
838
  /**
732
839
  * Display instructions in a user-friendly format
733
840
  */
734
- displayInstructions(instructions, sessionId, task, agentType = 'your AI agent') {
735
- const agentName = agentType.charAt(0).toUpperCase() + agentType.slice(1);
736
- console.log(`\n${CONFIG.colors.bgGreen}${CONFIG.colors.bright} Instructions for ${agentName} ${CONFIG.colors.reset}\n`);
841
+ displayInstructions(instructions, sessionId, task) {
842
+ console.log(`\n${CONFIG.colors.bgGreen}${CONFIG.colors.bright} Instructions for Claude/Cline ${CONFIG.colors.reset}\n`);
737
843
 
738
844
  // Clean separator
739
845
  console.log(`${CONFIG.colors.yellow}══════════════════════════════════════════════════════════════${CONFIG.colors.reset}`);
740
- console.log(`${CONFIG.colors.bright}COPY AND PASTE THIS ENTIRE BLOCK INTO ${agentName.toUpperCase()} BEFORE YOUR PROMPT:${CONFIG.colors.reset}`);
846
+ console.log(`${CONFIG.colors.bright}COPY AND PASTE THIS ENTIRE BLOCK INTO CLAUDE BEFORE YOUR PROMPT:${CONFIG.colors.reset}`);
741
847
  console.log(`${CONFIG.colors.yellow}──────────────────────────────────────────────────────────────${CONFIG.colors.reset}`);
742
848
  console.log();
743
849
 
@@ -1079,6 +1185,215 @@ The DevOps agent is monitoring this worktree for changes.
1079
1185
 
1080
1186
  return session;
1081
1187
  }
1188
+
1189
+ /**
1190
+ * Close a specific session
1191
+ */
1192
+ async closeSession(sessionId) {
1193
+ const lockFile = path.join(this.locksPath, `${sessionId}.lock`);
1194
+
1195
+ if (!fs.existsSync(lockFile)) {
1196
+ console.error(`${CONFIG.colors.red}Session not found: ${sessionId}${CONFIG.colors.reset}`);
1197
+ return false;
1198
+ }
1199
+
1200
+ const session = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
1201
+ console.log(`\n${CONFIG.colors.yellow}Closing session: ${sessionId}${CONFIG.colors.reset}`);
1202
+ console.log(`${CONFIG.colors.dim}Task: ${session.task}${CONFIG.colors.reset}`);
1203
+ console.log(`${CONFIG.colors.dim}Branch: ${session.branchName}${CONFIG.colors.reset}`);
1204
+
1205
+ // Kill agent if running
1206
+ if (session.agentPid) {
1207
+ try {
1208
+ process.kill(session.agentPid, 'SIGTERM');
1209
+ console.log(`${CONFIG.colors.green}✓${CONFIG.colors.reset} Agent process stopped`);
1210
+ } catch (err) {
1211
+ // Process might already be dead
1212
+ }
1213
+ }
1214
+
1215
+ // Check for uncommitted changes
1216
+ if (fs.existsSync(session.worktreePath)) {
1217
+ try {
1218
+ const status = execSync(`git -C "${session.worktreePath}" status --porcelain`, { encoding: 'utf8' });
1219
+ if (status.trim()) {
1220
+ console.log(`\n${CONFIG.colors.yellow}Warning: Uncommitted changes found${CONFIG.colors.reset}`);
1221
+ console.log(status);
1222
+
1223
+ const rl = readline.createInterface({
1224
+ input: process.stdin,
1225
+ output: process.stdout
1226
+ });
1227
+
1228
+ const answer = await new Promise(resolve => {
1229
+ rl.question('Commit these changes before closing? (y/N): ', resolve);
1230
+ });
1231
+ rl.close();
1232
+
1233
+ if (answer.toLowerCase() === 'y') {
1234
+ execSync(`git -C "${session.worktreePath}" add -A`, { stdio: 'pipe' });
1235
+ execSync(`git -C "${session.worktreePath}" commit -m "chore: final session cleanup for ${sessionId}"`, { stdio: 'pipe' });
1236
+ execSync(`git -C "${session.worktreePath}" push origin ${session.branchName}`, { stdio: 'pipe' });
1237
+ console.log(`${CONFIG.colors.green}✓${CONFIG.colors.reset} Changes committed and pushed`);
1238
+ }
1239
+ }
1240
+ } catch (err) {
1241
+ console.log(`${CONFIG.colors.dim}Could not check git status${CONFIG.colors.reset}`);
1242
+ }
1243
+
1244
+ // Ask about removing worktree
1245
+ const rl = readline.createInterface({
1246
+ input: process.stdin,
1247
+ output: process.stdout
1248
+ });
1249
+
1250
+ const removeWorktree = await new Promise(resolve => {
1251
+ rl.question(`\nRemove worktree at ${session.worktreePath}? (Y/n): `, resolve);
1252
+ });
1253
+ rl.close();
1254
+
1255
+ if (removeWorktree.toLowerCase() !== 'n') {
1256
+ try {
1257
+ // Remove worktree
1258
+ execSync(`git worktree remove "${session.worktreePath}" --force`, { stdio: 'pipe' });
1259
+ console.log(`${CONFIG.colors.green}✓${CONFIG.colors.reset} Worktree removed`);
1260
+
1261
+ // Prune worktree list
1262
+ execSync('git worktree prune', { stdio: 'pipe' });
1263
+ } catch (err) {
1264
+ console.error(`${CONFIG.colors.red}Failed to remove worktree: ${err.message}${CONFIG.colors.reset}`);
1265
+ }
1266
+ }
1267
+ }
1268
+
1269
+ // Remove lock file
1270
+ fs.unlinkSync(lockFile);
1271
+ console.log(`${CONFIG.colors.green}✓${CONFIG.colors.reset} Session closed successfully`);
1272
+
1273
+ return true;
1274
+ }
1275
+
1276
+ /**
1277
+ * Interactive session selection and close
1278
+ */
1279
+ async selectAndCloseSession() {
1280
+ if (!fs.existsSync(this.locksPath)) {
1281
+ console.log(`${CONFIG.colors.yellow}No active sessions${CONFIG.colors.reset}`);
1282
+ return;
1283
+ }
1284
+
1285
+ const locks = fs.readdirSync(this.locksPath);
1286
+ if (locks.length === 0) {
1287
+ console.log(`${CONFIG.colors.yellow}No active sessions${CONFIG.colors.reset}`);
1288
+ return;
1289
+ }
1290
+
1291
+ const sessions = [];
1292
+ locks.forEach(lockFile => {
1293
+ const lockPath = path.join(this.locksPath, lockFile);
1294
+ const session = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
1295
+ sessions.push(session);
1296
+ });
1297
+
1298
+ console.log(`\n${CONFIG.colors.bright}Select session to close:${CONFIG.colors.reset}\n`);
1299
+
1300
+ sessions.forEach((session, index) => {
1301
+ const status = session.status === 'active' ?
1302
+ `${CONFIG.colors.green}●${CONFIG.colors.reset}` :
1303
+ `${CONFIG.colors.yellow}○${CONFIG.colors.reset}`;
1304
+
1305
+ console.log(`${status} ${CONFIG.colors.bright}${index + 1})${CONFIG.colors.reset} ${session.sessionId}`);
1306
+ console.log(` Task: ${session.task}`);
1307
+ console.log(` Branch: ${session.branchName}`);
1308
+ console.log(` Created: ${session.created}`);
1309
+ console.log();
1310
+ });
1311
+
1312
+ const rl = readline.createInterface({
1313
+ input: process.stdin,
1314
+ output: process.stdout
1315
+ });
1316
+
1317
+ const answer = await new Promise(resolve => {
1318
+ rl.question(`Select session (1-${sessions.length}) or 'q' to quit: `, resolve);
1319
+ });
1320
+ rl.close();
1321
+
1322
+ if (answer.toLowerCase() === 'q') {
1323
+ return;
1324
+ }
1325
+
1326
+ const index = parseInt(answer) - 1;
1327
+ if (index >= 0 && index < sessions.length) {
1328
+ await this.closeSession(sessions[index].sessionId);
1329
+ } else {
1330
+ console.log(`${CONFIG.colors.red}Invalid selection${CONFIG.colors.reset}`);
1331
+ }
1332
+ }
1333
+
1334
+ /**
1335
+ * Clean up all stale sessions and worktrees
1336
+ */
1337
+ async cleanupAll() {
1338
+ console.log(`\n${CONFIG.colors.yellow}Cleaning up stale sessions and worktrees...${CONFIG.colors.reset}`);
1339
+
1340
+ // Clean up old lock files (older than 24 hours)
1341
+ const oneDayAgo = Date.now() - 86400000;
1342
+ let cleanedLocks = 0;
1343
+
1344
+ if (fs.existsSync(this.locksPath)) {
1345
+ const locks = fs.readdirSync(this.locksPath);
1346
+ locks.forEach(lockFile => {
1347
+ const lockPath = path.join(this.locksPath, lockFile);
1348
+ const stats = fs.statSync(lockPath);
1349
+ if (stats.mtimeMs < oneDayAgo) {
1350
+ fs.unlinkSync(lockPath);
1351
+ cleanedLocks++;
1352
+ }
1353
+ });
1354
+ }
1355
+
1356
+ if (cleanedLocks > 0) {
1357
+ console.log(`${CONFIG.colors.green}✓${CONFIG.colors.reset} Removed ${cleanedLocks} stale lock files`);
1358
+ }
1359
+
1360
+ // Prune git worktrees
1361
+ try {
1362
+ execSync('git worktree prune', { stdio: 'pipe' });
1363
+ console.log(`${CONFIG.colors.green}✓${CONFIG.colors.reset} Pruned git worktrees`);
1364
+ } catch (err) {
1365
+ console.log(`${CONFIG.colors.dim}Could not prune worktrees${CONFIG.colors.reset}`);
1366
+ }
1367
+
1368
+ // Clean up orphaned worktree directories
1369
+ if (fs.existsSync(this.worktreesPath)) {
1370
+ const worktrees = fs.readdirSync(this.worktreesPath);
1371
+ let cleanedWorktrees = 0;
1372
+
1373
+ for (const dir of worktrees) {
1374
+ const worktreePath = path.join(this.worktreesPath, dir);
1375
+
1376
+ // Check if this worktree is still valid
1377
+ try {
1378
+ execSync(`git worktree list | grep "${worktreePath}"`, { stdio: 'pipe' });
1379
+ } catch (err) {
1380
+ // Worktree not in git list, it's orphaned
1381
+ try {
1382
+ fs.rmSync(worktreePath, { recursive: true, force: true });
1383
+ cleanedWorktrees++;
1384
+ } catch (err) {
1385
+ console.log(`${CONFIG.colors.dim}Could not remove ${dir}${CONFIG.colors.reset}`);
1386
+ }
1387
+ }
1388
+ }
1389
+
1390
+ if (cleanedWorktrees > 0) {
1391
+ console.log(`${CONFIG.colors.green}✓${CONFIG.colors.reset} Removed ${cleanedWorktrees} orphaned worktree directories`);
1392
+ }
1393
+ }
1394
+
1395
+ console.log(`${CONFIG.colors.green}✓${CONFIG.colors.reset} Cleanup complete`);
1396
+ }
1082
1397
  }
1083
1398
 
1084
1399
  // ============================================================================
@@ -1154,13 +1469,31 @@ async function main() {
1154
1469
  break;
1155
1470
  }
1156
1471
 
1157
- case 'list': {
1158
- coordinator.listSessions();
1159
- break;
1472
+ case 'list': {
1473
+ coordinator.listSessions();
1474
+ break;
1475
+ }
1476
+
1477
+ case 'close': {
1478
+ // Close a session and clean up
1479
+ const sessionId = args[1];
1480
+ if (sessionId) {
1481
+ await coordinator.closeSession(sessionId);
1482
+ } else {
1483
+ // Interactive selection
1484
+ await coordinator.selectAndCloseSession();
1160
1485
  }
1161
-
1162
- case 'help':
1163
- default: {
1486
+ break;
1487
+ }
1488
+
1489
+ case 'cleanup': {
1490
+ // Clean up stale sessions and worktrees
1491
+ await coordinator.cleanupAll();
1492
+ break;
1493
+ }
1494
+
1495
+ case 'help':
1496
+ default: {
1164
1497
  console.log(`
1165
1498
  ${CONFIG.colors.bright}DevOps Session Coordinator${CONFIG.colors.reset}
1166
1499
 
@@ -1173,6 +1506,8 @@ ${CONFIG.colors.blue}Commands:${CONFIG.colors.reset}
1173
1506
  ${CONFIG.colors.green}create-and-start${CONFIG.colors.reset} Create session and start agent (all-in-one)
1174
1507
  ${CONFIG.colors.green}request [agent]${CONFIG.colors.reset} Request a session (for Claude to call)
1175
1508
  ${CONFIG.colors.green}list${CONFIG.colors.reset} List all active sessions
1509
+ ${CONFIG.colors.green}close [id]${CONFIG.colors.reset} Close session and clean up worktree
1510
+ ${CONFIG.colors.green}cleanup${CONFIG.colors.reset} Clean up all stale sessions
1176
1511
  ${CONFIG.colors.green}help${CONFIG.colors.reset} Show this help
1177
1512
 
1178
1513
  ${CONFIG.colors.blue}Options:${CONFIG.colors.reset}
@@ -1193,9 +1528,9 @@ ${CONFIG.colors.blue}Examples:${CONFIG.colors.reset}
1193
1528
 
1194
1529
  ${CONFIG.colors.yellow}Typical Workflow:${CONFIG.colors.reset}
1195
1530
  1. Run: ${CONFIG.colors.green}node session-coordinator.js create-and-start${CONFIG.colors.reset}
1196
- 2. Copy the displayed instructions to your AI development agent (Claude, Cursor, Cline, etc.)
1197
- 3. The AI agent navigates to the worktree and starts working
1198
- 4. DevOps agent automatically commits and pushes changes
1531
+ 2. Copy the displayed instructions to Claude/Cline
1532
+ 3. Claude navigates to the worktree and starts working
1533
+ 4. Agent automatically commits and pushes changes
1199
1534
  `);
1200
1535
  }
1201
1536
  }
@@ -7,7 +7,7 @@
7
7
  # This script provides a user-friendly way to start DevOps agent sessions.
8
8
  # It handles the complete workflow:
9
9
  # 1. Ask if using existing session or creating new
10
- # 2. If new, creates session and generates instructions for the AI development agent
10
+ # 2. If new, creates session and generates instructions for Claude
11
11
  # 3. Starts the DevOps agent monitoring the appropriate worktree
12
12
  #
13
13
  # ============================================================================
@@ -62,13 +62,12 @@ display_instructions() {
62
62
  local worktree_path="$2"
63
63
  local branch_name="$3"
64
64
  local task="$4"
65
- local agent_type="${5:-AI Development Agent}"
66
65
 
67
66
  echo
68
- echo -e "${BG_GREEN}${BOLD} Instructions for ${agent_type} ${NC}"
67
+ echo -e "${BG_GREEN}${BOLD} Instructions for Claude/Cline ${NC}"
69
68
  echo
70
69
  echo -e "${YELLOW}══════════════════════════════════════════════════════════════${NC}"
71
- echo -e "${BOLD}COPY AND PASTE THIS ENTIRE BLOCK INTO YOUR AI AGENT BEFORE YOUR PROMPT:${NC}"
70
+ echo -e "${BOLD}COPY AND PASTE THIS ENTIRE BLOCK INTO CLAUDE BEFORE YOUR PROMPT:${NC}"
72
71
  echo -e "${YELLOW}──────────────────────────────────────────────────────────────${NC}"
73
72
  echo
74
73
  echo "I'm working in a DevOps-managed session with the following setup:"
@@ -216,12 +215,8 @@ select_session() {
216
215
  echo
217
216
  echo -e "${GREEN}Using existing session: ${session_id}${NC}"
218
217
 
219
- # Extract agent type from session data
220
- local agent_type=$(echo "$session_data" | grep -o '"agentType"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*: *"\([^"]*\)".*/\1/')
221
- [[ -z "$agent_type" ]] && agent_type="AI Agent"
222
-
223
- # Display instructions for the AI agent IMMEDIATELY after selection
224
- display_instructions "$session_id" "$worktree_path" "$branch_name" "$task" "$agent_type"
218
+ # Display instructions for Claude/Cline IMMEDIATELY after selection
219
+ display_instructions "$session_id" "$worktree_path" "$branch_name" "$task"
225
220
 
226
221
  # Add a pause and visual separator before starting the agent
227
222
  echo -e "${DIM}Press Enter to start the DevOps agent monitoring...${NC}"
@@ -265,7 +260,7 @@ main() {
265
260
  echo
266
261
  echo "This tool will:"
267
262
  echo " 1. Help you create or select a session"
268
- echo " 2. Generate instructions for your AI development agent"
263
+ echo " 2. Generate instructions for Claude/Cline"
269
264
  echo " 3. Start the DevOps agent to monitor changes"
270
265
  echo
271
266