feat: implement runCommand wrapper and refactor command executions across modules

This commit is contained in:
Lauren ten Hoor
2026-02-13 10:50:35 +08:00
parent e55b7fa555
commit 83f1f1adf0
24 changed files with 209 additions and 178 deletions

38
AGENTS.md Normal file
View File

@@ -0,0 +1,38 @@
# DevClaw — Agent Instructions
DevClaw is an OpenClaw plugin that provides multi-project dev/qa pipeline orchestration with GitHub/GitLab integration, developer tiers, and audit logging.
## Testing Changes
To verify changes against a live gateway, run:
```bash
npm run build && openclaw gateway restart
```
Wait 3 seconds, then check the logs:
```bash
openclaw logs
```
Look for the plugin registration line and any errors:
```
[plugins] DevClaw plugin registered (11 tools, 1 CLI command group, 1 service)
```
## Project Structure
- `index.ts` — Plugin entry point, registers all tools/CLI/services
- `lib/run-command.ts` — Wrapper around `api.runtime.system.runCommandWithTimeout` (initialized in `register()`)
- `lib/dispatch.ts` — Core task dispatch logic (session spawn/reuse, gateway RPC)
- `lib/providers/` — GitHub and GitLab issue providers (via `gh`/`glab` CLI)
- `lib/services/` — Heartbeat, tick (queue scan), pipeline (completion rules)
- `lib/setup/` — Agent creation, model fetching, LLM-powered model selection
- `lib/tools/` — All registered tools (work_start, work_finish, task_create, etc.)
## Key Conventions
- All external command execution uses `runCommand()` from `lib/run-command.ts` — never import `child_process` directly (the OpenClaw plugin security scanner flags it).
- The plugin SDK provides `api.runtime.system.runCommandWithTimeout` which is the sanctioned way to run external commands.
- Functions that call `runCommand()` must be async.

View File

@@ -12,6 +12,7 @@ import { createOnboardTool } from "./lib/tools/onboard.js";
import { createAutoConfigureModelsTool } from "./lib/tools/autoconfigure-models.js"; import { createAutoConfigureModelsTool } from "./lib/tools/autoconfigure-models.js";
import { registerCli } from "./lib/cli.js"; import { registerCli } from "./lib/cli.js";
import { registerHeartbeatService } from "./lib/services/heartbeat.js"; import { registerHeartbeatService } from "./lib/services/heartbeat.js";
import { initRunCommand } from "./lib/run-command.js";
const plugin = { const plugin = {
id: "devclaw", id: "devclaw",
@@ -86,6 +87,8 @@ const plugin = {
}, },
register(api: OpenClawPluginApi) { register(api: OpenClawPluginApi) {
initRunCommand(api);
// Worker lifecycle // Worker lifecycle
api.registerTool(createWorkStartTool(api), { names: ["work_start"] }); api.registerTool(createWorkStartTool(api), { names: ["work_start"] });
api.registerTool(createWorkFinishTool(api), { names: ["work_finish"] }); api.registerTool(createWorkFinishTool(api), { names: ["work_finish"] });

View File

@@ -4,11 +4,10 @@
* Handles: session lookup, spawn/reuse via Gateway RPC, task dispatch via CLI, * Handles: session lookup, spawn/reuse via Gateway RPC, task dispatch via CLI,
* state update (activateWorker), and audit logging. * state update (activateWorker), and audit logging.
*/ */
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { promisify } from "node:util";
import { log as auditLog } from "./audit.js"; import { log as auditLog } from "./audit.js";
import { runCommand } from "./run-command.js";
import { import {
type Project, type Project,
activateWorker, activateWorker,
@@ -17,8 +16,6 @@ import {
} from "./projects.js"; } from "./projects.js";
import { resolveModel, levelEmoji } from "./tiers.js"; import { resolveModel, levelEmoji } from "./tiers.js";
const execFileAsync = promisify(execFile);
export type DispatchOpts = { export type DispatchOpts = {
workspaceDir: string; workspaceDir: string;
agentId?: string; agentId?: string;
@@ -142,30 +139,31 @@ export async function dispatchTask(
await transitionLabel(issueId, fromLabel, toLabel); await transitionLabel(issueId, fromLabel, toLabel);
let sessionKey = existingSessionKey;
let dispatched = false; let dispatched = false;
let session: { key: string; action: "spawn" | "send" };
try { try {
sessionKey = await ensureSession(sessionAction, sessionKey, { session = await ensureSession(sessionAction, existingSessionKey, {
agentId, projectName: project.name, role, level, model, agentId, projectName: project.name, role, level, model,
}); });
await sendToAgent(sessionKey!, taskMessage, { sendToAgent(session.key, taskMessage, {
agentId, projectName: project.name, issueId, role, agentId, projectName: project.name, issueId, role,
orchestratorSessionKey: opts.sessionKey, orchestratorSessionKey: opts.sessionKey,
}); });
dispatched = true; dispatched = true;
// Always store session key — a "send" may have fallen back to "spawn"
await recordWorkerState(workspaceDir, groupId, role, { await recordWorkerState(workspaceDir, groupId, role, {
issueId, level, sessionKey: sessionKey!, sessionAction, issueId, level, sessionKey: session.key, sessionAction: session.action,
}); });
} catch (err) { } catch (err) {
if (dispatched) { if (dispatched) {
await auditLog(workspaceDir, "work_start", { await auditLog(workspaceDir, "work_start", {
project: project.name, groupId, issue: issueId, role, project: project.name, groupId, issue: issueId, role,
warning: "State update failed after successful dispatch", warning: "State update failed after successful dispatch",
error: (err as Error).message, sessionKey, error: (err as Error).message, sessionKey: session!.key,
}); });
throw new Error( throw new Error(
`State update failed after successful session dispatch: ${(err as Error).message}. Session is running but projects.json was not updated.`, `State update failed after successful session dispatch: ${(err as Error).message}. Session is running but projects.json was not updated.`,
@@ -179,13 +177,13 @@ export async function dispatchTask(
await auditDispatch(workspaceDir, { await auditDispatch(workspaceDir, {
project: project.name, groupId, issueId, issueTitle, project: project.name, groupId, issueId, issueTitle,
role, level, model, sessionAction, sessionKey: sessionKey!, role, level, model, sessionAction: session.action, sessionKey: session.key,
fromLabel, toLabel, fromLabel, toLabel,
}); });
const announcement = buildAnnouncement(level, role, sessionAction, issueId, issueTitle, issueUrl); const announcement = buildAnnouncement(level, role, session.action, issueId, issueTitle, issueUrl);
return { sessionAction, sessionKey: sessionKey!, level, model, announcement }; return { sessionAction: session.action, sessionKey: session.key, level, model, announcement };
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -206,16 +204,28 @@ async function ensureSession(
action: "spawn" | "send", action: "spawn" | "send",
existingKey: string | null, existingKey: string | null,
opts: { agentId?: string; projectName: string; role: string; level: string; model: string }, opts: { agentId?: string; projectName: string; role: string; level: string; model: string },
): Promise<string> { ): Promise<{ key: string; action: "spawn" | "send" }> {
if (action === "send") return existingKey!; const expectedKey = `agent:${opts.agentId ?? "unknown"}:subagent:${opts.projectName}-${opts.role}-${opts.level}`;
const sessionKey = `agent:${opts.agentId ?? "unknown"}:subagent:${opts.projectName}-${opts.role}-${opts.level}`; // Reuse: validate stored key matches expected format, then verify session exists
await execFileAsync( if (action === "send" && existingKey === expectedKey) {
"openclaw", try {
["gateway", "call", "sessions.patch", "--params", JSON.stringify({ key: sessionKey, model: opts.model })], await runCommand(
{ timeout: 30_000 }, ["openclaw", "gateway", "call", "sessions.patch", "--params", JSON.stringify({ key: existingKey, model: opts.model })],
{ timeoutMs: 30_000 },
);
return { key: existingKey, action: "send" };
} catch {
// Session gone (deleted, cleanup, etc.) — fall through to spawn
}
}
// Spawn: create fresh session (also handles stale/mismatched keys)
await runCommand(
["openclaw", "gateway", "call", "sessions.patch", "--params", JSON.stringify({ key: expectedKey, model: opts.model })],
{ timeoutMs: 30_000 },
); );
return sessionKey; return { key: expectedKey, action: "spawn" };
} }
function sendToAgent( function sendToAgent(
@@ -231,27 +241,23 @@ function sendToAgent(
lane: "subagent", lane: "subagent",
...(opts.orchestratorSessionKey ? { spawnedBy: opts.orchestratorSessionKey } : {}), ...(opts.orchestratorSessionKey ? { spawnedBy: opts.orchestratorSessionKey } : {}),
}); });
const child = spawn( // Fire-and-forget: long-running agent turn, don't await
"openclaw", runCommand(
["gateway", "call", "agent", "--params", gatewayParams, "--expect-final", "--json"], ["openclaw", "gateway", "call", "agent", "--params", gatewayParams, "--expect-final", "--json"],
{ detached: true, stdio: "ignore" }, { timeoutMs: 600_000 },
); ).catch(() => { /* fire-and-forget */ });
child.unref();
} }
async function recordWorkerState( async function recordWorkerState(
workspaceDir: string, groupId: string, role: "dev" | "qa", workspaceDir: string, groupId: string, role: "dev" | "qa",
opts: { issueId: number; level: string; sessionKey: string; sessionAction: "spawn" | "send" }, opts: { issueId: number; level: string; sessionKey: string; sessionAction: "spawn" | "send" },
): Promise<void> { ): Promise<void> {
const params: { issueId: string; level: string; sessionKey?: string; startTime: string } = { await activateWorker(workspaceDir, groupId, role, {
issueId: String(opts.issueId), issueId: String(opts.issueId),
level: opts.level, level: opts.level,
sessionKey: opts.sessionKey,
startTime: new Date().toISOString(), startTime: new Date().toISOString(),
}; });
if (opts.sessionAction === "spawn") {
params.sessionKey = opts.sessionKey;
}
await activateWorker(workspaceDir, groupId, role, params);
} }
async function auditDispatch( async function auditDispatch(

View File

@@ -7,12 +7,9 @@
* - workerStart: Worker spawned/resumed for a task (→ project group) * - workerStart: Worker spawned/resumed for a task (→ project group)
* - workerComplete: Worker completed task (→ project group) * - workerComplete: Worker completed task (→ project group)
*/ */
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { log as auditLog } from "./audit.js"; import { log as auditLog } from "./audit.js";
import type { TickAction } from "./services/tick.js"; import type { TickAction } from "./services/tick.js";
import { runCommand } from "./run-command.js";
const execFileAsync = promisify(execFile);
/** Per-event-type toggle. All default to true — set to false to suppress. */ /** Per-event-type toggle. All default to true — set to false to suppress. */
export type NotificationConfig = Partial<Record<NotifyEvent["type"], boolean>>; export type NotificationConfig = Partial<Record<NotifyEvent["type"], boolean>>;
@@ -95,9 +92,9 @@ async function sendMessage(
workspaceDir: string, workspaceDir: string,
): Promise<boolean> { ): Promise<boolean> {
try { try {
await execFileAsync( await runCommand(
"openclaw",
[ [
"openclaw",
"message", "message",
"send", "send",
"--channel", "--channel",
@@ -108,7 +105,7 @@ async function sendMessage(
message, message,
"--json", "--json",
], ],
{ timeout: 30_000 }, { timeoutMs: 30_000 },
); );
return true; return true;
} catch (err) { } catch (err) {

View File

@@ -1,11 +1,6 @@
/** /**
* GitHubProvider — IssueProvider implementation using gh CLI. * GitHubProvider — IssueProvider implementation using gh CLI.
*/ */
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { writeFile, unlink } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { import {
type IssueProvider, type IssueProvider,
type Issue, type Issue,
@@ -13,8 +8,7 @@ import {
STATE_LABELS, STATE_LABELS,
LABEL_COLORS, LABEL_COLORS,
} from "./provider.js"; } from "./provider.js";
import { runCommand } from "../run-command.js";
const execFileAsync = promisify(execFile);
type GhIssue = { type GhIssue = {
number: number; number: number;
@@ -37,8 +31,8 @@ export class GitHubProvider implements IssueProvider {
constructor(opts: { repoPath: string }) { this.repoPath = opts.repoPath; } constructor(opts: { repoPath: string }) { this.repoPath = opts.repoPath; }
private async gh(args: string[]): Promise<string> { private async gh(args: string[]): Promise<string> {
const { stdout } = await execFileAsync("gh", args, { cwd: this.repoPath, timeout: 30_000 }); const result = await runCommand(["gh", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
return stdout.trim(); return result.stdout.trim();
} }
async ensureLabel(name: string, color: string): Promise<void> { async ensureLabel(name: string, color: string): Promise<void> {
@@ -51,16 +45,12 @@ export class GitHubProvider implements IssueProvider {
} }
async createIssue(title: string, description: string, label: StateLabel, assignees?: string[]): Promise<Issue> { async createIssue(title: string, description: string, label: StateLabel, assignees?: string[]): Promise<Issue> {
const tempFile = join(tmpdir(), `devclaw-issue-${Date.now()}.md`); const args = ["issue", "create", "--title", title, "--body", description, "--label", label];
await writeFile(tempFile, description, "utf-8"); if (assignees?.length) args.push("--assignee", assignees.join(","));
try { const url = await this.gh(args);
const args = ["issue", "create", "--title", title, "--body-file", tempFile, "--label", label]; const match = url.match(/\/issues\/(\d+)$/);
if (assignees?.length) args.push("--assignee", assignees.join(",")); if (!match) throw new Error(`Failed to parse issue URL: ${url}`);
const url = await this.gh(args); return this.getIssue(parseInt(match[1], 10));
const match = url.match(/\/issues\/(\d+)$/);
if (!match) throw new Error(`Failed to parse issue URL: ${url}`);
return this.getIssue(parseInt(match[1], 10));
} finally { try { await unlink(tempFile); } catch { /* ignore */ } }
} }
async listIssuesByLabel(label: StateLabel): Promise<Issue[]> { async listIssuesByLabel(label: StateLabel): Promise<Issue[]> {
@@ -111,10 +101,7 @@ export class GitHubProvider implements IssueProvider {
} }
async addComment(issueId: number, body: string): Promise<void> { async addComment(issueId: number, body: string): Promise<void> {
const tempFile = join(tmpdir(), `devclaw-comment-${Date.now()}.md`); await this.gh(["issue", "comment", String(issueId), "--body", body]);
await writeFile(tempFile, body, "utf-8");
try { await this.gh(["issue", "comment", String(issueId), "--body-file", tempFile]); }
finally { try { await unlink(tempFile); } catch { /* ignore */ } }
} }
async healthCheck(): Promise<boolean> { async healthCheck(): Promise<boolean> {

View File

@@ -1,11 +1,6 @@
/** /**
* GitLabProvider — IssueProvider implementation using glab CLI. * GitLabProvider — IssueProvider implementation using glab CLI.
*/ */
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { writeFile, unlink } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { import {
type IssueProvider, type IssueProvider,
type Issue, type Issue,
@@ -13,16 +8,15 @@ import {
STATE_LABELS, STATE_LABELS,
LABEL_COLORS, LABEL_COLORS,
} from "./provider.js"; } from "./provider.js";
import { runCommand } from "../run-command.js";
const execFileAsync = promisify(execFile);
export class GitLabProvider implements IssueProvider { export class GitLabProvider implements IssueProvider {
private repoPath: string; private repoPath: string;
constructor(opts: { repoPath: string }) { this.repoPath = opts.repoPath; } constructor(opts: { repoPath: string }) { this.repoPath = opts.repoPath; }
private async glab(args: string[]): Promise<string> { private async glab(args: string[]): Promise<string> {
const { stdout } = await execFileAsync("glab", args, { cwd: this.repoPath, timeout: 30_000 }); const result = await runCommand(["glab", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
return stdout.trim(); return result.stdout.trim();
} }
async ensureLabel(name: string, color: string): Promise<void> { async ensureLabel(name: string, color: string): Promise<void> {
@@ -35,19 +29,15 @@ export class GitLabProvider implements IssueProvider {
} }
async createIssue(title: string, description: string, label: StateLabel, assignees?: string[]): Promise<Issue> { async createIssue(title: string, description: string, label: StateLabel, assignees?: string[]): Promise<Issue> {
const tempFile = join(tmpdir(), `devclaw-issue-${Date.now()}.md`); // Pass description directly as argv — runCommand uses spawn (no shell),
await writeFile(tempFile, description, "utf-8"); // so no escaping issues with special characters.
try { const args = ["issue", "create", "--title", title, "--description", description, "--label", label];
const { exec } = await import("node:child_process"); if (assignees?.length) args.push("--assignee", assignees.join(","));
const execAsync = promisify(exec); const stdout = await this.glab(args);
let cmd = `glab issue create --title "${title.replace(/"/g, '\\"')}" --description "$(cat ${tempFile})" --label "${label}"`; // glab issue create returns the issue URL
if (assignees?.length) cmd += ` --assignee "${assignees.join(",")}"`; const match = stdout.match(/\/issues\/(\d+)/);
const { stdout } = await execAsync(cmd, { cwd: this.repoPath, timeout: 30_000 }); if (!match) throw new Error(`Failed to parse issue URL: ${stdout}`);
// glab issue create returns the issue URL return this.getIssue(parseInt(match[1], 10));
const match = stdout.trim().match(/\/issues\/(\d+)/);
if (!match) throw new Error(`Failed to parse issue URL: ${stdout.trim()}`);
return this.getIssue(parseInt(match[1], 10));
} finally { try { await unlink(tempFile); } catch { /* ignore */ } }
} }
async listIssuesByLabel(label: StateLabel): Promise<Issue[]> { async listIssuesByLabel(label: StateLabel): Promise<Issue[]> {
@@ -101,13 +91,8 @@ export class GitLabProvider implements IssueProvider {
} }
async addComment(issueId: number, body: string): Promise<void> { async addComment(issueId: number, body: string): Promise<void> {
const tempFile = join(tmpdir(), `devclaw-comment-${Date.now()}.md`); // Pass message directly as argv — no shell escaping needed with spawn
await writeFile(tempFile, body, "utf-8"); await this.glab(["issue", "note", String(issueId), "--message", body]);
try {
const { exec } = await import("node:child_process");
const execAsync = promisify(exec);
await execAsync(`glab issue note ${issueId} --message "$(cat ${tempFile})"`, { cwd: this.repoPath, timeout: 30_000 });
} finally { try { await unlink(tempFile); } catch { /* ignore */ } }
} }
async healthCheck(): Promise<boolean> { async healthCheck(): Promise<boolean> {

View File

@@ -1,11 +1,11 @@
/** /**
* Provider factory — auto-detects GitHub vs GitLab from git remote. * Provider factory — auto-detects GitHub vs GitLab from git remote.
*/ */
import { execFileSync } from "node:child_process";
import type { IssueProvider } from "./provider.js"; import type { IssueProvider } from "./provider.js";
import { GitLabProvider } from "./gitlab.js"; import { GitLabProvider } from "./gitlab.js";
import { GitHubProvider } from "./github.js"; import { GitHubProvider } from "./github.js";
import { resolveRepoPath } from "../projects.js"; import { resolveRepoPath } from "../projects.js";
import { runCommand } from "../run-command.js";
export type ProviderOptions = { export type ProviderOptions = {
provider?: "gitlab" | "github"; provider?: "gitlab" | "github";
@@ -18,19 +18,19 @@ export type ProviderWithType = {
type: "github" | "gitlab"; type: "github" | "gitlab";
}; };
function detectProvider(repoPath: string): "gitlab" | "github" { async function detectProvider(repoPath: string): Promise<"gitlab" | "github"> {
try { try {
const url = execFileSync("git", ["remote", "get-url", "origin"], { cwd: repoPath, timeout: 5_000 }).toString().trim(); const result = await runCommand(["git", "remote", "get-url", "origin"], { timeoutMs: 5_000, cwd: repoPath });
return url.includes("github.com") ? "github" : "gitlab"; return result.stdout.trim().includes("github.com") ? "github" : "gitlab";
} catch { } catch {
return "gitlab"; return "gitlab";
} }
} }
export function createProvider(opts: ProviderOptions): ProviderWithType { export async function createProvider(opts: ProviderOptions): Promise<ProviderWithType> {
const repoPath = opts.repoPath ?? (opts.repo ? resolveRepoPath(opts.repo) : null); const repoPath = opts.repoPath ?? (opts.repo ? resolveRepoPath(opts.repo) : null);
if (!repoPath) throw new Error("Either repoPath or repo must be provided"); if (!repoPath) throw new Error("Either repoPath or repo must be provided");
const type = opts.provider ?? detectProvider(repoPath); const type = opts.provider ?? await detectProvider(repoPath);
const provider = type === "github" ? new GitHubProvider({ repoPath }) : new GitLabProvider({ repoPath }); const provider = type === "github" ? new GitHubProvider({ repoPath }) : new GitLabProvider({ repoPath });
return { provider, type }; return { provider, type };
} }

28
lib/run-command.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* run-command.ts — Thin wrapper around the plugin SDK's runCommandWithTimeout.
*
* Initialised once during plugin registration, then available to all modules
* without threading the plugin API through every function signature.
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
type RunCommand = OpenClawPluginApi["runtime"]["system"]["runCommandWithTimeout"];
let _runCommand: RunCommand | undefined;
/**
* Store the plugin SDK's runCommandWithTimeout. Call once in register().
*/
export function initRunCommand(api: OpenClawPluginApi): void {
_runCommand = api.runtime.system.runCommandWithTimeout;
}
/**
* Run an external command via the plugin SDK.
*/
export const runCommand: RunCommand = (...args) => {
if (!_runCommand) {
throw new Error("runCommand not initialised — call initRunCommand(api) first");
}
return _runCommand(...args);
};

View File

@@ -84,7 +84,7 @@ export function registerHeartbeatService(api: OpenClawPluginApi) {
return; return;
} }
const agents = discoverAgents(ctx.config); const agents = discoverAgents(api.config);
if (agents.length === 0) { if (agents.length === 0) {
ctx.logger.warn("work_heartbeat service: no DevClaw agents registered"); ctx.logger.warn("work_heartbeat service: no DevClaw agents registered");
return; return;
@@ -117,20 +117,39 @@ export function registerHeartbeatService(api: OpenClawPluginApi) {
/** /**
* Discover DevClaw agents by scanning which agent workspaces have projects. * Discover DevClaw agents by scanning which agent workspaces have projects.
* Self-discovering: any agent whose workspace contains projects/projects.json is processed. * Self-discovering: any agent whose workspace contains projects/projects.json is processed.
* Also checks the default workspace (agents.defaults.workspace) for projects.
*/ */
function discoverAgents(config: ServiceContext["config"]): Agent[] { function discoverAgents(config: {
const agentsList = config.agents?.list || []; agents?: {
list?: Array<{ id: string; workspace?: string }>;
defaults?: { workspace?: string };
};
}): Agent[] {
const seen = new Set<string>();
const agents: Agent[] = [];
return agentsList // Check explicit agent list
.filter((a): a is { id: string; workspace: string } => { for (const a of config.agents?.list || []) {
if (!a.workspace) return false; if (!a.workspace) continue;
try { try {
return fs.existsSync(path.join(a.workspace, "projects", "projects.json")); if (fs.existsSync(path.join(a.workspace, "projects", "projects.json"))) {
} catch { agents.push({ agentId: a.id, workspace: a.workspace });
return false; seen.add(a.workspace);
} }
}) } catch { /* skip */ }
.map((a) => ({ agentId: a.id, workspace: a.workspace })); }
// Check default workspace (used when no explicit agents are registered)
const defaultWorkspace = config.agents?.defaults?.workspace;
if (defaultWorkspace && !seen.has(defaultWorkspace)) {
try {
if (fs.existsSync(path.join(defaultWorkspace, "projects", "projects.json"))) {
agents.push({ agentId: "main", workspace: defaultWorkspace });
}
} catch { /* skip */ }
}
return agents;
} }
/** /**
@@ -286,7 +305,7 @@ async function performHealthPass(
groupId: string, groupId: string,
project: any, project: any,
): Promise<number> { ): Promise<number> {
const { provider } = createProvider({ repo: project.repo }); const { provider } = await createProvider({ repo: project.repo });
let fixedCount = 0; let fixedCount = 0;
for (const role of ["dev", "qa"] as const) { for (const role of ["dev", "qa"] as const) {

View File

@@ -3,12 +3,9 @@
* *
* Replaces 7 if-blocks with a data-driven lookup table. * Replaces 7 if-blocks with a data-driven lookup table.
*/ */
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { StateLabel, IssueProvider } from "../providers/provider.js"; import type { StateLabel, IssueProvider } from "../providers/provider.js";
import { deactivateWorker } from "../projects.js"; import { deactivateWorker } from "../projects.js";
import { runCommand } from "../run-command.js";
const execFileAsync = promisify(execFile);
export type CompletionRule = { export type CompletionRule = {
from: StateLabel; from: StateLabel;
@@ -84,7 +81,7 @@ export async function executeCompletion(opts: {
// Git pull (dev:done) // Git pull (dev:done)
if (rule.gitPull) { if (rule.gitPull) {
try { try {
await execFileAsync("git", ["pull"], { cwd: repoPath, timeout: 30_000 }); await runCommand(["git", "pull"], { timeoutMs: 30_000, cwd: repoPath });
} catch { /* best-effort */ } } catch { /* best-effort */ }
} }

View File

@@ -33,7 +33,7 @@ export function getRoleForLabel(label: QueueLabel): "dev" | "qa" {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function fetchProjectQueues(project: Project): Promise<Record<QueueLabel, Issue[]>> { export async function fetchProjectQueues(project: Project): Promise<Record<QueueLabel, Issue[]>> {
const { provider } = createProvider({ repo: project.repo }); const { provider } = await createProvider({ repo: project.repo });
const labels: QueueLabel[] = ["To Improve", "To Test", "To Do"]; const labels: QueueLabel[] = ["To Improve", "To Test", "To Do"];
const queues: Record<QueueLabel, Issue[]> = { "To Improve": [], "To Test": [], "To Do": [] }; const queues: Record<QueueLabel, Issue[]> = { "To Improve": [], "To Test": [], "To Do": [] };

View File

@@ -124,7 +124,7 @@ export async function projectTick(opts: {
const project = (await readProjects(workspaceDir)).projects[groupId]; const project = (await readProjects(workspaceDir)).projects[groupId];
if (!project) return { pickups: [], skipped: [{ reason: `Project not found: ${groupId}` }] }; if (!project) return { pickups: [], skipped: [{ reason: `Project not found: ${groupId}` }] };
const provider = opts.provider ?? createProvider({ repo: project.repo }).provider; const provider = opts.provider ?? (await createProvider({ repo: project.repo })).provider;
const roleExecution = project.roleExecution ?? "parallel"; const roleExecution = project.roleExecution ?? "parallel";
const roles: Array<"dev" | "qa"> = targetRole ? [targetRole] : ["dev", "qa"]; const roles: Array<"dev" | "qa"> = targetRole ? [targetRole] : ["dev", "qa"];

View File

@@ -1,13 +1,10 @@
/** /**
* setup/agent.ts — Agent creation and workspace resolution. * setup/agent.ts — Agent creation and workspace resolution.
*/ */
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { runCommand } from "../run-command.js";
const execFileAsync = promisify(execFile);
/** /**
* Create a new agent via `openclaw agents add`. * Create a new agent via `openclaw agents add`.
@@ -27,7 +24,7 @@ export async function createAgent(
if (channelBinding) args.push("--bind", channelBinding); if (channelBinding) args.push("--bind", channelBinding);
try { try {
await execFileAsync("openclaw", args, { timeout: 30_000 }); await runCommand(["openclaw", ...args], { timeoutMs: 30_000 });
} catch (err) { } catch (err) {
throw new Error(`Failed to create agent "${name}": ${(err as Error).message}`); throw new Error(`Failed to create agent "${name}": ${(err as Error).message}`);
} }

View File

@@ -3,10 +3,7 @@
* *
* Uses an LLM to understand model capabilities and assign optimal models to DevClaw roles. * Uses an LLM to understand model capabilities and assign optimal models to DevClaw roles.
*/ */
import { execSync } from "node:child_process"; import { runCommand } from "../run-command.js";
import { writeFileSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
export type ModelAssignment = { export type ModelAssignment = {
dev: { dev: {
@@ -76,27 +73,18 @@ Return ONLY a JSON object in this exact format (no markdown, no explanation):
} }
}`; }`;
// Write prompt to temp file for safe passing to shell
const tmpFile = join(tmpdir(), `devclaw-model-select-${Date.now()}.txt`);
writeFileSync(tmpFile, prompt, "utf-8");
try { try {
// Call openclaw agent using current session context if available const sessionId = sessionKey ?? "devclaw-model-selection";
const sessionFlag = sessionKey
? `--session-id "${sessionKey}"`
: `--session-id devclaw-model-selection`;
const result = execSync( const result = await runCommand(
`openclaw agent --local ${sessionFlag} --message "$(cat ${tmpFile})" --json`, ["openclaw", "agent", "--local", "--session-id", sessionId, "--message", prompt, "--json"],
{ { timeoutMs: 30_000 },
encoding: "utf-8", );
timeout: 30000,
stdio: ["pipe", "pipe", "ignore"], const output = result.stdout.trim();
},
).trim();
// Parse the response from openclaw agent --json // Parse the response from openclaw agent --json
const lines = result.split("\n"); const lines = output.split("\n");
const jsonStartIndex = lines.findIndex((line) => line.trim().startsWith("{")); const jsonStartIndex = lines.findIndex((line) => line.trim().startsWith("{"));
if (jsonStartIndex === -1) { if (jsonStartIndex === -1) {
@@ -146,12 +134,5 @@ Return ONLY a JSON object in this exact format (no markdown, no explanation):
} catch (err) { } catch (err) {
console.error("LLM model selection failed:", (err as Error).message); console.error("LLM model selection failed:", (err as Error).message);
return null; return null;
} finally {
// Clean up temp file
try {
unlinkSync(tmpFile);
} catch {
// Ignore cleanup errors
}
} }
} }

View File

@@ -1,9 +1,9 @@
/** /**
* model-fetcher.ts — Shared helper for fetching OpenClaw models without logging. * model-fetcher.ts — Shared helper for fetching OpenClaw models.
* *
* Uses execSync to bypass OpenClaw's command logging infrastructure. * Uses the plugin SDK's runCommand to run openclaw CLI commands.
*/ */
import { execSync } from "node:child_process"; import { runCommand } from "../run-command.js";
export type OpenClawModelRow = { export type OpenClawModelRow = {
key: string; key: string;
@@ -17,25 +17,19 @@ export type OpenClawModelRow = {
}; };
/** /**
* Fetch all models from OpenClaw without logging. * Fetch all models from OpenClaw.
* *
* @param allModels - If true, fetches all models (--all flag). If false, only authenticated models. * @param allModels - If true, fetches all models (--all flag). If false, only authenticated models.
* @returns Array of model objects from OpenClaw's model registry * @returns Array of model objects from OpenClaw's model registry
*/ */
export function fetchModels(allModels = true): OpenClawModelRow[] { export async function fetchModels(allModels = true): Promise<OpenClawModelRow[]> {
try { try {
const command = allModels const args = allModels
? "openclaw models list --all --json" ? ["openclaw", "models", "list", "--all", "--json"]
: "openclaw models list --json"; : ["openclaw", "models", "list", "--json"];
// Use execSync directly to bypass OpenClaw's command logging const result = await runCommand(args, { timeoutMs: 10_000 });
const output = execSync(command, { const output = result.stdout.trim();
encoding: "utf-8",
timeout: 10000,
cwd: process.cwd(),
// Suppress stderr to avoid any error messages
stdio: ["pipe", "pipe", "ignore"],
}).trim();
if (!output) { if (!output) {
throw new Error("Empty output from openclaw models list"); throw new Error("Empty output from openclaw models list");
@@ -75,7 +69,6 @@ export function fetchModels(allModels = true): OpenClawModelRow[] {
/** /**
* Fetch only authenticated models (available: true). * Fetch only authenticated models (available: true).
*/ */
export function fetchAuthenticatedModels(): OpenClawModelRow[] { export async function fetchAuthenticatedModels(): Promise<OpenClawModelRow[]> {
// Use --all flag but suppress logging via stdio in fetchModels() return (await fetchModels(true)).filter((m) => m.available === true);
return fetchModels(true).filter((m) => m.available === true);
} }

View File

@@ -39,7 +39,7 @@ export async function resolveProject(
/** /**
* Create an issue provider for a project. * Create an issue provider for a project.
*/ */
export function resolveProvider(project: Project): ProviderWithType { export async function resolveProvider(project: Project): Promise<ProviderWithType> {
return createProvider({ repo: project.repo }); return createProvider({ repo: project.repo });
} }

View File

@@ -21,7 +21,7 @@ async function getAuthenticatedModels(
api: OpenClawPluginApi, api: OpenClawPluginApi,
): Promise<Array<{ model: string; provider: string; authenticated: boolean }>> { ): Promise<Array<{ model: string; provider: string; authenticated: boolean }>> {
try { try {
const models = fetchAuthenticatedModels(); const models = await fetchAuthenticatedModels();
// Map to the format expected by assignModels() // Map to the format expected by assignModels()
return models.map((m) => { return models.map((m) => {

View File

@@ -39,7 +39,7 @@ export function createHealthTool() {
for (const pid of projectIds) { for (const pid of projectIds) {
const project = getProject(data, pid); const project = getProject(data, pid);
if (!project) continue; if (!project) continue;
const { provider } = resolveProvider(project); const { provider } = await resolveProvider(project);
for (const role of ["dev", "qa"] as const) { for (const role of ["dev", "qa"] as const) {
const fixes = await checkWorkerHealth({ const fixes = await checkWorkerHealth({

View File

@@ -124,7 +124,7 @@ export function createProjectRegisterTool() {
const repoPath = resolveRepoPath(repo); const repoPath = resolveRepoPath(repo);
// 3. Create provider and verify it works // 3. Create provider and verify it works
const { provider, type: providerType } = createProvider({ repo }); const { provider, type: providerType } = await createProvider({ repo });
const healthy = await provider.healthCheck(); const healthy = await provider.healthCheck();
if (!healthy) { if (!healthy) {

View File

@@ -68,7 +68,7 @@ Examples:
} }
const { project } = await resolveProject(workspaceDir, groupId); const { project } = await resolveProject(workspaceDir, groupId);
const { provider, type: providerType } = resolveProvider(project); const { provider, type: providerType } = await resolveProvider(project);
const issue = await provider.getIssue(issueId); const issue = await provider.getIssue(issueId);

View File

@@ -70,7 +70,7 @@ Examples:
const workspaceDir = requireWorkspaceDir(ctx); const workspaceDir = requireWorkspaceDir(ctx);
const { project } = await resolveProject(workspaceDir, groupId); const { project } = await resolveProject(workspaceDir, groupId);
const { provider, type: providerType } = resolveProvider(project); const { provider, type: providerType } = await resolveProvider(project);
const issue = await provider.createIssue(title, description, label, assignees); const issue = await provider.createIssue(title, description, label, assignees);

View File

@@ -60,7 +60,7 @@ Examples:
const workspaceDir = requireWorkspaceDir(ctx); const workspaceDir = requireWorkspaceDir(ctx);
const { project } = await resolveProject(workspaceDir, groupId); const { project } = await resolveProject(workspaceDir, groupId);
const { provider, type: providerType } = resolveProvider(project); const { provider, type: providerType } = await resolveProvider(project);
const issue = await provider.getIssue(issueId); const issue = await provider.getIssue(issueId);
const currentState = provider.getCurrentStateLabel(issue); const currentState = provider.getCurrentStateLabel(issue);

View File

@@ -54,7 +54,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
const issueId = worker.issueId ? Number(worker.issueId.split(",")[0]) : null; const issueId = worker.issueId ? Number(worker.issueId.split(",")[0]) : null;
if (!issueId) throw new Error(`No issueId for active ${role.toUpperCase()} on ${project.name}`); if (!issueId) throw new Error(`No issueId for active ${role.toUpperCase()} on ${project.name}`);
const { provider } = resolveProvider(project); const { provider } = await resolveProvider(project);
const repoPath = resolveRepoPath(project.repo); const repoPath = resolveRepoPath(project.repo);
const issue = await provider.getIssue(issueId); const issue = await provider.getIssue(issueId);

View File

@@ -42,7 +42,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
if (!groupId) throw new Error("projectGroupId is required"); if (!groupId) throw new Error("projectGroupId is required");
const { project } = await resolveProject(workspaceDir, groupId); const { project } = await resolveProject(workspaceDir, groupId);
const { provider } = resolveProvider(project); const { provider } = await resolveProvider(project);
// Find issue // Find issue
let issue: { iid: number; title: string; description: string; labels: string[]; web_url: string; state: string }; let issue: { iid: number; title: string; description: string; labels: string[]; web_url: string; state: string };