Skip to content
Open
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
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
23 changes: 21 additions & 2 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
210 changes: 201 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]);

Expand Down Expand Up @@ -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(
Expand All @@ -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 ??
Expand All @@ -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 {
Expand All @@ -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);
Expand Down Expand Up @@ -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.`,
};
Expand Down Expand Up @@ -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<string, number> = {};
let totalRuntimeSeconds = 0;
const byHarness: Record<string, number> = { "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,
};
},
}),
],
});
Loading
Loading