diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 19d97fd..1018181 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,9 @@ on: branches: [main] pull_request: +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest diff --git a/bin/claudebox b/bin/claudebox new file mode 100755 index 0000000..0a250be --- /dev/null +++ b/bin/claudebox @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# ClaudeBox launcher — runs the server with auto-update restart loop. +# +# Usage: +# claudebox [--http-only] [--no-auto-update] [--profiles=X,Y] [...] +# +# The server exits with code 75 when auto-update pulls new code. +# This script restarts it automatically. + +set -euo pipefail + +# Resolve the repo directory (this script lives in /bin/) +SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0" 2>/dev/null || realpath "$0" 2>/dev/null || echo "$0")")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Source env file if it exists (secrets, config) +ENV_FILE="$HOME/.claudebox/env" +if [ -f "$ENV_FILE" ]; then + set -a + source "$ENV_FILE" + set +a +fi + +# Default to auto-update unless explicitly disabled +AUTO_UPDATE="--auto-update" +ARGS=() +for arg in "$@"; do + if [ "$arg" = "--no-auto-update" ]; then + AUTO_UPDATE="" + else + ARGS+=("$arg") + fi +done + +cd "$REPO_DIR" + +while true; do + echo "─── claudebox starting ($(git rev-parse --short HEAD 2>/dev/null || echo '?')) ───" + set +e + node --experimental-strip-types --no-warnings server.ts $AUTO_UPDATE "${ARGS[@]}" + EXIT_CODE=$? + set -e + + if [ "$EXIT_CODE" = "75" ]; then + echo "─── auto-update: restarting ───" + sleep 1 + continue + fi + + echo "─── claudebox exited ($EXIT_CODE) ───" + exit $EXIT_CODE +done diff --git a/bin/refresh-oauth.sh b/bin/refresh-oauth.sh new file mode 100755 index 0000000..5898335 --- /dev/null +++ b/bin/refresh-oauth.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Refresh OAuth token by briefly launching claude in a PTY +# script -c allocates a pseudo-TTY so claude thinks it's interactive + +export HOME=/mnt/user-data/claude +export PATH="$HOME/.local/bin:$PATH" + +script -qec "claude" /dev/null & +PID=$! +sleep 10 +kill $PID 2>/dev/null +wait $PID 2>/dev/null +echo "[$(date -Is)] OAuth token refreshed (pid=$PID)" diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..7d312d5 --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Bootstrap ClaudeBox — install dependencies. +# Called by install.sh and by auto-update after pulling new code. +set -euo pipefail + +cd "$(dirname "$0")" + +# Install/update npm deps (skip if node_modules is fresh) +if [ package.json -nt node_modules/.package-lock.json ] 2>/dev/null || [ ! -d node_modules ]; then + echo "[bootstrap] npm install..." + npm install --no-audit --no-fund --prefer-offline 2>&1 | tail -3 +else + echo "[bootstrap] Dependencies up to date." +fi diff --git a/cli.ts b/cli.ts index df65e62..ab2ca08 100644 --- a/cli.ts +++ b/cli.ts @@ -1716,6 +1716,193 @@ async function streamLogs(server: { url: string; user: string; password: string } } +// ── Creds command ──────────────────────────────────────────────── + +const CREDS_DIR = join(homedir(), ".claudebox", "credentials"); +const CLAUDE_DIR = join(homedir(), ".claude"); +const CLAUDE_CREDS = join(CLAUDE_DIR, ".credentials.json"); + +function credsSlots(): { name: string; path: string }[] { + if (!existsSync(CREDS_DIR)) return []; + return readdirSync(CREDS_DIR) + .filter(d => existsSync(join(CREDS_DIR, d, ".credentials.json"))) + .sort() + .map(name => ({ name, path: join(CREDS_DIR, name) })); +} + +function readCredsMeta(credPath: string): { email: string; expires: string; expired: boolean } { + try { + const creds = JSON.parse(readFileSync(join(credPath, ".credentials.json"), "utf-8")); + const oauth = creds.claudeAiOauth || {}; + const expiresAt = oauth.expiresAt || 0; + const expired = expiresAt < Date.now(); + const expiresDate = expiresAt ? new Date(expiresAt).toISOString().replace("T", " ").slice(0, 19) : "unknown"; + + // Try to read email from .claude.json in the slot + let email = ""; + const claudeJson = join(credPath, ".claude.json"); + if (existsSync(claudeJson)) { + try { + const cfg = JSON.parse(readFileSync(claudeJson, "utf-8")); + email = cfg.oauthAccount?.emailAddress || ""; + } catch {} + } + // Fallback: check the token prefix for differentiation + if (!email) { + const token = (oauth.accessToken || "").slice(0, 25); + email = token ? `token:${token}...` : "unknown"; + } + return { email, expires: expiresDate, expired }; + } catch { + return { email: "error", expires: "error", expired: true }; + } +} + +function activeSlotName(): string | null { + if (!existsSync(CLAUDE_CREDS)) return null; + try { + const activeCreds = readFileSync(CLAUDE_CREDS, "utf-8"); + const activeToken = JSON.parse(activeCreds).claudeAiOauth?.refreshToken || ""; + if (!activeToken) return null; + for (const slot of credsSlots()) { + try { + const slotCreds = JSON.parse(readFileSync(join(slot.path, ".credentials.json"), "utf-8")); + if ((slotCreds.claudeAiOauth?.refreshToken || "") === activeToken) return slot.name; + } catch {} + } + } catch {} + return null; +} + +async function credsCommand(args: string[]): Promise { + const sub = args[0]; + + if (sub === "list" || sub === "ls" || !sub) { + const slots = credsSlots(); + if (!slots.length) { + console.log(`No credential slots found.\n\nSet up slots:\n mkdir -p ${CREDS_DIR}/account-1\n cp ~/.claude/.credentials.json ${CREDS_DIR}/account-1/`); + return; + } + const active = activeSlotName(); + const pad = Math.max(...slots.map(s => s.name.length), 4); + + console.log(`\n ${"SLOT".padEnd(pad)} ${"IDENTITY".padEnd(30)} ${"EXPIRES".padEnd(19)} STATUS`); + console.log(` ${"─".repeat(pad)} ${"─".repeat(30)} ${"─".repeat(19)} ──────`); + for (const slot of slots) { + const meta = readCredsMeta(slot.path); + const isActive = slot.name === active; + const marker = isActive ? "● " : " "; + const statusParts: string[] = []; + if (isActive) statusParts.push("\x1b[32mactive\x1b[0m"); + if (meta.expired) statusParts.push("\x1b[31mexpired\x1b[0m"); + else statusParts.push("\x1b[32mvalid\x1b[0m"); + console.log(`${marker}${slot.name.padEnd(pad)} ${meta.email.padEnd(30)} ${meta.expires} ${statusParts.join(" ")}`); + } + console.log(); + return; + } + + if (sub === "use" || sub === "promote") { + const slotName = args[1]; + if (!slotName) { console.error("Usage: claudebox creds use "); process.exit(1); } + + const slot = credsSlots().find(s => s.name === slotName); + if (!slot) { console.error(`Slot "${slotName}" not found. Run: claudebox creds list`); process.exit(1); } + + const srcCreds = join(slot.path, ".credentials.json"); + if (!existsSync(srcCreds)) { console.error(`No .credentials.json in slot "${slotName}"`); process.exit(1); } + + // Back up current credentials to a slot if not already tracked + const active = activeSlotName(); + if (active && active !== slotName) { + // Current creds are already in a slot, just switch + console.log(` Deactivating: ${active}`); + } else if (!active && existsSync(CLAUDE_CREDS)) { + // Current creds aren't in any slot — save them + const backupName = `backup-${Date.now()}`; + const backupDir = join(CREDS_DIR, backupName); + mkdirSync(backupDir, { recursive: true }); + writeFileSync(join(backupDir, ".credentials.json"), readFileSync(CLAUDE_CREDS)); + chmodSync(join(backupDir, ".credentials.json"), 0o600); + console.log(` Backed up current credentials → ${backupName}`); + } + + // Copy slot credentials to active location + writeFileSync(CLAUDE_CREDS, readFileSync(srcCreds)); + chmodSync(CLAUDE_CREDS, 0o600); + + // Also copy .claude.json if the slot has one + const srcClaudeJson = join(slot.path, ".claude.json"); + const dstClaudeJson = join(homedir(), ".claude.json"); + if (existsSync(srcClaudeJson)) { + writeFileSync(dstClaudeJson, readFileSync(srcClaudeJson)); + chmodSync(dstClaudeJson, 0o600); + } + + const meta = readCredsMeta(slot.path); + console.log(` ● Activated: ${slotName} (${meta.email})`); + if (meta.expired) console.log(` ⚠ Token is expired — Claude will refresh on next use`); + console.log(); + return; + } + + if (sub === "save") { + const slotName = args[1]; + if (!slotName) { console.error("Usage: claudebox creds save "); process.exit(1); } + if (!/^[a-z0-9][a-z0-9._-]*$/i.test(slotName)) { console.error("Invalid slot name (use alphanumeric, hyphens, dots)"); process.exit(1); } + + const slotDir = join(CREDS_DIR, slotName); + mkdirSync(slotDir, { recursive: true }); + + if (!existsSync(CLAUDE_CREDS)) { console.error("No active credentials to save"); process.exit(1); } + + writeFileSync(join(slotDir, ".credentials.json"), readFileSync(CLAUDE_CREDS)); + chmodSync(join(slotDir, ".credentials.json"), 0o600); + + // Also save .claude.json for the account identity + const claudeJson = join(homedir(), ".claude.json"); + if (existsSync(claudeJson)) { + writeFileSync(join(slotDir, ".claude.json"), readFileSync(claudeJson)); + chmodSync(join(slotDir, ".claude.json"), 0o600); + } + + const meta = readCredsMeta(slotDir); + console.log(` Saved current credentials → ${slotName}`); + console.log(); + return; + } + + if (sub === "rm" || sub === "remove") { + const slotName = args[1]; + if (!slotName) { console.error("Usage: claudebox creds rm "); process.exit(1); } + + const slot = credsSlots().find(s => s.name === slotName); + if (!slot) { console.error(`Slot "${slotName}" not found`); process.exit(1); } + + const active = activeSlotName(); + if (active === slotName) { console.error(`Cannot remove active slot. Switch first: claudebox creds use `); process.exit(1); } + + const { rmSync } = await import("fs"); + rmSync(slot.path, { recursive: true }); + console.log(` Removed slot: ${slotName}`); + console.log(); + return; + } + + console.log(`claudebox creds — manage Claude OAuth credentials + +Usage: + claudebox creds List all credential slots + claudebox creds list List all credential slots + claudebox creds use Activate a credential slot + claudebox creds save Save current credentials to a slot + claudebox creds rm Remove a credential slot + +Slots are stored in: ${CREDS_DIR}/ +Each slot is a directory containing .credentials.json (and optionally .claude.json). +`); +} + // ── Main ──────────────────────────────────────────────────────── const [command, ...args] = process.argv.slice(2); @@ -1774,6 +1961,10 @@ switch (command) { case "register": registerCommand(args).catch(e => { console.error(e.message); process.exit(1); }); break; + case "creds": + case "credentials": + credsCommand(args).catch(e => { console.error(e.message); process.exit(1); }); + break; default: console.log(`ClaudeBox CLI @@ -1795,6 +1986,7 @@ Usage: claudebox register --user-id --server-url Register for DM routing claudebox status Health check / local summary claudebox profiles List available profiles + claudebox creds [list|use|save|rm] Manage OAuth credentials claudebox config [value] Get/set config Sessions are identified by name (session/) or worktree ID. diff --git a/container-entrypoint.sh b/container-entrypoint.sh index e068e63..473076f 100755 --- a/container-entrypoint.sh +++ b/container-entrypoint.sh @@ -40,9 +40,10 @@ cat > /tmp/mcp.json < /workspace/.claude/CLAUDE.md < ~/.claude/claudebox/repo +# cd ~/.claude/claudebox/repo +# ./install.sh +# +# What this does: +# 1. Runs bootstrap.sh (npm install) +# 2. Creates data directories +# 3. Symlinks bin/claudebox → ~/.local/bin/claudebox +# 4. Optionally sets up systemd user service +# +# After install: +# claudebox # start server (auto-updates from origin/next) +# claudebox --http-only # no Slack +# claudebox --no-auto-update # disable git pull loop + +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "$0")" && pwd)" +BIN_DIR="$HOME/.local/bin" +DATA_DIR="$HOME/.claudebox" +ENV_FILE="$DATA_DIR/env" + +echo "ClaudeBox installer" +echo " Repo: $REPO_DIR" +echo "" + +# 1. Bootstrap +echo "[1/4] Installing dependencies..." +"$REPO_DIR/bootstrap.sh" + +# 2. Create data directories +echo "[2/4] Creating data directories..." +mkdir -p "$DATA_DIR/sessions" "$DATA_DIR/worktrees" "$DATA_DIR/stats" + +# 3. Symlink bin +echo "[3/4] Linking claudebox to $BIN_DIR..." +mkdir -p "$BIN_DIR" +ln -sf "$REPO_DIR/bin/claudebox" "$BIN_DIR/claudebox" + +# Check PATH +if ! echo "$PATH" | tr ':' '\n' | grep -qx "$BIN_DIR"; then + echo "" + echo " ⚠ $BIN_DIR is not in your PATH. Add to your shell profile:" + echo " export PATH=\"$BIN_DIR:\$PATH\"" + echo "" +fi + +# 4. Env file +if [ ! -f "$ENV_FILE" ]; then + echo "[4/4] Creating env file..." + cat > "$ENV_FILE" << 'EOF' +# ClaudeBox environment — sourced by systemd service. +# Required: +CLAUDEBOX_SESSION_PASS= +CLAUDEBOX_API_SECRET= +GH_TOKEN= + +# Slack (not needed with --http-only): +SLACK_BOT_TOKEN= +SLACK_APP_TOKEN= + +# Optional: +# CLAUDEBOX_PORT=3000 +# CLAUDEBOX_HOST=claudebox.work +# CLAUDEBOX_DOCKER_IMAGE=devbox:latest +EOF + chmod 600 "$ENV_FILE" + echo " Created $ENV_FILE — edit and fill in secrets." +else + echo "[4/4] Env file exists: $ENV_FILE" +fi + +echo "" +echo "Done! Run:" +echo " claudebox # start server" +echo " claudebox --http-only # without Slack" +echo "" +echo "Optional systemd setup:" +echo " ./scripts/setup-systemd.sh" diff --git a/packages/libclaudebox/auto-update.ts b/packages/libclaudebox/auto-update.ts new file mode 100644 index 0000000..21a1de4 --- /dev/null +++ b/packages/libclaudebox/auto-update.ts @@ -0,0 +1,87 @@ +/** + * Auto-updater for ClaudeBox. + * + * Polls origin/next. When new commits are found: + * 1. git fetch origin next + * 2. git reset --hard origin/next + * 3. ./bootstrap.sh + * 4. Exit with code 75 (EX_TEMPFAIL) — systemd or wrapper restarts us + */ + +import { execFileSync, execSync } from "child_process"; + +const POLL_INTERVAL = 60_000; // 1 minute +const BRANCH = "origin/next"; +const RESTART_EXIT_CODE = 75; // EX_TEMPFAIL — signals "restart me" + +let repoDir = ""; +let updating = false; + +function git(...args: string[]): string { + return execFileSync("git", args, { + cwd: repoDir, + encoding: "utf-8", + timeout: 30_000, + }).trim(); +} + +function checkAndUpdate(): void { + if (updating) return; + updating = true; + + try { + const before = git("rev-parse", "HEAD"); + + git("fetch", "origin", "next", "--quiet"); + + const after = git("rev-parse", BRANCH); + if (before === after) { + updating = false; + return; + } + + console.log(`[AUTOUPDATE] ${before.slice(0, 8)} → ${after.slice(0, 8)}`); + + try { + const log = git("log", "--oneline", `${before}..${after}`, "--max-count=10"); + if (log) console.log(`[AUTOUPDATE] Changes:\n${log}`); + } catch {} + + git("reset", "--hard", BRANCH); + console.log(`[AUTOUPDATE] Reset to ${BRANCH}`); + + console.log("[AUTOUPDATE] Running bootstrap.sh..."); + execSync("./bootstrap.sh", { + cwd: repoDir, + stdio: "inherit", + timeout: 120_000, + }); + + console.log("[AUTOUPDATE] Exiting for restart..."); + process.exit(RESTART_EXIT_CODE); + } catch (e: any) { + console.error(`[AUTOUPDATE] Error: ${e.message}`); + updating = false; + } +} + +/** + * Start the auto-updater. Call once from server.ts. + * @param dir - The repo directory to update + */ +export function startAutoUpdate(dir?: string): void { + repoDir = dir || process.cwd(); + + try { + git("rev-parse", "--git-dir"); + } catch { + console.warn("[AUTOUPDATE] Not a git repo, disabled"); + return; + } + + const head = git("rev-parse", "HEAD").slice(0, 8); + console.log(` Auto-update: enabled (${head}, poll ${POLL_INTERVAL / 1000}s)`); + + setTimeout(checkAndUpdate, 10_000); + setInterval(checkAndUpdate, POLL_INTERVAL); +} diff --git a/packages/libclaudebox/config.ts b/packages/libclaudebox/config.ts index c7463e9..ff626d8 100644 --- a/packages/libclaudebox/config.ts +++ b/packages/libclaudebox/config.ts @@ -6,16 +6,17 @@ import { homedir } from "os"; // centralized in libcreds / libcreds-host. Do NOT read them here. export const SLACK_APP_TOKEN = process.env.SLACK_APP_TOKEN!; export const API_SECRET = process.env.CLAUDEBOX_API_SECRET || ""; +export const GITHUB_WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET || ""; export const HTTP_PORT = parseInt(process.env.CLAUDEBOX_PORT || "3000", 10); export const INTERNAL_PORT = parseInt(process.env.CLAUDEBOX_INTERNAL_PORT || String(HTTP_PORT + 2), 10); export const MAX_CONCURRENT = 10; // ── Paths ─────────────────────────────────────────────────────── export const REPO_DIR = process.env.CLAUDE_REPO_DIR ?? join(homedir(), "repo"); -export const SESSIONS_DIR = join(REPO_DIR, ".claude", "claudebox", "sessions"); export const DOCKER_IMAGE = process.env.CLAUDEBOX_DOCKER_IMAGE || "devbox:latest"; export const CLAUDEBOX_DIR = join(homedir(), ".claudebox"); -export const CLAUDEBOX_SESSIONS_DIR = join(CLAUDEBOX_DIR, "sessions"); // legacy +export const SESSIONS_DIR = join(CLAUDEBOX_DIR, "sessions"); +export const CLAUDEBOX_SESSIONS_DIR = SESSIONS_DIR; // alias for back-compat export const CLAUDEBOX_WORKTREES_DIR = join(CLAUDEBOX_DIR, "worktrees"); export const CLAUDEBOX_STATS_DIR = join(CLAUDEBOX_DIR, "stats"); // Parent of packages/libclaudebox/ — the root claudebox directory @@ -33,9 +34,10 @@ export const SESSION_PAGE_PASS = process.env.CLAUDEBOX_SESSION_PASS || ""; // The logId format is -, so we extract the worktreeId prefix. export const LOG_BASE_URL = `https://${CLAUDEBOX_HOST}`; export function buildLogUrl(logId: string): string { - // Extract worktreeId from logId (e.g. "d9441073aae158ae-3" → "d9441073aae158ae") + // Extract worktreeId and seq from logId (e.g. "d9441073aae158ae-3" → worktree "d9441073aae158ae", run "3") const worktreeId = logId.replace(/-\d+$/, ""); - return `${LOG_BASE_URL}/s/${worktreeId}`; + const seq = logId.match(/-(\d+)$/)?.[1] || ""; + return `${LOG_BASE_URL}/s/${worktreeId}${seq ? `?run=${seq}` : ""}`; } export const DEFAULT_BASE_BRANCH = process.env.CLAUDEBOX_DEFAULT_BRANCH || "main"; diff --git a/packages/libclaudebox/docker.ts b/packages/libclaudebox/docker.ts index 42e0e73..d8407ad 100644 --- a/packages/libclaudebox/docker.ts +++ b/packages/libclaudebox/docker.ts @@ -14,6 +14,7 @@ import { buildLogUrl, } from "./config.ts"; import { incrActiveSessions, decrActiveSessions } from "./runtime.ts"; +import { updateGithubOnCompletion } from "./github-completion.ts"; // Token env vars — sourced from libcreds-host (the only package that reads token env vars). import { getContainerTokens } from "../libcreds-host/index.ts"; @@ -124,6 +125,21 @@ export class DockerService { const wt = store.getOrCreateWorktree(opts.worktreeId); const { worktreeId, workspaceDir, claudeProjectsDir } = wt; + // Kill any leftover containers from previous runs on this worktree + const prevSessions = store.listByWorktree(worktreeId); + for (const prev of prevSessions) { + if (prev.status === "running" && prev._log_id) { + const prevContainer = prev.container || `claudebox-${prev._log_id}`; + const prevSidecar = prev.sidecar || `claudebox-sidecar-${prev._log_id}`; + const prevNetwork = `claudebox-net-${prev._log_id}`; + this.stopAndRemoveSync(prevContainer, 3); + this.stopAndRemoveSync(prevSidecar, 3); + this.removeNetworkSync(prevNetwork); + store.update(prev._log_id, { status: "cancelled", exit_code: 137, finished: new Date().toISOString() }); + console.log(`[DOCKER] Cleaned up previous run ${prev._log_id} on worktree ${worktreeId}`); + } + } + const logId = store.nextSessionLogId(worktreeId); const sessionUuid = randomUUID(); const networkName = `claudebox-net-${logId}`; @@ -180,6 +196,9 @@ export class DockerService { slack_channel_name: opts.slackChannelName || "", slack_thread_ts: opts.slackThreadTs || "", slack_message_ts: opts.slackMessageTs || "", + run_comment_id: opts.runCommentId || "", + comment_id: opts.commentId || "", + repo: basename(REPO_DIR) === "aztec-packages" ? "AztecProtocol/aztec-packages" : "", claude_session_id: sessionUuid, worktree_id: worktreeId, base_branch: baseBranch, @@ -221,6 +240,15 @@ export class DockerService { `${CLAUDEBOX_DIR}:${CONTAINER_HOME}/.claudebox:rw`, ]; sidecarBinds.push(`${join(REPO_DIR, ".git")}:/reference-repo/.git:ro`); + // Mount yarn-project node_modules + prettier config for format tools + const ypNodeModules = join(REPO_DIR, "yarn-project/node_modules"); + if (existsSync(ypNodeModules)) { + sidecarBinds.push(`${ypNodeModules}:/reference-repo/yarn-project/node_modules:ro`); + } + const prettierConfig = join(REPO_DIR, "yarn-project/foundation/.prettierrc.json"); + if (existsSync(prettierConfig)) { + sidecarBinds.push(`${prettierConfig}:/reference-repo/yarn-project/foundation/.prettierrc.json:ro`); + } // Server URL for sidecar → server communication (internal port, not exposed to internet) const serverUrl = `http://host.docker.internal:${INTERNAL_PORT}`; @@ -302,6 +330,7 @@ export class DockerService { "-e", `PARENT_LOG_ID=${logId}`, "-e", `CLAUDEBOX_PROFILE=${profileDir}`, "-e", `CLAUDEBOX_MODEL=${opts.model || ""}`, + "-e", `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1`, ]; // Pass tag categories to sidecar const tagCats = profile.tagCategories || []; @@ -418,6 +447,9 @@ export class DockerService { writeFileSync(metaPath, JSON.stringify(m, null, 2)); } catch {} + // Update GitHub PR comment on completion + updateGithubOnCompletion(store, logId, worktreeId, exitCode) + .catch((e) => console.warn(`[DOCKER] GitHub completion update failed: ${e.message}`)); resolve(exitCode); }); @@ -431,6 +463,168 @@ export class DockerService { } } + // Set of logIds being monitored by recoverRunningSessions — prevents reconcile + // from interfering with sessions we've already re-attached to. + private recoveredSessions = new Set(); + + /** Check if a session is being monitored by recovery (for reconcile to skip). */ + isRecovered(logId: string): boolean { + return this.recoveredSessions.has(logId); + } + + /** + * Recover sessions that are still running in Docker after a server restart. + * Re-attaches session streamers and exit monitors for orphaned containers. + */ + async recoverRunningSessions(store: WorktreeStore): Promise { + const all = store.listAll(); + const running = all.filter(s => s.status === "running" && s.container); + + if (!running.length) { + console.log("[RECOVER] No running sessions to recover."); + return; + } + + console.log(`[RECOVER] Found ${running.length} session(s) marked running — checking containers...`); + + for (const session of running) { + const logId = session._log_id!; + const containerName = session.container!; + const worktreeId = session.worktree_id; + + try { + // Check if the Docker container is actually still running + const { running: isRunning, exitCode } = this.inspectContainerSync(containerName); + + if (!isRunning) { + // Container already exited — just update the session status + const sidecarName = session.sidecar || `claudebox-sidecar-${logId}`; + const networkName = `claudebox-net-${logId}`; + this.forceRemoveSync(containerName); + this.forceRemoveSync(sidecarName); + this.removeNetworkSync(networkName); + store.update(logId, { + status: "completed", + exit_code: exitCode, + finished: new Date().toISOString(), + }); + console.log(`[RECOVER] ${logId}: container already exited (code=${exitCode}) — marked completed`); + continue; + } + + // Container is still running — re-attach! + console.log(`[RECOVER] ${logId}: container ${containerName} still running — re-attaching...`); + incrActiveSessions(); + this.recoveredSessions.add(logId); + + // Shared exit handler — used by both with-worktree and without-worktree paths + const onContainerExit = (code: number, streamer?: SessionStreamer, cacheLogProc?: ChildProcess | null) => { + this.recoveredSessions.delete(logId); + decrActiveSessions(); + console.log(`[RECOVER] Container ${containerName} exited: ${code}`); + + // Stop streamer and cache_log + if (streamer) streamer.stop(); + if (cacheLogProc) setTimeout(() => { cacheLogProc.stdin?.end(); }, 500); + + // Clean up containers/network + const sidecarName = session.sidecar || `claudebox-sidecar-${logId}`; + const networkName = `claudebox-net-${logId}`; + this.forceRemoveSync(containerName); + this.stopAndRemoveSync(sidecarName, 5); + this.removeNetworkSync(networkName); + + // Only update status if not already cancelled (e.g. by cancelSession) + const current = store.get(logId); + if (current && current.status === "running") { + store.update(logId, { + status: "completed", + finished: new Date().toISOString(), + exit_code: code, + }); + } + + // Record activity line count + if (worktreeId) { + try { + const actLines = store.readActivity(worktreeId).length; + const metaPath = join(store.worktreesDir, worktreeId, "meta.json"); + const m = store.getWorktreeMeta(worktreeId); + m.activity_synced_lines = actLines; + writeFileSync(metaPath, JSON.stringify(m, null, 2)); + } catch {} + + // Update GitHub PR comment on completion + updateGithubOnCompletion(store, logId, worktreeId, code) + .catch((e) => console.warn(`[RECOVER] GitHub completion update failed: ${e.message}`)); + } + }; + + // Set up streamer + exit monitor + let streamer: SessionStreamer | undefined; + let cacheLogProc: ChildProcess | null = null; + + if (worktreeId) { + try { + const workspaceDir = join(store.worktreesDir, worktreeId, "workspace"); + const claudeProjectsDir = join(store.worktreesDir, worktreeId, "claude-projects"); + const activityLog = join(workspaceDir, "activity.jsonl"); + + // Start cache_log if available + const cacheLogBin = join(REPO_DIR, "ci3", "cache_log"); + if (existsSync(cacheLogBin)) { + cacheLogProc = spawn(cacheLogBin, ["claudebox", logId], { + stdio: ["pipe", "inherit", "inherit"], + env: { ...process.env, DUP: "1" }, + }); + } + + streamer = new SessionStreamer({ + projectDir: claudeProjectsDir, + activityLog, + repoDir: REPO_DIR, + parentLogId: logId, + onOutput: (text) => { cacheLogProc?.stdin?.write(text); }, + }); + streamer.start().catch(() => {}); + } catch (e: any) { + console.warn(`[RECOVER] ${logId}: failed to start streamer: ${e.message}`); + } + } + + // Spawn `docker wait` to monitor container exit + // Works even if container already exited between inspect and now — returns immediately + const capturedStreamer = streamer; + const capturedCacheLog = cacheLogProc; + let exited = false; + const waiter = spawn("docker", ["wait", containerName], { + stdio: ["ignore", "pipe", "ignore"], + }); + + // Accumulate stdout (docker wait may deliver exit code in chunks) + // and parse on close to avoid double-firing onContainerExit + let waiterBuf = ""; + waiter.stdout?.on("data", (d: Buffer) => { waiterBuf += d.toString(); }); + waiter.on("close", () => { + if (exited) return; + exited = true; + const code = parseInt(waiterBuf.trim(), 10) || 1; + onContainerExit(code, capturedStreamer, capturedCacheLog); + }); + + waiter.on("error", () => { + if (exited) return; + exited = true; + onContainerExit(1, capturedStreamer, capturedCacheLog); + }); + + console.log(`[RECOVER] ${logId}: re-attached streamer + exit monitor`); + } catch (e: any) { + console.warn(`[RECOVER] ${logId}: error during recovery: ${e.message}`); + } + } + } + /** Cancel a running session by stopping its containers. */ cancelSession(id: string, session: RunMeta, store: WorktreeStore): boolean { const logId = session._log_id || id; diff --git a/packages/libclaudebox/github-completion.ts b/packages/libclaudebox/github-completion.ts new file mode 100644 index 0000000..b5762c8 --- /dev/null +++ b/packages/libclaudebox/github-completion.ts @@ -0,0 +1,117 @@ +/** + * GitHub PR comment updates on session completion. + * + * Updates the run_comment_id with final status + response, + * and posts a new comment linking to the completed run. + */ + +import type { WorktreeStore } from "./worktree-store.ts"; +import { getHostCreds } from "../libcreds-host/index.ts"; +import { sessionUrl } from "./util.ts"; + +/** Extract PR number from a GitHub link like https://github.com/org/repo/pull/123 */ +function prNumberFromLink(link: string): number | null { + const m = link.match(/\/pull\/(\d+)/); + return m ? parseInt(m[1], 10) : null; +} + +/** Extract repo from a link like https://github.com/AztecProtocol/aztec-packages/... */ +function repoFromLink(link: string): string | null { + const m = link.match(/github\.com\/([^/]+\/[^/]+)/); + return m ? m[1] : null; +} + +export async function updateGithubOnCompletion( + store: WorktreeStore, + logId: string, + worktreeId: string, + exitCode: number, +): Promise { + const session = store.get(logId); + if (!session) return; + + // Determine repo + PR number from session metadata + const link = session.link || ""; + const repo = session.repo || repoFromLink(link) || ""; + const prNumber = prNumberFromLink(link); + if (!repo || !prNumber) return; + + const creds = getHostCreds(); + if (!creds.github.hasToken) return; + + const statusEmoji = exitCode === 0 ? "\u2705" : "\u26A0\uFE0F"; + const statusText = exitCode === 0 ? "completed" : `error (exit ${exitCode})`; + + // Elapsed time + const started = session.started; + const elapsed = (() => { + if (!started) return ""; + const ms = Date.now() - new Date(started).getTime(); + if (ms < 0) return ""; + const mins = Math.floor(ms / 60000); + if (mins < 1) return "<1m"; + if (mins < 60) return `${mins}m`; + return `${Math.floor(mins / 60)}h${mins % 60}m`; + })(); + + const currentSeq = logId.match(/-(\d+)$/)?.[1] || "1"; + const baseUrl = sessionUrl(worktreeId) + `?run=${currentSeq}`; + + // Get latest response from activity + const activity = store.readActivity(worktreeId).reverse(); // oldest first + const currentActivity = activity.filter(a => a.log_id === logId); + const lastResponse = currentActivity.filter(a => a.type === "response").pop(); + const artifacts = currentActivity.filter(a => a.type === "artifact"); + + // Build the run comment body + const lines: string[] = []; + const elapsedSuffix = elapsed ? ` (${elapsed})` : ""; + lines.push(`${statusEmoji} **Run #${currentSeq}** — ${statusText}${elapsedSuffix}`); + lines.push(`[Live status](${baseUrl})`); + + // Response + if (lastResponse?.text) { + const text = lastResponse.text.length > 600 ? lastResponse.text.slice(0, 600) + "\u2026" : lastResponse.text; + lines.push(""); + lines.push(text); + } + + // Artifacts + if (artifacts.length > 0) { + const artifactLinks: string[] = []; + const seenUrls = new Set(); + for (const a of artifacts) { + const urlMatch = a.text.match(/(https?:\/\/[^\s)>\]]+)/); + if (!urlMatch) continue; + const url = urlMatch[1].replace(/[.,;:!?]+$/, ""); + if (seenUrls.has(url)) continue; + seenUrls.add(url); + const prMatch = url.match(/\/pull\/(\d+)/); + if (prMatch) { artifactLinks.push(`[PR #${prMatch[1]}](${url})`); continue; } + const issueMatch = url.match(/\/issues\/(\d+)/); + if (issueMatch) { artifactLinks.push(`[Issue #${issueMatch[1]}](${url})`); continue; } + if (url.includes("gist.github")) { artifactLinks.push(`[Gist](${url})`); continue; } + artifactLinks.push(`[Link](${url})`); + } + if (artifactLinks.length) { + lines.push(""); + lines.push(artifactLinks.join(" \u00B7 ")); + } + } + + const body = lines.join("\n"); + + try { + // Update existing run_comment_id if we have one + if (session.run_comment_id) { + await creds.github.updateIssueComment(repo, session.run_comment_id, body); + console.log(`[GITHUB] Updated run comment ${session.run_comment_id} on ${repo}#${prNumber}`); + } else { + // Post a new comment on the PR + await creds.github.addIssueComment(repo, prNumber, body); + console.log(`[GITHUB] Posted completion comment on ${repo}#${prNumber}`); + } + } catch (e: any) { + console.warn(`[GITHUB] Failed to update PR comment: ${e.message}`); + } +} diff --git a/packages/libclaudebox/html/dashboard.ts b/packages/libclaudebox/html/dashboard.ts index 7f6c98c..491da47 100644 --- a/packages/libclaudebox/html/dashboard.ts +++ b/packages/libclaudebox/html/dashboard.ts @@ -94,6 +94,7 @@ a{color:#7aa2f7;text-decoration:none}a:hover{text-decoration:underline} .thread-sessions{border-top:1px solid #0d0d0d} .thread-msg-link{color:#333;font-size:10px;margin-left:6px;text-decoration:none;flex-shrink:0} .thread-msg-link:hover{color:#7aa2f7} +.thread-artifacts{display:flex;gap:3px;flex-shrink:0} /* Card grid (used by skeleton only) */ .card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(380px,1fr));gap:8px} @@ -142,6 +143,18 @@ a{color:#7aa2f7;text-decoration:none}a:hover{text-decoration:underline} .badge-tag{color:#bb9af7;border-color:#1a1a2a;background:#0d0a0d;cursor:pointer} .badge-tag:hover{border-color:#bb9af7} +/* Card artifacts */ +.card-artifacts{display:flex;gap:4px;margin-top:6px;flex-wrap:wrap} +.card-artifact{font-size:10px;padding:2px 8px;border-radius:10px;text-decoration:none;transition:all 0.15s} +.card-artifact:hover{opacity:0.85;text-decoration:none} +.card-artifact.a-pr{color:#9ece6a;border:1px solid #1a2a1a;background:#0a0d0a} +.card-artifact.a-issue{color:#f7768e;border:1px solid #2a1a1a;background:#0d0a0a} +.card-artifact.a-gist{color:#bb9af7;border:1px solid #1a1a2a;background:#0d0a0d} +.card-artifact.a-link{color:#7dcfff;border:1px solid #1a2a2a;background:#0a0d0d} + +/* Card reply */ +.card-reply{font-size:11px;color:#555;margin-top:6px;padding:6px 8px;background:#050505;border-left:2px solid #1a1a1a;border-radius:0 2px 2px 0;word-break:break-word;max-height:60px;overflow:hidden;line-height:1.4} + /* Kebab menu */ .kebab{color:#333;cursor:pointer;padding:2px 6px;font-size:16px;line-height:1;border-radius:2px;flex-shrink:0} .kebab:hover{color:#888;background:#111} @@ -347,6 +360,20 @@ function WorkspaceCard({ w, onRefresh, nested }) { \${!nested && w.origin === "github" ? html\`github\` : null} \${w.profile ? html\`\${w.profile}\` : null} + \${w.artifacts && w.artifacts.length > 0 ? html\` +
+ \${w.artifacts.map(a => html\` + e.stopPropagation()}>\${a.text} + \`)} +
+ \` : null} + \${w.statusText && !w.lastReply ? html\` +
\${w.statusText}
+ \` : null} + \${w.lastReply ? html\` +
\${w.lastReply}
+ \` : null} \${menuOpen ? html\`