diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..dc6213e --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/index.ts b/index.ts index 63e88e8..46131e9 100644 --- a/index.ts +++ b/index.ts @@ -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"] }); diff --git a/lib/dispatch.ts b/lib/dispatch.ts index db26715..e1bc1b1 100644 --- a/lib/dispatch.ts +++ b/lib/dispatch.ts @@ -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 { - 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 { 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( @@ -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 { - 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( diff --git a/lib/notify.ts b/lib/notify.ts index 3e9eac7..687deb6 100644 --- a/lib/notify.ts +++ b/lib/notify.ts @@ -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>; @@ -95,9 +92,9 @@ async function sendMessage( workspaceDir: string, ): Promise { 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) { diff --git a/lib/providers/github.ts b/lib/providers/github.ts index d583767..93f258a 100644 --- a/lib/providers/github.ts +++ b/lib/providers/github.ts @@ -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 { - 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 { @@ -51,16 +45,12 @@ export class GitHubProvider implements IssueProvider { } async createIssue(title: string, description: string, label: StateLabel, assignees?: string[]): Promise { - 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]; - 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 */ } } + 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)); } async listIssuesByLabel(label: StateLabel): Promise { @@ -111,10 +101,7 @@ export class GitHubProvider implements IssueProvider { } async addComment(issueId: number, body: string): Promise { - 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 { diff --git a/lib/providers/gitlab.ts b/lib/providers/gitlab.ts index 6989e79..c90b220 100644 --- a/lib/providers/gitlab.ts +++ b/lib/providers/gitlab.ts @@ -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 { - 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 { @@ -35,19 +29,15 @@ export class GitLabProvider implements IssueProvider { } async createIssue(title: string, description: string, label: StateLabel, assignees?: string[]): Promise { - 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 }); - // 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()}`); - return this.getIssue(parseInt(match[1], 10)); - } finally { try { await unlink(tempFile); } catch { /* ignore */ } } + // 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.match(/\/issues\/(\d+)/); + if (!match) throw new Error(`Failed to parse issue URL: ${stdout}`); + return this.getIssue(parseInt(match[1], 10)); } async listIssuesByLabel(label: StateLabel): Promise { @@ -101,13 +91,8 @@ export class GitLabProvider implements IssueProvider { } async addComment(issueId: number, body: string): Promise { - 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 { diff --git a/lib/providers/index.ts b/lib/providers/index.ts index 1adeb3c..65e906f 100644 --- a/lib/providers/index.ts +++ b/lib/providers/index.ts @@ -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 { 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 }; } diff --git a/lib/run-command.ts b/lib/run-command.ts new file mode 100644 index 0000000..3c53f5f --- /dev/null +++ b/lib/run-command.ts @@ -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); +}; diff --git a/lib/services/heartbeat.ts b/lib/services/heartbeat.ts index 6b7d725..b4d66c4 100644 --- a/lib/services/heartbeat.ts +++ b/lib/services/heartbeat.ts @@ -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(); + const agents: Agent[] = []; - return agentsList - .filter((a): a is { id: string; workspace: string } => { - if (!a.workspace) return false; - try { - return fs.existsSync(path.join(a.workspace, "projects", "projects.json")); - } catch { - return false; + // Check explicit agent list + for (const a of config.agents?.list || []) { + if (!a.workspace) continue; + try { + 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 { - const { provider } = createProvider({ repo: project.repo }); + const { provider } = await createProvider({ repo: project.repo }); let fixedCount = 0; for (const role of ["dev", "qa"] as const) { diff --git a/lib/services/pipeline.ts b/lib/services/pipeline.ts index 2024ad6..2822f26 100644 --- a/lib/services/pipeline.ts +++ b/lib/services/pipeline.ts @@ -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 */ } } diff --git a/lib/services/queue.ts b/lib/services/queue.ts index d65dda0..9d8e0d3 100644 --- a/lib/services/queue.ts +++ b/lib/services/queue.ts @@ -33,7 +33,7 @@ export function getRoleForLabel(label: QueueLabel): "dev" | "qa" { // --------------------------------------------------------------------------- export async function fetchProjectQueues(project: Project): Promise> { - 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 = { "To Improve": [], "To Test": [], "To Do": [] }; diff --git a/lib/services/tick.ts b/lib/services/tick.ts index 1e45cb1..07522a9 100644 --- a/lib/services/tick.ts +++ b/lib/services/tick.ts @@ -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"]; diff --git a/lib/setup/agent.ts b/lib/setup/agent.ts index f135cc6..e0b838b 100644 --- a/lib/setup/agent.ts +++ b/lib/setup/agent.ts @@ -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}`); } diff --git a/lib/setup/llm-model-selector.ts b/lib/setup/llm-model-selector.ts index db0cf32..41dd90a 100644 --- a/lib/setup/llm-model-selector.ts +++ b/lib/setup/llm-model-selector.ts @@ -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 - } } } diff --git a/lib/setup/model-fetcher.ts b/lib/setup/model-fetcher.ts index 9346b83..6def34b 100644 --- a/lib/setup/model-fetcher.ts +++ b/lib/setup/model-fetcher.ts @@ -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 { 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 { + return (await fetchModels(true)).filter((m) => m.available === true); } diff --git a/lib/tool-helpers.ts b/lib/tool-helpers.ts index 1d436b7..cf4b675 100644 --- a/lib/tool-helpers.ts +++ b/lib/tool-helpers.ts @@ -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 { return createProvider({ repo: project.repo }); } diff --git a/lib/tools/autoconfigure-models.ts b/lib/tools/autoconfigure-models.ts index 583aed2..cd6f62a 100644 --- a/lib/tools/autoconfigure-models.ts +++ b/lib/tools/autoconfigure-models.ts @@ -21,7 +21,7 @@ async function getAuthenticatedModels( api: OpenClawPluginApi, ): Promise> { try { - const models = fetchAuthenticatedModels(); + const models = await fetchAuthenticatedModels(); // Map to the format expected by assignModels() return models.map((m) => { diff --git a/lib/tools/health.ts b/lib/tools/health.ts index 1b2bc4b..f8bf593 100644 --- a/lib/tools/health.ts +++ b/lib/tools/health.ts @@ -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({ diff --git a/lib/tools/project-register.ts b/lib/tools/project-register.ts index 172e2b4..e21739a 100644 --- a/lib/tools/project-register.ts +++ b/lib/tools/project-register.ts @@ -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) { diff --git a/lib/tools/task-comment.ts b/lib/tools/task-comment.ts index 715e6c1..3e4873c 100644 --- a/lib/tools/task-comment.ts +++ b/lib/tools/task-comment.ts @@ -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); diff --git a/lib/tools/task-create.ts b/lib/tools/task-create.ts index c85d141..88c9103 100644 --- a/lib/tools/task-create.ts +++ b/lib/tools/task-create.ts @@ -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); diff --git a/lib/tools/task-update.ts b/lib/tools/task-update.ts index 235e45f..6640c46 100644 --- a/lib/tools/task-update.ts +++ b/lib/tools/task-update.ts @@ -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); diff --git a/lib/tools/work-finish.ts b/lib/tools/work-finish.ts index ecab469..1d473e4 100644 --- a/lib/tools/work-finish.ts +++ b/lib/tools/work-finish.ts @@ -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); diff --git a/lib/tools/work-start.ts b/lib/tools/work-start.ts index 765af7d..96e4114 100644 --- a/lib/tools/work-start.ts +++ b/lib/tools/work-start.ts @@ -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 };