diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7e3b637 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main, "agent/**"] + pull_request: + branches: [main] + +jobs: + test: + name: Lint & Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build + run: npm run build diff --git a/package.json b/package.json index fed7a17..f9b35db 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "description": "Launch and manage Claude Code or Codex as background coding sessions for OpenClaw.", "scripts": { "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "npm run typecheck && npm test", "plugin:build": "npm run build && openclaw plugins build --entry ./dist/index.js", "plugin:validate": "npm run build && openclaw plugins validate --entry ./dist/index.js", "test": "vitest run" diff --git a/src/index.test.ts b/src/index.test.ts index fe88155..4fa347d 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -2,8 +2,27 @@ import { describe, expect, it } from "vitest"; import entry from "./index.js"; import { getToolPluginMetadata } from "openclaw/plugin-sdk/tool-plugin"; +const EXPECTED_TOOLS = [ + "agent_launch", + "agent_output", + "agent_respond", + "agent_sessions", + "agent_kill", + "agent_merge", + "agent_pr", + "agent_worktree_status", + "agent_worktree_cleanup", + "agent_stats", +]; + describe("code-agent", () => { - it("declares tool metadata", () => { - expect(getToolPluginMetadata(entry)?.tools.map((tool) => tool.name)).toEqual(["echo"]); + it("declares correct tool names", () => { + expect( + getToolPluginMetadata(entry)?.tools.map((tool) => tool.name), + ).toEqual(EXPECTED_TOOLS); + }); + + it("plugin id is code-agent", () => { + expect(getToolPluginMetadata(entry)?.id).toBe("code-agent"); }); }); diff --git a/src/index.ts b/src/index.ts index bc0b3f1..34234c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,11 +11,13 @@ const HarnessEnum = Type.Union([ const PermissionModeEnum = Type.Union([ Type.Literal("bypassPermissions"), + Type.Literal("plan"), Type.Literal("default"), ]); const WorktreeStrategyEnum = Type.Union([ Type.Literal("auto"), + Type.Literal("delegate"), // alias for "auto" — the plugin creates the worktree Type.Literal("off"), ]); @@ -96,15 +98,15 @@ export default defineToolPlugin({ ), ), worktree_strategy: Type.Optional( - Type.Union([Type.Literal("auto"), Type.Literal("off")], { + Type.Union([Type.Literal("auto"), Type.Literal("delegate"), Type.Literal("off")], { description: - '"auto" creates a git worktree, "off" works in the given workdir. Default: "off".', + '"auto"/"delegate" creates a git worktree for isolation, "off" works in the given workdir. Default: "off".', }), ), branch: Type.Optional( Type.String({ description: - "Git branch to checkout/create. Required when worktree_strategy is auto.", + 'Git branch to checkout/create. Required when worktree_strategy is "auto" or "delegate".', }), ), timeout_seconds: Type.Optional( @@ -118,13 +120,23 @@ export default defineToolPlugin({ 'Model override, e.g. "claude-opus-4-6" or "o3". Passed to the CLI.', }), ), + worktree_pr_target_repo: Type.Optional( + Type.String({ + description: + 'Target repo for PRs in fork workflows, e.g. "owner/repo". Passed to agent_pr.', + }), + ), }), async execute(params, config) { const harness = params.harness ?? config.defaultHarness ?? "claude-code"; const permissionMode = params.permission_mode ?? "bypassPermissions"; - const worktreeStrategy = params.worktree_strategy ?? "off"; + // Normalise "delegate" → "auto": both mean the plugin manages the worktree. + const worktreeStrategy = + params.worktree_strategy === "delegate" + ? "auto" + : (params.worktree_strategy ?? "off"); const timeoutSeconds = params.timeout_seconds ?? config.defaultTimeoutSeconds ?? @@ -136,20 +148,36 @@ export default defineToolPlugin({ let effectiveWorkdir = params.workdir; // Handle worktree creation - if (worktreeStrategy === "auto" && params.branch) { + if (worktreeStrategy === "auto") { + // For "auto" strategy: branch is mandatory — caller controls the branch name. + // For "delegate" strategy (normalised to "auto" above): auto-derive from session name + // when branch is not provided, matching how the skill uses `worktree_strategy: "delegate"`. + const effectiveBranch = + params.branch ?? + (params.worktree_strategy === "delegate" + ? `agent/${params.name.replace(/[^a-zA-Z0-9-_]/g, "-")}` + : undefined); + + if (!effectiveBranch) { + return { + error: + 'worktree_strategy "auto" requires a "branch" parameter. Use "delegate" to auto-derive the branch from the session name.', + }; + } + const { execSync } = await import("node:child_process"); const worktreeBase = config.worktreeBase ?? `${params.workdir}/.worktrees`; - const slug = params.branch.replace(/[^a-zA-Z0-9-_]/g, "-"); + const slug = effectiveBranch.replace(/[^a-zA-Z0-9-_]/g, "-"); effectiveWorkdir = `${worktreeBase}/${slug}`; try { execSync( - `git -C "${params.workdir}" worktree add "${effectiveWorkdir}" -b "${params.branch}" 2>/dev/null || git -C "${params.workdir}" worktree add "${effectiveWorkdir}" "${params.branch}"`, + `git -C "${params.workdir}" worktree add "${effectiveWorkdir}" -b "${effectiveBranch}" 2>/dev/null || git -C "${params.workdir}" worktree add "${effectiveWorkdir}" "${effectiveBranch}"`, { stdio: "pipe" }, ); } catch { - // Worktree may already exist + // Worktree may already exist — continue if the path is there const { existsSync } = await import("node:fs"); if (!existsSync(effectiveWorkdir)) { return { @@ -166,8 +194,15 @@ export default defineToolPlugin({ if (harness === "claude-code") { command = claudePath; args = ["--print"]; - if (permissionMode === "bypassPermissions") { + // Use string cast to prevent TS narrowing from rejecting the "plan" + // branch when typebox doesn't emit a proper TS union for the schema. + const pm = permissionMode as string; + if (pm === "bypassPermissions") { args.unshift("--dangerously-skip-permissions"); + } else if (pm === "plan") { + // Plan mode: Claude Code proposes a plan and waits for approval before + // executing any tools. Useful for gated autonomous dispatch. + args.push("--permission-mode", "plan"); } if (params.model) { args.push("--model", params.model); @@ -204,6 +239,7 @@ export default defineToolPlugin({ name: session.name, harness, workdir: effectiveWorkdir, + worktree_pr_target_repo: params.worktree_pr_target_repo, status: session.status, message: `Launched ${harness} session "${params.name}" (${session.id}). Use agent_output to check progress.`, }; @@ -524,5 +560,161 @@ export default defineToolPlugin({ } }, }), + + // ─── agent_worktree_status ─── + tool({ + name: "agent_worktree_status", + description: + "List all git worktrees in a repo and their associated session lifecycle state.", + parameters: Type.Object({ + workdir: Type.String({ + description: "Absolute path to the repo root.", + }), + }), + async execute({ workdir }) { + const { execSync } = await import("node:child_process"); + try { + const raw = execSync("git worktree list --porcelain", { + cwd: workdir, + encoding: "utf-8", + }); + + // Parse porcelain output into structured records + const worktrees: Array<{ + path: string; + branch: string | null; + commit: string | null; + bare: boolean; + }> = []; + + let current: { path?: string; branch?: string; commit?: string; bare?: boolean } = {}; + for (const line of raw.split("\n")) { + if (line.startsWith("worktree ")) { + if (current.path) worktrees.push({ path: current.path, branch: current.branch ?? null, commit: current.commit ?? null, bare: current.bare ?? false }); + current = { path: line.slice("worktree ".length).trim() }; + } else if (line.startsWith("HEAD ")) { + current.commit = line.slice("HEAD ".length).trim(); + } else if (line.startsWith("branch ")) { + current.branch = line.slice("branch refs/heads/".length).trim(); + } else if (line === "bare") { + current.bare = true; + } + } + if (current.path) worktrees.push({ path: current.path, branch: current.branch ?? null, commit: current.commit ?? null, bare: current.bare ?? false }); + + // Enrich with session data + const sessions = sessionManager.list(true); + const enriched = worktrees.map((wt) => { + const linked = sessions.find((s) => s.worktreePath === wt.path); + return { + ...wt, + session_id: linked?.id ?? null, + session_status: linked?.status ?? null, + }; + }); + + return { count: enriched.length, worktrees: enriched }; + } catch (err) { + return { + error: `Failed to list worktrees: ${err instanceof Error ? err.message : String(err)}`, + }; + } + }, + }), + + // ─── agent_worktree_cleanup ─── + tool({ + name: "agent_worktree_cleanup", + description: + "Remove completed/failed/killed worktrees from a repo. By default, only removes worktrees whose associated session has ended.", + parameters: Type.Object({ + workdir: Type.String({ + description: "Absolute path to the repo root.", + }), + force: Type.Optional( + Type.Boolean({ + description: + "Also remove worktrees whose session is still running. Default: false.", + }), + ), + }), + async execute({ workdir, force }) { + const { execSync } = await import("node:child_process"); + const sessions = sessionManager.list(true); + + const raw = execSync("git worktree list --porcelain", { + cwd: workdir, + encoding: "utf-8", + }); + + const worktreePaths: string[] = []; + for (const line of raw.split("\n")) { + if (line.startsWith("worktree ")) { + worktreePaths.push(line.slice("worktree ".length).trim()); + } + } + + const removed: string[] = []; + const skipped: string[] = []; + + for (const wtPath of worktreePaths.slice(1)) { + // Skip the main worktree (first entry) + const linked = sessions.find((s) => s.worktreePath === wtPath); + const canRemove = !linked || linked.status !== "running" || force === true; + + if (!canRemove) { + skipped.push(wtPath); + continue; + } + + try { + execSync(`git worktree remove "${wtPath}" --force`, { + cwd: workdir, + stdio: "pipe", + }); + removed.push(wtPath); + } catch { + skipped.push(wtPath); + } + } + + return { + removed_count: removed.length, + skipped_count: skipped.length, + removed, + skipped, + }; + }, + }), + + // ─── agent_stats ─── + tool({ + name: "agent_stats", + description: + "Return usage and runtime statistics for all coding sessions in this gateway process lifecycle.", + parameters: Type.Object({}), + async execute() { + const all = sessionManager.list(true); + + const byStatus: Record = {}; + let totalRuntimeSeconds = 0; + const byHarness: Record = { "claude-code": 0, codex: 0 }; + + for (const s of all) { + byStatus[s.status] = (byStatus[s.status] ?? 0) + 1; + totalRuntimeSeconds += s.runtimeSeconds; + byHarness[s.harness] = (byHarness[s.harness] ?? 0) + 1; + } + + return { + total_sessions: all.length, + by_status: byStatus, + by_harness: byHarness, + total_runtime_seconds: totalRuntimeSeconds, + average_runtime_seconds: + all.length > 0 ? Math.round(totalRuntimeSeconds / all.length) : 0, + }; + }, + }), ], }); diff --git a/src/session-manager.ts b/src/session-manager.ts index 23a1d2c..f6b57be 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -8,6 +8,32 @@ export type SessionStatus = | "killed" | "timeout"; +/** + * Env vars that Claude Code uses as a nesting guard — must be stripped from + * the child process environment to prevent the spawned Claude Code binary from + * detecting a "nested session" and silently exiting. + * + * Verified live keys (current Claude Code): CLAUDECODE, CLAUDE_CODE_ENTRYPOINT, + * CLAUDE_CODE_SESSION_ID, CLAUDE_CODE_EXECPATH, CLAUDE_CODE_TMPDIR. + * CLAUDE_CODE_SESSION (no _ID) does NOT exist in current Claude Code but is + * kept here as a harmless no-op to cover any older or future variant. + * + * See: openclaw/openclaw#57858, openclaw/openclaw PR#87702. + */ +const CLAUDE_NESTING_VARS = [ + "CLAUDECODE", + "CLAUDE_CODE_ENTRYPOINT", + "CLAUDE_CODE_SESSION_ID", + "CLAUDE_CODE_SESSION", // no-op on current Claude Code; kept for compat + "CLAUDE_CODE_EXECPATH", + "CLAUDE_CODE_TMPDIR", +] as const; + +/** Maximum age in ms for completed sessions before they are pruned from memory. */ +const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 h +/** How often to run the prune sweep. */ +const SESSION_PRUNE_INTERVAL_MS = 60 * 60 * 1000; // 1 h + export interface CodeSession { id: string; name: string; @@ -52,19 +78,52 @@ export interface LaunchOptions { export class SessionManager { private sessions = new Map(); + private pruneTimer: ReturnType | undefined; + + constructor() { + // Prune stale completed sessions once per hour so the gateway process + // doesn't accumulate unbounded session state over multi-day runs. + this.pruneTimer = setInterval( + () => this.pruneStale(), + SESSION_PRUNE_INTERVAL_MS, + ).unref(); + } + + /** Remove completed/failed/killed/timed-out sessions older than SESSION_MAX_AGE_MS. */ + private pruneStale(): void { + const cutoff = Date.now() - SESSION_MAX_AGE_MS; + for (const [id, session] of this.sessions) { + if (session.status !== "running" && session.endedAt && session.endedAt.getTime() < cutoff) { + this.sessions.delete(id); + } + } + } + + /** Dispose the prune timer (e.g. in tests). */ + dispose(): void { + if (this.pruneTimer !== undefined) { + clearInterval(this.pruneTimer); + this.pruneTimer = undefined; + } + } async launch(opts: LaunchOptions): Promise { const id = randomUUID().slice(0, 8); + // Build the child environment: inherit parent env but strip Claude Code + // nesting markers so the child claude binary does not treat itself as a + // nested session and silently exit. See openclaw/openclaw#57858. + const childEnv: NodeJS.ProcessEnv = { ...process.env }; + for (const key of CLAUDE_NESTING_VARS) { + delete childEnv[key]; + } + childEnv["CI"] = "true"; + childEnv["TERM"] = "dumb"; + const child = spawn(opts.command, opts.args, { cwd: opts.workdir, stdio: ["pipe", "pipe", "pipe"], - env: { - ...process.env, - // Ensure non-interactive mode - CI: "true", - TERM: "dumb", - }, + env: childEnv, detached: false, });