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 { registerCli } from "./lib/cli.js";
import { registerHeartbeatService } from "./lib/services/heartbeat.js";
import { initRunCommand } from "./lib/run-command.js";
const plugin = {
id: "devclaw",
@@ -86,6 +87,8 @@ const plugin = {
},
register(api: OpenClawPluginApi) {
initRunCommand(api);
// Worker lifecycle
api.registerTool(createWorkStartTool(api), { names: ["work_start"] });
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,
* state update (activateWorker), and audit logging.
*/
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { log as auditLog } from "./audit.js";
import { runCommand } from "./run-command.js";
import {
type Project,
activateWorker,
@@ -17,8 +16,6 @@ import {
} from "./projects.js";
import { resolveModel, levelEmoji } from "./tiers.js";
const execFileAsync = promisify(execFile);
export type DispatchOpts = {
workspaceDir: string;
agentId?: string;
@@ -142,30 +139,31 @@ export async function dispatchTask(
await transitionLabel(issueId, fromLabel, toLabel);
let sessionKey = existingSessionKey;
let dispatched = false;
let session: { key: string; action: "spawn" | "send" };
try {
sessionKey = await ensureSession(sessionAction, sessionKey, {
session = await ensureSession(sessionAction, existingSessionKey, {
agentId, projectName: project.name, role, level, model,
});
await sendToAgent(sessionKey!, taskMessage, {
sendToAgent(session.key, taskMessage, {
agentId, projectName: project.name, issueId, role,
orchestratorSessionKey: opts.sessionKey,
});
dispatched = true;
// Always store session key — a "send" may have fallen back to "spawn"
await recordWorkerState(workspaceDir, groupId, role, {
issueId, level, sessionKey: sessionKey!, sessionAction,
issueId, level, sessionKey: session.key, sessionAction: session.action,
});
} catch (err) {
if (dispatched) {
await auditLog(workspaceDir, "work_start", {
project: project.name, groupId, issue: issueId, role,
warning: "State update failed after successful dispatch",
error: (err as Error).message, sessionKey,
error: (err as Error).message, sessionKey: session!.key,
});
throw new Error(
`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, {
project: project.name, groupId, issueId, issueTitle,
role, level, model, sessionAction, sessionKey: sessionKey!,
role, level, model, sessionAction: session.action, sessionKey: session.key,
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",
existingKey: string | null,
opts: { agentId?: string; projectName: string; role: string; level: string; model: string },
): Promise<string> {
if (action === "send") return existingKey!;
): Promise<{ key: string; action: "spawn" | "send" }> {
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}`;
await execFileAsync(
"openclaw",
["gateway", "call", "sessions.patch", "--params", JSON.stringify({ key: sessionKey, model: opts.model })],
{ timeout: 30_000 },
// Reuse: validate stored key matches expected format, then verify session exists
if (action === "send" && existingKey === expectedKey) {
try {
await runCommand(
["openclaw", "gateway", "call", "sessions.patch", "--params", JSON.stringify({ key: existingKey, model: opts.model })],
{ timeoutMs: 30_000 },
);
return sessionKey;
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 { key: expectedKey, action: "spawn" };
}
function sendToAgent(
@@ -231,27 +241,23 @@ function sendToAgent(
lane: "subagent",
...(opts.orchestratorSessionKey ? { spawnedBy: opts.orchestratorSessionKey } : {}),
});
const child = spawn(
"openclaw",
["gateway", "call", "agent", "--params", gatewayParams, "--expect-final", "--json"],
{ detached: true, stdio: "ignore" },
);
child.unref();
// Fire-and-forget: long-running agent turn, don't await
runCommand(
["openclaw", "gateway", "call", "agent", "--params", gatewayParams, "--expect-final", "--json"],
{ timeoutMs: 600_000 },
).catch(() => { /* fire-and-forget */ });
}
async function recordWorkerState(
workspaceDir: string, groupId: string, role: "dev" | "qa",
opts: { issueId: number; level: string; sessionKey: string; sessionAction: "spawn" | "send" },
): Promise<void> {
const params: { issueId: string; level: string; sessionKey?: string; startTime: string } = {
await activateWorker(workspaceDir, groupId, role, {
issueId: String(opts.issueId),
level: opts.level,
sessionKey: opts.sessionKey,
startTime: new Date().toISOString(),
};
if (opts.sessionAction === "spawn") {
params.sessionKey = opts.sessionKey;
}
await activateWorker(workspaceDir, groupId, role, params);
});
}
async function auditDispatch(

View File

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

View File

@@ -1,11 +1,6 @@
/**
* 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 {
type IssueProvider,
type Issue,
@@ -13,8 +8,7 @@ import {
STATE_LABELS,
LABEL_COLORS,
} from "./provider.js";
const execFileAsync = promisify(execFile);
import { runCommand } from "../run-command.js";
type GhIssue = {
number: number;
@@ -37,8 +31,8 @@ export class GitHubProvider implements IssueProvider {
constructor(opts: { repoPath: string }) { this.repoPath = opts.repoPath; }
private async gh(args: string[]): Promise<string> {
const { stdout } = await execFileAsync("gh", args, { cwd: this.repoPath, timeout: 30_000 });
return stdout.trim();
const result = await runCommand(["gh", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
return result.stdout.trim();
}
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> {
const tempFile = join(tmpdir(), `devclaw-issue-${Date.now()}.md`);
await writeFile(tempFile, description, "utf-8");
try {
const args = ["issue", "create", "--title", title, "--body-file", tempFile, "--label", label];
const args = ["issue", "create", "--title", title, "--body", description, "--label", label];
if (assignees?.length) args.push("--assignee", assignees.join(","));
const url = await this.gh(args);
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[]> {
@@ -111,10 +101,7 @@ export class GitHubProvider implements IssueProvider {
}
async addComment(issueId: number, body: string): Promise<void> {
const tempFile = join(tmpdir(), `devclaw-comment-${Date.now()}.md`);
await writeFile(tempFile, body, "utf-8");
try { await this.gh(["issue", "comment", String(issueId), "--body-file", tempFile]); }
finally { try { await unlink(tempFile); } catch { /* ignore */ } }
await this.gh(["issue", "comment", String(issueId), "--body", body]);
}
async healthCheck(): Promise<boolean> {

View File

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

View File

@@ -1,11 +1,11 @@
/**
* Provider factory — auto-detects GitHub vs GitLab from git remote.
*/
import { execFileSync } from "node:child_process";
import type { IssueProvider } from "./provider.js";
import { GitLabProvider } from "./gitlab.js";
import { GitHubProvider } from "./github.js";
import { resolveRepoPath } from "../projects.js";
import { runCommand } from "../run-command.js";
export type ProviderOptions = {
provider?: "gitlab" | "github";
@@ -18,19 +18,19 @@ export type ProviderWithType = {
type: "github" | "gitlab";
};
function detectProvider(repoPath: string): "gitlab" | "github" {
async function detectProvider(repoPath: string): Promise<"gitlab" | "github"> {
try {
const url = execFileSync("git", ["remote", "get-url", "origin"], { cwd: repoPath, timeout: 5_000 }).toString().trim();
return url.includes("github.com") ? "github" : "gitlab";
const result = await runCommand(["git", "remote", "get-url", "origin"], { timeoutMs: 5_000, cwd: repoPath });
return result.stdout.trim().includes("github.com") ? "github" : "gitlab";
} catch {
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);
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 });
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;
}
const agents = discoverAgents(ctx.config);
const agents = discoverAgents(api.config);
if (agents.length === 0) {
ctx.logger.warn("work_heartbeat service: no DevClaw agents registered");
return;
@@ -117,20 +117,39 @@ export function registerHeartbeatService(api: OpenClawPluginApi) {
/**
* Discover DevClaw agents by scanning which agent workspaces have projects.
* 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[] {
const agentsList = config.agents?.list || [];
function discoverAgents(config: {
agents?: {
list?: Array<{ id: string; workspace?: string }>;
defaults?: { workspace?: string };
};
}): Agent[] {
const seen = new Set<string>();
const agents: Agent[] = [];
return agentsList
.filter((a): a is { id: string; workspace: string } => {
if (!a.workspace) return false;
// Check explicit agent list
for (const a of config.agents?.list || []) {
if (!a.workspace) continue;
try {
return fs.existsSync(path.join(a.workspace, "projects", "projects.json"));
} catch {
return false;
if (fs.existsSync(path.join(a.workspace, "projects", "projects.json"))) {
agents.push({ agentId: a.id, workspace: a.workspace });
seen.add(a.workspace);
}
})
.map((a) => ({ agentId: a.id, workspace: a.workspace }));
} catch { /* skip */ }
}
// 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,
project: any,
): Promise<number> {
const { provider } = createProvider({ repo: project.repo });
const { provider } = await createProvider({ repo: project.repo });
let fixedCount = 0;
for (const role of ["dev", "qa"] as const) {

View File

@@ -3,12 +3,9 @@
*
* 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 { deactivateWorker } from "../projects.js";
const execFileAsync = promisify(execFile);
import { runCommand } from "../run-command.js";
export type CompletionRule = {
from: StateLabel;
@@ -84,7 +81,7 @@ export async function executeCompletion(opts: {
// Git pull (dev:done)
if (rule.gitPull) {
try {
await execFileAsync("git", ["pull"], { cwd: repoPath, timeout: 30_000 });
await runCommand(["git", "pull"], { timeoutMs: 30_000, cwd: repoPath });
} 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[]>> {
const { provider } = createProvider({ repo: project.repo });
const { provider } = await createProvider({ repo: project.repo });
const labels: QueueLabel[] = ["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];
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 roles: Array<"dev" | "qa"> = targetRole ? [targetRole] : ["dev", "qa"];

View File

@@ -1,13 +1,10 @@
/**
* 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 path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
const execFileAsync = promisify(execFile);
import { runCommand } from "../run-command.js";
/**
* Create a new agent via `openclaw agents add`.
@@ -27,7 +24,7 @@ export async function createAgent(
if (channelBinding) args.push("--bind", channelBinding);
try {
await execFileAsync("openclaw", args, { timeout: 30_000 });
await runCommand(["openclaw", ...args], { timeoutMs: 30_000 });
} catch (err) {
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.
*/
import { execSync } from "node:child_process";
import { writeFileSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { runCommand } from "../run-command.js";
export type ModelAssignment = {
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 {
// Call openclaw agent using current session context if available
const sessionFlag = sessionKey
? `--session-id "${sessionKey}"`
: `--session-id devclaw-model-selection`;
const sessionId = sessionKey ?? "devclaw-model-selection";
const result = execSync(
`openclaw agent --local ${sessionFlag} --message "$(cat ${tmpFile})" --json`,
{
encoding: "utf-8",
timeout: 30000,
stdio: ["pipe", "pipe", "ignore"],
},
).trim();
const result = await runCommand(
["openclaw", "agent", "--local", "--session-id", sessionId, "--message", prompt, "--json"],
{ timeoutMs: 30_000 },
);
const output = result.stdout.trim();
// 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("{"));
if (jsonStartIndex === -1) {
@@ -146,12 +134,5 @@ Return ONLY a JSON object in this exact format (no markdown, no explanation):
} catch (err) {
console.error("LLM model selection failed:", (err as Error).message);
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 = {
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.
* @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 {
const command = allModels
? "openclaw models list --all --json"
: "openclaw models list --json";
const args = allModels
? ["openclaw", "models", "list", "--all", "--json"]
: ["openclaw", "models", "list", "--json"];
// Use execSync directly to bypass OpenClaw's command logging
const output = execSync(command, {
encoding: "utf-8",
timeout: 10000,
cwd: process.cwd(),
// Suppress stderr to avoid any error messages
stdio: ["pipe", "pipe", "ignore"],
}).trim();
const result = await runCommand(args, { timeoutMs: 10_000 });
const output = result.stdout.trim();
if (!output) {
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).
*/
export function fetchAuthenticatedModels(): OpenClawModelRow[] {
// Use --all flag but suppress logging via stdio in fetchModels()
return fetchModels(true).filter((m) => m.available === true);
export async function fetchAuthenticatedModels(): Promise<OpenClawModelRow[]> {
return (await fetchModels(true)).filter((m) => m.available === true);
}

View File

@@ -39,7 +39,7 @@ export async function resolveProject(
/**
* 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 });
}

View File

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

View File

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

View File

@@ -124,7 +124,7 @@ export function createProjectRegisterTool() {
const repoPath = resolveRepoPath(repo);
// 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();
if (!healthy) {

View File

@@ -68,7 +68,7 @@ Examples:
}
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);

View File

@@ -70,7 +70,7 @@ Examples:
const workspaceDir = requireWorkspaceDir(ctx);
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);

View File

@@ -60,7 +60,7 @@ Examples:
const workspaceDir = requireWorkspaceDir(ctx);
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 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;
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 issue = await provider.getIssue(issueId);

View File

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