Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .iloom/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"iloom-issue-planner": { "model": "opus" },
"iloom-issue-analyze-and-plan": { "model": "opus" },
"iloom-issue-implementer": { "model": "opus" },
"iloom-issue-reviewer": { "providers": { "gemini": "gemini-3-pro-preview" } }
"iloom-code-reviewer": { "providers": { "gemini": "gemini-3-pro-preview" } }
},
"colors": {
"terminal": true,
Expand Down
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,57 @@ Command Reference
| `il init` | `config` | Interactive configuration wizard. |
| `il feedback` | `f` | Submit bug reports/feedback directly from the CLI. |
| `il update` | | Update iloom CLI to the latest version. |
| `il remote` | | Manage remote daemon for automatic PR cleanup. |

For detailed documentation including all command options, flags, and examples, see the [Complete Command Reference](docs/iloom-commands.md).

### Remote Daemon (`il remote`)

The remote daemon automatically cleans up local looms when their associated PRs are closed or merged on GitHub.

**Actions:**

| Action | Description |
|--------|-------------|
| `il remote start` | Start the daemon (runs in background) |
| `il remote stop` | Stop the running daemon |
| `il remote status` | Show daemon status (running, PID, last poll) |
| `il remote restart` | Restart the daemon with new settings |
| `il remote logs` | View recent daemon log entries |

**Options:**

| Option | Description |
|--------|-------------|
| `--interval <seconds>` | Polling interval (60-3600s, default: 300) |
| `--lines <n>` | Number of log lines to show (default: 50) |
| `--json` | Output as JSON |

**Examples:**

```bash
# Start daemon with default 5-minute polling interval
il remote start

# Start with custom 2-minute interval
il remote start --interval 120

# Check daemon status
il remote status

# View last 100 log entries
il remote logs --lines 100

# Stop the daemon
il remote stop
```

**Security Notes:**
- The daemon runs as a background process under your user account
- State files are stored in `~/.config/iloom-ai/remote-daemon/` with restricted permissions (0o700)
- Only your user can read daemon logs and status files
- The daemon validates its own heartbeat before stopping to prevent accidentally killing recycled PIDs

Configuration
-------------

Expand Down Expand Up @@ -237,6 +285,12 @@ This example shows how to configure a project-wide default (e.g., GitHub remote)
},
"summary": {
"model": "sonnet" // Claude model for session summaries: sonnet (default), opus, or haiku
},
"remote": {
"mode": "polling", // Enable remote daemon: "polling" or "off"
"polling": {
"interval": 300 // Polling interval in seconds (60-3600, default: 300)
}
}
}
```
Expand Down
46 changes: 46 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,8 @@ program
.option('--dry-run', 'Show what would be done without doing it')
.option('--json', 'Output result as JSON')
.option('--defer <ms>', 'Wait specified milliseconds before cleanup', parseInt)
.option('--archive', 'Archive metadata before cleanup')
.option('--summary', 'Generate session summary before cleanup')
.action(async (identifier?: string, options?: CleanupOptions) => {
const executeAction = async (): Promise<void> => {
try {
Expand Down Expand Up @@ -1627,6 +1629,50 @@ program
}
})

program
.command('remote')
.description('Manage remote daemon for automatic PR cleanup')
.argument('<action>', 'Action: start, stop, status, restart, or logs')
.option('--interval <seconds>', 'Polling interval in seconds (default: 300)', parseInt)
.option('--lines <n>', 'Number of log lines to show (default: 50)', parseInt)
.option('-f, --follow', 'Continuously stream new log entries (logs action only)')
.option('--json', 'Output as JSON')
.action(async (action: string, options: { interval?: number; lines?: number; follow?: boolean; json?: boolean }) => {
const executeAction = async (): Promise<void> => {
try {
const { RemoteCommand } = await import('./commands/remote.js')
const command = new RemoteCommand()
const result = await command.execute({ action, options })

if (options.json) {
console.log(JSON.stringify(result, null, 2))
}

// Success - result is either DaemonStatus or string[] (logs)
process.exit(0)
} catch (error) {
if (options.json) {
console.log(JSON.stringify({
success: false,
action,
message: error instanceof Error ? error.message : 'Unknown error'
}, null, 2))
} else {
logger.error(`Remote command failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
process.exit(1)
}
}

// Wrap execution in logger context for JSON mode
if (options.json) {
const jsonLogger = createStderrLogger()
await withLogger(jsonLogger, executeAction)
} else {
await executeAction()
}
})

// Test command for Neon integration
program
.command('test-neon')
Expand Down
90 changes: 87 additions & 3 deletions src/commands/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ import { DatabaseManager } from '../lib/DatabaseManager.js'
import { EnvironmentManager } from '../lib/EnvironmentManager.js'
import { CLIIsolationManager } from '../lib/CLIIsolationManager.js'
import { SettingsManager } from '../lib/SettingsManager.js'
import { MetadataManager } from '../lib/MetadataManager.js'
import { SessionSummaryService, type SessionSummaryInput } from '../lib/SessionSummaryService.js'
import { promptConfirmation } from '../utils/prompt.js'
import { IdentifierParser } from '../utils/IdentifierParser.js'
import { loadEnvIntoProcess } from '../utils/env.js'
import { createNeonProviderFromSettings } from '../utils/neon-helpers.js'
import { LoomManager } from '../lib/LoomManager.js'
import type { CleanupOptions } from '../types/index.js'
import { CleanupSafetyError } from '../types/cleanup.js'
import type { CleanupResult } from '../types/cleanup.js'
import type { ParsedInput } from './start.js'

Expand Down Expand Up @@ -48,6 +51,8 @@ export class CleanupCommand {
private resourceCleanup?: ResourceCleanup
private loomManager?: import('../lib/LoomManager.js').LoomManager
private readonly identifierParser: IdentifierParser
private readonly metadataManager: MetadataManager
private sessionSummaryService?: SessionSummaryService

constructor(
gitWorktreeManager?: GitWorktreeManager,
Expand All @@ -72,6 +77,9 @@ export class CleanupCommand {

// Initialize IdentifierParser for pattern-based detection
this.identifierParser = new IdentifierParser(this.gitWorktreeManager)

// Initialize MetadataManager for archive functionality
this.metadataManager = new MetadataManager()
}

/**
Expand Down Expand Up @@ -151,7 +159,67 @@ export class CleanupCommand {
// Check if the TARGET loom has any child looms
const hasChildLooms = await this.loomManager.checkAndWarnChildLooms(targetBranch)
if (hasChildLooms) {
throw new Error('Cannot cleanup loom while child looms exist. Please \'finish\' or \'cleanup\' child looms first.')
throw new CleanupSafetyError(
'Cannot cleanup loom while child looms exist. Please \'finish\' or \'cleanup\' child looms first.',
'child-loom'
)
}
}

/**
* Execute pre-cleanup actions based on options
*
* Handles:
* - --summary: Generate and post session summary to issue/PR (must run BEFORE archive)
* - --archive: Archive metadata to finished/ directory
*
* Order is important: summary needs metadata, so it must run before archiving
*
* @param worktreePath - Path to the worktree being cleaned up
* @param options - Cleanup options
* @param issueNumber - Optional issue number for summary posting
*/
private async preCleanupActions(
worktreePath: string,
options: CleanupOptions,
issueNumber?: string | number
): Promise<void> {
// Read metadata before any actions (needed for both summary and archive)
const metadata = await this.metadataManager.readMetadata(worktreePath)

// Generate session summary if --summary flag is set
// Must run BEFORE archive since it needs the metadata
if (options.summary && metadata && metadata.issueType !== 'branch') {
try {
// Initialize SessionSummaryService lazily
this.sessionSummaryService ??= new SessionSummaryService()

const summaryInput: SessionSummaryInput = {
worktreePath,
issueNumber: issueNumber ?? metadata.issue_numbers?.[0] ?? 'unknown',
branchName: metadata.branchName ?? 'unknown',
loomType: metadata.issueType ?? 'branch',
}
// Add prNumber if available (separate assignment to satisfy exactOptionalPropertyTypes)
const prNumberStr = metadata.pr_numbers?.[0]
if (prNumberStr) {
summaryInput.prNumber = parseInt(prNumberStr, 10)
}

getLogger().info('Generating session summary...')
await this.sessionSummaryService.generateAndPostSummary(summaryInput)
} catch (error) {
// Non-blocking: Log warning but don't throw
const errorMessage = error instanceof Error ? error.message : String(error)
getLogger().warn(`Failed to generate session summary: ${errorMessage}`)
}
}

// Archive metadata if --archive flag is set
if (options.archive) {
getLogger().debug(`Archiving metadata for worktree: ${worktreePath}`)
await this.metadataManager.archiveMetadata(worktreePath)
getLogger().info('Metadata archived')
}
}

Expand Down Expand Up @@ -396,7 +464,17 @@ export class CleanupCommand {
}
}

// Step 4: Execute worktree cleanup (includes safety validation)
// Step 4: Find worktree path for pre-cleanup actions
const worktree = await this.gitWorktreeManager.findWorktreeForBranch(parsedInput.branchName ?? identifier)
const worktreePath = worktree?.path

// Step 5: Execute pre-cleanup actions (summary, archive) before actual cleanup
// Only run if we have a worktree path and archive or summary flags are set
if (worktreePath && (parsed.options.archive || parsed.options.summary)) {
await this.preCleanupActions(worktreePath, parsed.options, parsedInput.number)
}

// Step 6: Execute worktree cleanup (includes safety validation)
// Issue #275 fix: Run 5-point safety check BEFORE any deletion
// This prevents the scenario where worktree is deleted but branch deletion fails
await this.ensureResourceCleanup()
Expand All @@ -414,7 +492,7 @@ export class CleanupCommand {
// Add dryRun flag to result
cleanupResult.dryRun = dryRun ?? false

// Step 5: Report cleanup results
// Step 7: Report cleanup results
this.reportCleanupResults(cleanupResult)

// Final success message
Expand Down Expand Up @@ -547,6 +625,12 @@ export class CleanupCommand {
originalInput: String(issueNumber)
}

// Execute pre-cleanup actions (summary, archive) before actual cleanup
// Only run if we have a worktree path and archive or summary flags are set
if (target.worktreePath && (parsed.options.archive || parsed.options.summary)) {
await this.preCleanupActions(target.worktreePath, parsed.options, issueNumber)
}

await this.ensureResourceCleanup()
if (!this.resourceCleanup) {
throw new Error('Failed to initialize ResourceCleanup')
Expand Down
Loading