diff --git a/.gemini/skills/offload/scripts/attach.ts b/.gemini/skills/offload/scripts/attach.ts new file mode 100644 index 0000000000..c8a161aeb6 --- /dev/null +++ b/.gemini/skills/offload/scripts/attach.ts @@ -0,0 +1,69 @@ +/** + * Offload Attach Utility (Local) + * + * Re-attaches to a running tmux session on the worker. + */ +import { spawnSync } from 'child_process'; +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '../../../..'); + +const q = (str: string) => `'${str.replace(/'/g, "'\\''")}'`; + +export async function runAttach(args: string[], env: NodeJS.ProcessEnv = process.env) { + const prNumber = args[0]; + const action = args[1] || 'review'; + const isLocal = args.includes('--local'); + + if (!prNumber) { + console.error('Usage: npm run offload:attach [action] [--local]'); + return 1; + } + + // ... (load settings) + const settingsPath = path.join(REPO_ROOT, '.gemini/settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + const config = settings.maintainer?.deepReview; + if (!config) { + console.error('โŒ Settings not found. Run "npm run offload:setup" first.'); + return 1; + } + + const { remoteHost } = config; + const sessionName = `offload-${prNumber}-${action}`; + const finalSSH = `ssh -t ${remoteHost} "tmux attach-session -t ${sessionName}"`; + + console.log(`๐Ÿ”— Attaching to session: ${sessionName}...`); + + // 2. Open in iTerm2 if within Gemini AND NOT --local + const isWithinGemini = !!env.GEMINI_CLI || !!env.GEMINI_SESSION_ID || !!env.GCLI_SESSION_ID; + if (isWithinGemini && !isLocal) { + const tempCmdPath = path.join(process.env.TMPDIR || '/tmp', `offload-attach-${prNumber}.sh`); + fs.writeFileSync(tempCmdPath, `#!/bin/bash\n${finalSSH}\nrm "$0"`, { mode: 0o755 }); + + const appleScript = ` + on run argv + tell application "iTerm" + set newWindow to (create window with default profile) + tell current session of newWindow + write text (item 1 of argv) & return + end tell + activate + end tell + end run + `; + spawnSync('osascript', ['-', tempCmdPath], { input: appleScript }); + console.log(`โœ… iTerm2 window opened for ${sessionName}.`); + return 0; + } + + spawnSync(finalSSH, { stdio: 'inherit', shell: true }); + return 0; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runAttach(process.argv.slice(2)).catch(console.error); +} diff --git a/.gemini/skills/offload/scripts/fleet.ts b/.gemini/skills/offload/scripts/fleet.ts index 1e781b5e7f..194c072279 100644 --- a/.gemini/skills/offload/scripts/fleet.ts +++ b/.gemini/skills/offload/scripts/fleet.ts @@ -108,6 +108,14 @@ async function stopWorker() { } } +async function remoteStatus() { + const name = INSTANCE_PREFIX; + const zone = 'us-west1-a'; + + console.log(`๐Ÿ“ก Fetching remote status from ${name}...`); + spawnSync('ssh', ['gcli-worker', 'tsx .offload/scripts/status.ts'], { stdio: 'inherit', shell: true }); +} + async function main() { const action = process.argv[2] || 'list'; @@ -121,6 +129,9 @@ async function main() { case 'stop': await stopWorker(); break; + case 'status': + await remoteStatus(); + break; case 'create-image': await createImage(); break; diff --git a/.gemini/skills/offload/scripts/logs.ts b/.gemini/skills/offload/scripts/logs.ts new file mode 100644 index 0000000000..4098112aab --- /dev/null +++ b/.gemini/skills/offload/scripts/logs.ts @@ -0,0 +1,50 @@ +/** + * Offload Log Tailer (Local) + * + * Tails the latest remote logs for a specific job. + */ +import { spawnSync } from 'child_process'; +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '../../../..'); + +export async function runLogs(args: string[]) { + const prNumber = args[0]; + const action = args[1] || 'review'; + + if (!prNumber) { + console.error('Usage: npm run offload:logs [action]'); + return 1; + } + + const settingsPath = path.join(REPO_ROOT, '.gemini/settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + const config = settings.maintainer?.deepReview; + const { remoteHost, remoteHome } = config; + + const jobDir = `${remoteHome}/dev/worktrees/offload-${prNumber}-${action}`; + const logDir = `${jobDir}/.gemini/logs`; + + console.log(`๐Ÿ“‹ Tailing latest logs for job ${prNumber}-${action}...`); + + // Remote command to find the latest log file and tail it + const tailCmd = ` + latest_log=$(ls -t ${logDir}/*.log 2>/dev/null | head -n 1) + if [ -z "$latest_log" ]; then + echo "โŒ No logs found for this job yet." + exit 1 + fi + echo "๐Ÿ“„ Tailing: $latest_log" + tail -f "$latest_log" + `; + + spawnSync(`ssh ${remoteHost} ${JSON.stringify(tailCmd)}`, { stdio: 'inherit', shell: true }); + return 0; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runLogs(process.argv.slice(2)).catch(console.error); +} diff --git a/.gemini/skills/offload/scripts/orchestrator.ts b/.gemini/skills/offload/scripts/orchestrator.ts index 7138abd0a8..f53de9cb38 100644 --- a/.gemini/skills/offload/scripts/orchestrator.ts +++ b/.gemini/skills/offload/scripts/orchestrator.ts @@ -54,7 +54,7 @@ export async function runOrchestrator(args: string[], env: NodeJS.ProcessEnv = p const setupCmd = ` mkdir -p ${remoteHome}/dev/worktrees && \ cd ${remoteWorkDir} && \ - git fetch origin pull/${prNumber}/head && \ + git fetch upstream pull/${prNumber}/head && \ git worktree add -f ${remoteWorktreeDir} FETCH_HEAD `; diff --git a/.gemini/skills/offload/scripts/playbooks/implement.ts b/.gemini/skills/offload/scripts/playbooks/implement.ts index c20e3f73fc..531b7d8350 100644 --- a/.gemini/skills/offload/scripts/playbooks/implement.ts +++ b/.gemini/skills/offload/scripts/playbooks/implement.ts @@ -2,24 +2,73 @@ import { TaskRunner } from '../TaskRunner.js'; import path from 'path'; import { spawnSync } from 'child_process'; +import { TaskRunner } from '../TaskRunner.js'; +import path from 'path'; +import { spawnSync } from 'child_process'; +import fs from 'fs'; + export async function runImplementPlaybook(issueNumber: string, workDir: string, policyPath: string, geminiBin: string) { - const runner = new TaskRunner( - path.join(workDir, `.gemini/logs/offload-issue-${issueNumber}`), - `๐Ÿš€ Offload | IMPLEMENT | Issue #${issueNumber}` - ); + console.log(`๐Ÿš€ Offload | IMPLEMENT (Supervisor Loop) | Issue #${issueNumber}`); + + const ghView = spawnSync('gh', ['issue', 'view', issueNumber, '--json', 'title,body', '-q', '{title:.title,body:.body}'], { shell: true }); + const meta = JSON.parse(ghView.stdout.toString()); + const branchName = `impl/${issueNumber}-${meta.title.toLowerCase().replace(/[^a-z0-9]/g, '-')}`.slice(0, 50); - console.log(`๐Ÿ” Fetching metadata for Issue #${issueNumber}...`); - const ghView = spawnSync('gh', ['issue', 'view', issueNumber, '--json', 'title', '-q', '.title'], { shell: true }); - const title = ghView.stdout.toString().trim() || `issue-${issueNumber}`; - const branchName = `impl/${issueNumber}-${title.toLowerCase().replace(/[^a-z0-9]/g, '-')}`.slice(0, 50); + // 1. Initial Research & Test Creation + console.log('\n๐Ÿง  Phase 1: Research & Reproduction...'); + spawnSync(geminiBin, [ + '--policy', policyPath, '--cwd', workDir, + '-p', `Research Issue #${issueNumber}: "${meta.title}". + Description: ${meta.body}. + ACTION: Create a NEW Vitest test file in 'tests/repro_issue_${issueNumber}.test.ts' that demonstrates the issue or feature. + Ensure this test fails currently.` + ], { stdio: 'inherit' }); - runner.register([ - { id: 'branch', name: 'Create Branch', cmd: `git checkout -b ${branchName}` }, - { id: 'research', name: 'Codebase Research', cmd: `${geminiBin} --policy ${policyPath} -p "Research the requirements for issue #${issueNumber} using 'gh issue view ${issueNumber}'. Map out the files that need to be changed."`, dep: 'branch' }, - { id: 'implement', name: 'Implementation', cmd: `${geminiBin} --policy ${policyPath} -p "Implement the changes for issue #${issueNumber} based on your research. Ensure all code follows project standards."`, dep: 'research' }, - { id: 'verify', name: 'Verification', cmd: `npm run build && npm test`, dep: 'implement' }, - { id: 'pr', name: 'Create Pull Request', cmd: `git add . && git commit -m "feat: implement issue #${issueNumber}" && git push origin ${branchName} && gh pr create --title "${title}" --body "Closes #${issueNumber}"`, dep: 'verify' } - ]); + // 2. The Self-Healing Loop + let attempts = 0; + const maxAttempts = 5; + let success = false; - return runner.run(); + console.log('\n๐Ÿ› ๏ธ Phase 2: Implementation Loop...'); + while (attempts < maxAttempts && !success) { + attempts++; + console.log(`\n๐Ÿ‘‰ Attempt ${attempts}/${maxAttempts}...`); + + // Run the specific repro test + const testRun = spawnSync('npx', ['vitest', 'run', `tests/repro_issue_${issueNumber}.test.ts`], { cwd: workDir }); + + if (testRun.status === 0) { + console.log('โœ… Reproduction test PASSED!'); + success = true; + break; + } + + console.log('โŒ Test failed. Asking Gemini to fix the implementation...'); + const testError = testRun.stdout.toString() + testRun.stderr.toString(); + + spawnSync(geminiBin, [ + '--policy', policyPath, '--cwd', workDir, + '-p', `The reproduction test for Issue #${issueNumber} is still failing. + ERROR OUTPUT: + ${testError.slice(-2000)} + + ACTION: Modify the source code to fix this error and make the test pass. + Do not modify the test itself unless it has a syntax error.` + ], { stdio: 'inherit' }); + } + + // 3. Final Verification + if (success) { + console.log('\n๐Ÿงช Phase 3: Final Verification...'); + const finalCheck = spawnSync('npm', ['test'], { cwd: workDir, stdio: 'inherit' }); + if (finalCheck.status === 0) { + console.log('\n๐ŸŽ‰ Implementation complete and verified!'); + spawnSync('git', ['add', '.'], { cwd: workDir }); + spawnSync('git', ['commit', '-m', `feat: implement issue #${issueNumber}`], { cwd: workDir }); + return 0; + } + } + + console.error('\nโŒ Supervisor: Failed to reach a passing state within retry limit.'); + return 1; } diff --git a/.gemini/skills/offload/scripts/setup.ts b/.gemini/skills/offload/scripts/setup.ts index 13448c639d..b5a0349515 100644 --- a/.gemini/skills/offload/scripts/setup.ts +++ b/.gemini/skills/offload/scripts/setup.ts @@ -85,6 +85,34 @@ Host ${sshAlias} // 1. Configure Fast-Path SSH Alias // ... (unchanged) + // 1b. Security Fork Management + console.log('\n๐Ÿด Configuring Security Fork...'); + const upstreamRepo = 'google-gemini/gemini-cli'; + + const forkCheck = spawnSync('gh', ['repo', 'view', '--json', 'parent,nameWithOwner'], { stdio: 'pipe' }); + let currentRepo = ''; + try { + const repoInfo = JSON.parse(forkCheck.stdout.toString()); + currentRepo = repoInfo.nameWithOwner; + } catch (e) {} + + let userFork = ''; + if (currentRepo.includes(`${env.USER}/`) || currentRepo.includes('mattkorwel/')) { + userFork = currentRepo; + console.log(` โœ… Using existing fork: ${userFork}`); + } else { + console.log(` ๐Ÿ” No personal fork detected for ${upstreamRepo}.`); + if (await confirm(' Would you like to create a personal fork for autonomous work?')) { + const forkResult = spawnSync('gh', ['repo', 'fork', upstreamRepo, '--clone=false'], { stdio: 'inherit' }); + if (forkResult.status === 0) { + // Get the fork name (usually /gemini-cli) + const user = spawnSync('gh', ['api', 'user', '-q', '.login'], { stdio: 'pipe' }).stdout.toString().trim(); + userFork = `${user}/gemini-cli`; + console.log(` โœ… Created fork: ${userFork}`); + } + } + } + // Use the alias for remaining setup steps const remoteHost = sshAlias; const remoteHome = spawnSync(`ssh ${remoteHost} "pwd"`, { shell: true }).stdout.toString().trim(); @@ -109,25 +137,36 @@ Host ${sshAlias} } } - // 4. Remote GitHub Login - if (await confirm('Authenticate GitHub CLI on the worker?')) { - console.log('\n๐Ÿ” Performing non-interactive GitHub CLI authentication on worker...'); - const localToken = spawnSync('gh', ['auth', 'token'], { stdio: 'pipe' }).stdout.toString().trim(); - if (!localToken) { - console.error('โŒ Could not find local GitHub token. Please log in locally first with "gh auth login".'); - return 1; - } - - // Pipe the local token to the remote worker's gh auth login - const loginCmd = `gh auth login --with-token --insecure-storage`; - const result = spawnSync(`echo ${localToken} | ssh ${remoteHost} ${JSON.stringify(loginCmd)}`, { shell: true, stdio: 'inherit' }); + // 4. Scoped Token Onboarding (Security Hardening) + if (await confirm('Generate a scoped, secure token for the autonomous agent? (Recommended)')) { + const user = spawnSync('gh', ['api', 'user', '-q', '.login'], { stdio: 'pipe' }).stdout.toString().trim(); - if (result.status === 0) { - console.log('โœ… GitHub CLI authenticated successfully on worker.'); - // Set git protocol to https by default for simplicity - spawnSync(`ssh ${remoteHost} "gh config set git_protocol https"`, { shell: true }); - } else { - console.error('โŒ GitHub CLI authentication failed on worker.'); + // Construct the Pre-Filled Magic Link for Fine-Grained PAT + const scopes = 'contents:write,pull_requests:write,metadata:read'; + const description = `Gemini CLI Offload - ${env.USER || 'maintainer'}`; + const magicLink = `https://github.com/settings/tokens/beta/new?description=${encodeURIComponent(description)}&repositories[]=${encodeURIComponent(upstreamRepo)}&repositories[]=${encodeURIComponent(userFork)}&permissions[contents]=write&permissions[pull_requests]=write&permissions[metadata]=read`; + + console.log('\n๐Ÿ” SECURITY HARDENING:'); + console.log('1. Open this Magic Link in your browser to create a scoped token:'); + console.log(` \x1b[34m${magicLink}\x1b[0m`); + console.log('2. Click "Generate token" at the bottom of the page.'); + console.log('3. Copy the token and paste it here.'); + + const scopedToken = await prompt('\nPaste Scoped Token', ''); + + if (scopedToken) { + console.log(` - Mirroring scoped token to worker...`); + // Save it to a persistent file on the worker that entrypoint.ts will prioritize + spawnSync(`ssh ${remoteHost} "mkdir -p ~/.offload && echo ${scopedToken} > ~/.offload/.gh_token && chmod 600 ~/.offload/.gh_token"`, { shell: true }); + console.log(' โœ… Scoped token saved on worker.'); + } + } else { + // Fallback: Standard gh auth login if they skip scoped token + if (await confirm('Fallback: Authenticate via standard GitHub CLI login?')) { + console.log('\n๐Ÿ” Starting GitHub CLI authentication on worker...'); + const localToken = spawnSync('gh', ['auth', 'token'], { stdio: 'pipe' }).stdout.toString().trim(); + const loginCmd = `gh auth login --with-token --insecure-storage`; + spawnSync(`echo ${localToken} | ssh ${remoteHost} ${JSON.stringify(loginCmd)}`, { shell: true }); } } @@ -136,9 +175,9 @@ Host ${sshAlias} console.log('๐Ÿš€ Installing global developer tools...'); spawnSync(`ssh ${remoteHost} "sudo npm install -g tsx vitest"`, { shell: true, stdio: 'inherit' }); - console.log('๐Ÿš€ Cloning repository (blob-less) on worker...'); - const repoUrl = 'https://github.com/google-gemini/gemini-cli.git'; - const cloneCmd = `[ -d ${remoteWorkDir}/.git ] || git clone --filter=blob:none ${repoUrl} ${remoteWorkDir}`; + console.log(`๐Ÿš€ Cloning fork ${userFork} on worker...`); + const repoUrl = `https://github.com/${userFork}.git`; + const cloneCmd = `[ -d ${remoteWorkDir}/.git ] || (git clone --filter=blob:none ${repoUrl} ${remoteWorkDir} && cd ${remoteWorkDir} && git remote add upstream https://github.com/${upstreamRepo}.git && git fetch upstream)`; spawnSync(`ssh ${remoteHost} ${JSON.stringify(cloneCmd)}`, { shell: true, stdio: 'inherit' }); // We skip the full npm install here as requested; per-worktree builds will handle it if needed. @@ -157,6 +196,8 @@ Host ${sshAlias} remoteHost, remoteHome, remoteWorkDir, + userFork, + upstreamRepo, terminalType: 'iterm2' }; fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); diff --git a/.gemini/skills/offload/scripts/status.ts b/.gemini/skills/offload/scripts/status.ts new file mode 100644 index 0000000000..72ac31ad62 --- /dev/null +++ b/.gemini/skills/offload/scripts/status.ts @@ -0,0 +1,69 @@ +/** + * Offload Status Inspector (Remote) + * + * Scans tmux sessions and logs to provide a real-time status of offload jobs. + */ +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +const WORKTREE_BASE = path.join(os.homedir(), 'dev/worktrees'); + +function getStatus() { + console.log('\n๐Ÿ›ฐ๏ธ Offload Mission Control Status:'); + console.log(''.padEnd(80, '-')); + console.log(`${'JOB ID'.padEnd(10)} | ${'ACTION'.padEnd(10)} | ${'STATE'.padEnd(12)} | ${'SESSION'.padEnd(25)}`); + console.log(''.padEnd(80, '-')); + + // 1. Get active tmux sessions + const tmux = spawnSync('tmux', ['ls', '-F', '#{session_name}']); + const activeSessions = tmux.stdout.toString().split('\n').filter(s => s.startsWith('offload-')); + + // 2. Scan worktrees for job history + if (!fs.existsSync(WORKTREE_BASE)) { + console.log(' No jobs found.'); + return; + } + + const jobs = fs.readdirSync(WORKTREE_BASE).filter(d => d.startsWith('offload-')); + + if (jobs.length === 0 && activeSessions.length === 0) { + console.log(' No jobs found.'); + return; + } + + const allJobIds = new Set([...jobs, ...activeSessions.map(s => s)]); + + allJobIds.forEach(id => { + const parts = id.split('-'); // offload-123-review + const pr = parts[1] || '???'; + const action = parts[2] || '???'; + + let state = '๐Ÿ’ค IDLE'; + if (activeSessions.includes(id)) { + state = '๐Ÿƒ RUNNING'; + } else { + // Check logs for final state + const logDir = path.join(WORKTREE_BASE, id, '.gemini/logs'); + if (fs.existsSync(logDir)) { + const logFiles = fs.readdirSync(logDir).sort(); + if (logFiles.length > 0) { + const lastLog = fs.readFileSync(path.join(logDir, logFiles[logFiles.length - 1]), 'utf8'); + if (lastLog.includes('SUCCESS')) state = 'โœ… SUCCESS'; + else if (lastLog.includes('FAILED')) state = 'โŒ FAILED'; + else state = '๐Ÿ FINISHED'; + } + } + } + + console.log(`${pr.padEnd(10)} | ${action.padEnd(10)} | ${state.padEnd(12)} | ${id.padEnd(25)}`); + if (state === '๐Ÿƒ RUNNING') { + console.log(` โ””โ”€ Attach: npm run offload:attach ${pr} ${action} [--local]`); + console.log(` โ””โ”€ Logs: npm run offload:logs ${pr} ${action}`); + } + }); + console.log(''.padEnd(80, '-')); +} + +getStatus(); diff --git a/package.json b/package.json index e3f0937fbb..98c9379233 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,9 @@ "offload:check": "tsx .gemini/skills/offload/scripts/check.ts", "offload:clean": "tsx .gemini/skills/offload/scripts/clean.ts", "offload:fleet": "tsx .gemini/skills/offload/scripts/fleet.ts", + "offload:status": "npm run offload:fleet status", + "offload:attach": "tsx .gemini/skills/offload/scripts/attach.ts", + "offload:logs": "tsx .gemini/skills/offload/scripts/logs.ts", "pre-commit": "node scripts/pre-commit.js" }, "overrides": {