@calltelemetry/openclaw-linear 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +322 -386
- package/index.ts +54 -2
- package/openclaw.plugin.json +9 -1
- package/package.json +3 -2
- package/src/active-session.ts +106 -0
- package/src/agent.ts +173 -1
- package/src/auth.ts +6 -2
- package/src/claude-tool.ts +280 -0
- package/src/cli-shared.ts +75 -0
- package/src/cli.ts +39 -0
- package/src/client.ts +1 -0
- package/src/code-tool.ts +202 -0
- package/src/codex-tool.ts +240 -0
- package/src/codex-worktree.ts +390 -0
- package/src/dispatch-service.ts +113 -0
- package/src/dispatch-state.ts +265 -0
- package/src/gemini-tool.ts +238 -0
- package/src/orchestration-tools.ts +134 -0
- package/src/pipeline.ts +343 -56
- package/src/tier-assess.ts +157 -0
- package/src/tools.ts +29 -79
- package/src/webhook.ts +532 -275
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
4
|
+
import type { ActivityContent } from "./linear-api.js";
|
|
5
|
+
import {
|
|
6
|
+
buildLinearApi,
|
|
7
|
+
resolveSession,
|
|
8
|
+
extractPrompt,
|
|
9
|
+
DEFAULT_TIMEOUT_MS,
|
|
10
|
+
DEFAULT_BASE_REPO,
|
|
11
|
+
type CliToolParams,
|
|
12
|
+
type CliResult,
|
|
13
|
+
} from "./cli-shared.js";
|
|
14
|
+
|
|
15
|
+
const CLAUDE_BIN = "/home/claw/.npm-global/bin/claude";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Map a Claude Code stream-json JSONL event to a Linear activity.
|
|
19
|
+
*
|
|
20
|
+
* Claude event types:
|
|
21
|
+
* system(init) → assistant (text|tool_use) → user (tool_result) → result
|
|
22
|
+
*/
|
|
23
|
+
function mapClaudeEventToActivity(event: any): ActivityContent | null {
|
|
24
|
+
const type = event?.type;
|
|
25
|
+
|
|
26
|
+
// Assistant message — text response or tool use
|
|
27
|
+
if (type === "assistant") {
|
|
28
|
+
const content = event.message?.content;
|
|
29
|
+
if (!Array.isArray(content)) return null;
|
|
30
|
+
|
|
31
|
+
for (const block of content) {
|
|
32
|
+
if (block.type === "text" && block.text) {
|
|
33
|
+
return { type: "thought", body: block.text.slice(0, 1000) };
|
|
34
|
+
}
|
|
35
|
+
if (block.type === "tool_use") {
|
|
36
|
+
const toolName = block.name ?? "tool";
|
|
37
|
+
const input = block.input ?? {};
|
|
38
|
+
// Summarize the input for display
|
|
39
|
+
let paramSummary: string;
|
|
40
|
+
if (input.command) {
|
|
41
|
+
paramSummary = String(input.command).slice(0, 200);
|
|
42
|
+
} else if (input.file_path) {
|
|
43
|
+
paramSummary = String(input.file_path);
|
|
44
|
+
} else if (input.pattern) {
|
|
45
|
+
paramSummary = String(input.pattern);
|
|
46
|
+
} else if (input.query) {
|
|
47
|
+
paramSummary = String(input.query).slice(0, 200);
|
|
48
|
+
} else {
|
|
49
|
+
paramSummary = JSON.stringify(input).slice(0, 200);
|
|
50
|
+
}
|
|
51
|
+
return { type: "action", action: `Running ${toolName}`, parameter: paramSummary };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Tool result
|
|
58
|
+
if (type === "user") {
|
|
59
|
+
const content = event.message?.content;
|
|
60
|
+
if (!Array.isArray(content)) return null;
|
|
61
|
+
|
|
62
|
+
for (const block of content) {
|
|
63
|
+
if (block.type === "tool_result") {
|
|
64
|
+
const output = typeof block.content === "string" ? block.content : "";
|
|
65
|
+
const truncated = output.length > 300 ? output.slice(0, 300) + "..." : output;
|
|
66
|
+
const isError = block.is_error === true;
|
|
67
|
+
return {
|
|
68
|
+
type: "action",
|
|
69
|
+
action: isError ? "Tool error" : "Tool result",
|
|
70
|
+
parameter: truncated || "(no output)",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Final result
|
|
78
|
+
if (type === "result") {
|
|
79
|
+
const cost = event.total_cost_usd;
|
|
80
|
+
const turns = event.num_turns ?? 0;
|
|
81
|
+
const usage = event.usage;
|
|
82
|
+
const parts: string[] = [`Claude completed (${turns} turns)`];
|
|
83
|
+
if (cost != null) parts.push(`$${cost.toFixed(4)}`);
|
|
84
|
+
if (usage) {
|
|
85
|
+
const input = usage.input_tokens ?? 0;
|
|
86
|
+
const output = usage.output_tokens ?? 0;
|
|
87
|
+
parts.push(`${input} in / ${output} out tokens`);
|
|
88
|
+
}
|
|
89
|
+
return { type: "thought", body: parts.join(" — ") };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Run Claude Code CLI with JSONL streaming, mapping events to Linear activities.
|
|
97
|
+
*/
|
|
98
|
+
export async function runClaude(
|
|
99
|
+
api: OpenClawPluginApi,
|
|
100
|
+
params: CliToolParams,
|
|
101
|
+
pluginConfig?: Record<string, unknown>,
|
|
102
|
+
): Promise<CliResult> {
|
|
103
|
+
api.logger.info(`claude_run params: ${JSON.stringify(params).slice(0, 500)}`);
|
|
104
|
+
|
|
105
|
+
const prompt = extractPrompt(params);
|
|
106
|
+
if (!prompt) {
|
|
107
|
+
return {
|
|
108
|
+
success: false,
|
|
109
|
+
output: `claude_run error: no prompt provided. Received keys: ${Object.keys(params).join(", ")}`,
|
|
110
|
+
error: "missing prompt",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const { model, timeoutMs } = params;
|
|
115
|
+
const { agentSessionId, issueIdentifier } = resolveSession(params);
|
|
116
|
+
|
|
117
|
+
api.logger.info(`claude_run: session=${agentSessionId ?? "none"}, issue=${issueIdentifier ?? "none"}`);
|
|
118
|
+
|
|
119
|
+
const timeout = timeoutMs ?? (pluginConfig?.claudeTimeoutMs as number) ?? DEFAULT_TIMEOUT_MS;
|
|
120
|
+
const workingDir = params.workingDir ?? (pluginConfig?.claudeBaseRepo as string) ?? DEFAULT_BASE_REPO;
|
|
121
|
+
|
|
122
|
+
const linearApi = buildLinearApi(api, agentSessionId);
|
|
123
|
+
|
|
124
|
+
if (linearApi && agentSessionId) {
|
|
125
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
126
|
+
type: "thought",
|
|
127
|
+
body: `Starting Claude Code: "${prompt.slice(0, 100)}${prompt.length > 100 ? "..." : ""}"`,
|
|
128
|
+
}).catch(() => {});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Build claude command
|
|
132
|
+
const args = [
|
|
133
|
+
"--print",
|
|
134
|
+
"--output-format", "stream-json",
|
|
135
|
+
"--verbose",
|
|
136
|
+
"--dangerously-skip-permissions",
|
|
137
|
+
];
|
|
138
|
+
if (model ?? pluginConfig?.claudeModel) {
|
|
139
|
+
args.push("--model", (model ?? pluginConfig?.claudeModel) as string);
|
|
140
|
+
}
|
|
141
|
+
args.push("-C", workingDir);
|
|
142
|
+
args.push("-p", prompt);
|
|
143
|
+
|
|
144
|
+
api.logger.info(`Claude exec: ${CLAUDE_BIN} ${args.join(" ").slice(0, 200)}...`);
|
|
145
|
+
|
|
146
|
+
return new Promise<CliResult>((resolve) => {
|
|
147
|
+
// Must unset CLAUDECODE to avoid "nested session" error
|
|
148
|
+
const env = { ...process.env };
|
|
149
|
+
delete env.CLAUDECODE;
|
|
150
|
+
|
|
151
|
+
const child = spawn(CLAUDE_BIN, args, {
|
|
152
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
153
|
+
env,
|
|
154
|
+
timeout: 0,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
let killed = false;
|
|
158
|
+
const timer = setTimeout(() => {
|
|
159
|
+
killed = true;
|
|
160
|
+
child.kill("SIGTERM");
|
|
161
|
+
setTimeout(() => { if (!child.killed) child.kill("SIGKILL"); }, 5_000);
|
|
162
|
+
}, timeout);
|
|
163
|
+
|
|
164
|
+
const collectedMessages: string[] = [];
|
|
165
|
+
const collectedCommands: string[] = [];
|
|
166
|
+
let stderrOutput = "";
|
|
167
|
+
let lastToolName = "";
|
|
168
|
+
|
|
169
|
+
const rl = createInterface({ input: child.stdout! });
|
|
170
|
+
rl.on("line", (line) => {
|
|
171
|
+
if (!line.trim()) return;
|
|
172
|
+
|
|
173
|
+
let event: any;
|
|
174
|
+
try {
|
|
175
|
+
event = JSON.parse(line);
|
|
176
|
+
} catch {
|
|
177
|
+
collectedMessages.push(line);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Collect assistant text for final output
|
|
182
|
+
if (event.type === "assistant") {
|
|
183
|
+
const content = event.message?.content;
|
|
184
|
+
if (Array.isArray(content)) {
|
|
185
|
+
for (const block of content) {
|
|
186
|
+
if (block.type === "text" && block.text) {
|
|
187
|
+
collectedMessages.push(block.text);
|
|
188
|
+
}
|
|
189
|
+
if (block.type === "tool_use") {
|
|
190
|
+
lastToolName = block.name ?? "tool";
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Collect tool results for final output
|
|
197
|
+
if (event.type === "user") {
|
|
198
|
+
const content = event.message?.content;
|
|
199
|
+
const toolResult = event.tool_use_result;
|
|
200
|
+
if (Array.isArray(content)) {
|
|
201
|
+
for (const block of content) {
|
|
202
|
+
if (block.type === "tool_result") {
|
|
203
|
+
const output = toolResult?.stdout ?? (typeof block.content === "string" ? block.content : "");
|
|
204
|
+
const isError = block.is_error === true;
|
|
205
|
+
const truncOutput = output.length > 500 ? output.slice(0, 500) + "..." : output;
|
|
206
|
+
if (truncOutput) {
|
|
207
|
+
collectedCommands.push(
|
|
208
|
+
`\`${lastToolName}\` → ${isError ? "error" : "ok"}${truncOutput ? "\n```\n" + truncOutput + "\n```" : ""}`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Collect final result text
|
|
217
|
+
if (event.type === "result" && event.result) {
|
|
218
|
+
// result.result contains the final answer — only add if we haven't already captured it
|
|
219
|
+
// (it duplicates the last assistant text message)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Stream activity to Linear
|
|
223
|
+
const activity = mapClaudeEventToActivity(event);
|
|
224
|
+
if (activity && linearApi && agentSessionId) {
|
|
225
|
+
linearApi.emitActivity(agentSessionId, activity).catch((err) => {
|
|
226
|
+
api.logger.warn(`Failed to emit Claude activity: ${err}`);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
child.stderr?.on("data", (chunk) => {
|
|
232
|
+
stderrOutput += chunk.toString();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
child.on("close", (code) => {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
rl.close();
|
|
238
|
+
|
|
239
|
+
const parts: string[] = [];
|
|
240
|
+
if (collectedMessages.length > 0) parts.push(collectedMessages.join("\n\n"));
|
|
241
|
+
if (collectedCommands.length > 0) parts.push(collectedCommands.join("\n\n"));
|
|
242
|
+
const output = parts.join("\n\n") || stderrOutput || "(no output)";
|
|
243
|
+
|
|
244
|
+
if (killed) {
|
|
245
|
+
api.logger.warn(`Claude timed out after ${timeout}ms`);
|
|
246
|
+
resolve({
|
|
247
|
+
success: false,
|
|
248
|
+
output: `Claude timed out after ${Math.round(timeout / 1000)}s. Partial output:\n${output}`,
|
|
249
|
+
error: "timeout",
|
|
250
|
+
});
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (code !== 0) {
|
|
255
|
+
api.logger.warn(`Claude exited with code ${code}`);
|
|
256
|
+
resolve({
|
|
257
|
+
success: false,
|
|
258
|
+
output: `Claude failed (exit ${code}):\n${output}`,
|
|
259
|
+
error: `exit ${code}`,
|
|
260
|
+
});
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
api.logger.info(`Claude completed successfully`);
|
|
265
|
+
resolve({ success: true, output });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
child.on("error", (err) => {
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
rl.close();
|
|
271
|
+
api.logger.error(`Claude spawn error: ${err}`);
|
|
272
|
+
resolve({
|
|
273
|
+
success: false,
|
|
274
|
+
output: `Failed to start Claude: ${err.message}`,
|
|
275
|
+
error: err.message,
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { LinearAgentApi } from "./linear-api.js";
|
|
3
|
+
import { resolveLinearToken, LinearAgentApi as LinearAgentApiClass } from "./linear-api.js";
|
|
4
|
+
import { getCurrentSession, getActiveSessionByIdentifier } from "./active-session.js";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_TIMEOUT_MS = 10 * 60_000; // 10 minutes
|
|
7
|
+
export const DEFAULT_BASE_REPO = "/home/claw/ai-workspace";
|
|
8
|
+
|
|
9
|
+
export interface CliToolParams {
|
|
10
|
+
prompt: string;
|
|
11
|
+
workingDir?: string;
|
|
12
|
+
model?: string;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
issueIdentifier?: string;
|
|
15
|
+
agentSessionId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CliResult {
|
|
19
|
+
success: boolean;
|
|
20
|
+
output: string;
|
|
21
|
+
error?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build a LinearAgentApi instance for streaming activities to Linear.
|
|
26
|
+
*/
|
|
27
|
+
export function buildLinearApi(
|
|
28
|
+
api: OpenClawPluginApi,
|
|
29
|
+
agentSessionId?: string,
|
|
30
|
+
): LinearAgentApi | null {
|
|
31
|
+
if (!agentSessionId) return null;
|
|
32
|
+
|
|
33
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
34
|
+
const tokenInfo = resolveLinearToken(pluginConfig);
|
|
35
|
+
if (!tokenInfo.accessToken) return null;
|
|
36
|
+
|
|
37
|
+
const clientId = (pluginConfig?.clientId as string) ?? process.env.LINEAR_CLIENT_ID;
|
|
38
|
+
const clientSecret = (pluginConfig?.clientSecret as string) ?? process.env.LINEAR_CLIENT_SECRET;
|
|
39
|
+
|
|
40
|
+
return new LinearAgentApiClass(tokenInfo.accessToken, {
|
|
41
|
+
refreshToken: tokenInfo.refreshToken,
|
|
42
|
+
expiresAt: tokenInfo.expiresAt,
|
|
43
|
+
clientId: clientId ?? undefined,
|
|
44
|
+
clientSecret: clientSecret ?? undefined,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve session info from explicit params or the active session registry.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveSession(params: CliToolParams): {
|
|
52
|
+
agentSessionId: string | undefined;
|
|
53
|
+
issueIdentifier: string | undefined;
|
|
54
|
+
} {
|
|
55
|
+
let { issueIdentifier, agentSessionId } = params;
|
|
56
|
+
|
|
57
|
+
if (!agentSessionId || !issueIdentifier) {
|
|
58
|
+
const active = issueIdentifier
|
|
59
|
+
? getActiveSessionByIdentifier(issueIdentifier)
|
|
60
|
+
: getCurrentSession();
|
|
61
|
+
if (active) {
|
|
62
|
+
agentSessionId = agentSessionId ?? active.agentSessionId;
|
|
63
|
+
issueIdentifier = issueIdentifier ?? active.issueIdentifier;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { agentSessionId, issueIdentifier };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Robustly extract a prompt from params, handling various key names.
|
|
72
|
+
*/
|
|
73
|
+
export function extractPrompt(params: CliToolParams): string | undefined {
|
|
74
|
+
return params.prompt ?? (params as any).text ?? (params as any).message ?? (params as any).task;
|
|
75
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { exec } from "node:child_process";
|
|
|
8
8
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { resolveLinearToken, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "./linear-api.js";
|
|
10
10
|
import { LINEAR_OAUTH_AUTH_URL, LINEAR_OAUTH_TOKEN_URL, LINEAR_AGENT_SCOPES } from "./auth.js";
|
|
11
|
+
import { listWorktrees } from "./codex-worktree.js";
|
|
11
12
|
|
|
12
13
|
function prompt(question: string): Promise<string> {
|
|
13
14
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -200,4 +201,42 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
|
|
200
201
|
|
|
201
202
|
console.log();
|
|
202
203
|
});
|
|
204
|
+
|
|
205
|
+
// --- openclaw openclaw-linear worktrees ---
|
|
206
|
+
linear
|
|
207
|
+
.command("worktrees")
|
|
208
|
+
.description("List Codex worktrees (use --prune to remove specific ones)")
|
|
209
|
+
.option("--prune <path>", "Remove a specific worktree by path")
|
|
210
|
+
.action(async (opts: { prune?: string }) => {
|
|
211
|
+
if (opts.prune) {
|
|
212
|
+
try {
|
|
213
|
+
const { removeWorktree } = await import("./codex-worktree.js");
|
|
214
|
+
removeWorktree(opts.prune, { deleteBranch: true });
|
|
215
|
+
console.log(`\nRemoved: ${opts.prune}\n`);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
console.error(`\nFailed to remove ${opts.prune}: ${err}\n`);
|
|
218
|
+
process.exitCode = 1;
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const worktrees = listWorktrees();
|
|
224
|
+
|
|
225
|
+
if (worktrees.length === 0) {
|
|
226
|
+
console.log("\nNo Codex worktrees found.\n");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
console.log(`\nCodex Worktrees (${worktrees.length})`);
|
|
231
|
+
console.log("─".repeat(60));
|
|
232
|
+
|
|
233
|
+
for (const wt of worktrees) {
|
|
234
|
+
const ageH = Math.round(wt.ageMs / 3_600_000 * 10) / 10;
|
|
235
|
+
const changes = wt.hasChanges ? " (uncommitted changes)" : "";
|
|
236
|
+
console.log(` ${wt.path}`);
|
|
237
|
+
console.log(` branch: ${wt.branch} age: ${ageH}h${changes}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
console.log(`\nTo remove one: openclaw openclaw-linear worktrees --prune <path>\n`);
|
|
241
|
+
});
|
|
203
242
|
}
|
package/src/client.ts
CHANGED
package/src/code-tool.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
5
|
+
import { jsonResult } from "openclaw/plugin-sdk";
|
|
6
|
+
import { getCurrentSession } from "./active-session.js";
|
|
7
|
+
import { runCodex } from "./codex-tool.js";
|
|
8
|
+
import { runClaude } from "./claude-tool.js";
|
|
9
|
+
import { runGemini } from "./gemini-tool.js";
|
|
10
|
+
import type { CliToolParams, CliResult } from "./cli-shared.js";
|
|
11
|
+
|
|
12
|
+
export type CodingBackend = "claude" | "codex" | "gemini";
|
|
13
|
+
|
|
14
|
+
const BACKEND_LABELS: Record<CodingBackend, string> = {
|
|
15
|
+
claude: "Claude Code (Anthropic)",
|
|
16
|
+
codex: "Codex (OpenAI)",
|
|
17
|
+
gemini: "Gemini CLI (Google)",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const BACKEND_RUNNERS: Record<
|
|
21
|
+
CodingBackend,
|
|
22
|
+
(api: OpenClawPluginApi, params: CliToolParams, pluginConfig?: Record<string, unknown>) => Promise<CliResult>
|
|
23
|
+
> = {
|
|
24
|
+
claude: runClaude,
|
|
25
|
+
codex: runCodex,
|
|
26
|
+
gemini: runGemini,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
interface BackendConfig {
|
|
30
|
+
aliases?: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CodingToolsConfig {
|
|
34
|
+
codingTool?: string;
|
|
35
|
+
agentCodingTools?: Record<string, string>;
|
|
36
|
+
backends?: Record<string, BackendConfig>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Load coding tool config from the plugin's coding-tools.json file.
|
|
41
|
+
* Falls back to empty config if the file doesn't exist or is invalid.
|
|
42
|
+
*/
|
|
43
|
+
export function loadCodingConfig(): CodingToolsConfig {
|
|
44
|
+
try {
|
|
45
|
+
// Resolve relative to the plugin root (one level up from src/)
|
|
46
|
+
const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
47
|
+
const raw = readFileSync(join(pluginRoot, "coding-tools.json"), "utf8");
|
|
48
|
+
return JSON.parse(raw) as CodingToolsConfig;
|
|
49
|
+
} catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build a reverse lookup map: alias (lowercase) → backend ID.
|
|
56
|
+
* Backend IDs themselves are always valid aliases.
|
|
57
|
+
*/
|
|
58
|
+
function buildAliasMap(config: CodingToolsConfig): Map<string, CodingBackend> {
|
|
59
|
+
const map = new Map<string, CodingBackend>();
|
|
60
|
+
|
|
61
|
+
for (const backendId of Object.keys(BACKEND_RUNNERS) as CodingBackend[]) {
|
|
62
|
+
// The backend ID itself is always an alias
|
|
63
|
+
map.set(backendId, backendId);
|
|
64
|
+
|
|
65
|
+
// Add configured aliases
|
|
66
|
+
const aliases = config.backends?.[backendId]?.aliases;
|
|
67
|
+
if (aliases) {
|
|
68
|
+
for (const alias of aliases) {
|
|
69
|
+
map.set(alias.toLowerCase(), backendId);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return map;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve a user-provided alias string to a backend ID.
|
|
79
|
+
* Returns undefined if no match.
|
|
80
|
+
*/
|
|
81
|
+
function resolveAlias(aliasMap: Map<string, CodingBackend>, input: string): CodingBackend | undefined {
|
|
82
|
+
return aliasMap.get(input.toLowerCase());
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolve which coding backend to use for a given agent.
|
|
87
|
+
*
|
|
88
|
+
* Priority:
|
|
89
|
+
* 1. Per-agent override: config.agentCodingTools[agentId]
|
|
90
|
+
* 2. Global default: config.codingTool
|
|
91
|
+
* 3. Hardcoded fallback: "claude"
|
|
92
|
+
*/
|
|
93
|
+
export function resolveCodingBackend(
|
|
94
|
+
config: CodingToolsConfig,
|
|
95
|
+
agentId?: string,
|
|
96
|
+
): CodingBackend {
|
|
97
|
+
// Per-agent override
|
|
98
|
+
if (agentId) {
|
|
99
|
+
const override = config.agentCodingTools?.[agentId];
|
|
100
|
+
if (override && override in BACKEND_RUNNERS) return override as CodingBackend;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Global default
|
|
104
|
+
const global = config.codingTool;
|
|
105
|
+
if (global && global in BACKEND_RUNNERS) return global as CodingBackend;
|
|
106
|
+
|
|
107
|
+
return "claude";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Create the unified `code_run` tool.
|
|
112
|
+
*
|
|
113
|
+
* The tool dispatches to the backend configured in coding-tools.json
|
|
114
|
+
* (codingTool / agentCodingTools). The agent always calls `code_run` —
|
|
115
|
+
* it doesn't need to know which CLI is being used.
|
|
116
|
+
*/
|
|
117
|
+
export function createCodeTool(
|
|
118
|
+
api: OpenClawPluginApi,
|
|
119
|
+
_ctx: Record<string, unknown>,
|
|
120
|
+
): AnyAgentTool {
|
|
121
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
122
|
+
const codingConfig = loadCodingConfig();
|
|
123
|
+
const aliasMap = buildAliasMap(codingConfig);
|
|
124
|
+
|
|
125
|
+
// Resolve the default backend for the tool description (may be overridden at runtime per-agent)
|
|
126
|
+
const defaultBackend = resolveCodingBackend(codingConfig);
|
|
127
|
+
const defaultLabel = BACKEND_LABELS[defaultBackend];
|
|
128
|
+
|
|
129
|
+
// Build alias description for each backend so the LLM knows what names to use
|
|
130
|
+
const aliasDescParts: string[] = [];
|
|
131
|
+
for (const backendId of Object.keys(BACKEND_RUNNERS) as CodingBackend[]) {
|
|
132
|
+
const aliases = codingConfig.backends?.[backendId]?.aliases ?? [backendId];
|
|
133
|
+
aliasDescParts.push(`${BACKEND_LABELS[backendId]}: ${aliases.map(a => `"${a}"`).join(", ")}`);
|
|
134
|
+
}
|
|
135
|
+
const aliasDesc = aliasDescParts.join("; ");
|
|
136
|
+
|
|
137
|
+
api.logger.info(`code_run: default backend=${defaultBackend}, aliases=${JSON.stringify(Object.fromEntries(aliasMap))}, per-agent overrides=${JSON.stringify(codingConfig.agentCodingTools ?? {})}`);
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
name: "code_run",
|
|
141
|
+
label: "Run Coding Agent",
|
|
142
|
+
description:
|
|
143
|
+
`Run an agentic coding CLI to perform a hands-on coding task. ` +
|
|
144
|
+
`Default backend: ${defaultLabel}. You can override with the 'backend' parameter ` +
|
|
145
|
+
`if the user asks for a specific tool. ` +
|
|
146
|
+
`Known aliases — ${aliasDesc}. ` +
|
|
147
|
+
`The CLI can read/write files, run commands, search code, run tests, and more. ` +
|
|
148
|
+
`Streams progress to Linear in real-time. Use this for writing code, debugging, ` +
|
|
149
|
+
`refactoring, creating files, running tests, and other hands-on development work.`,
|
|
150
|
+
parameters: {
|
|
151
|
+
type: "object",
|
|
152
|
+
properties: {
|
|
153
|
+
prompt: {
|
|
154
|
+
type: "string",
|
|
155
|
+
description:
|
|
156
|
+
"What the coding agent should do. Be specific: include file paths, function names, " +
|
|
157
|
+
"expected behavior, and test requirements.",
|
|
158
|
+
},
|
|
159
|
+
backend: {
|
|
160
|
+
type: "string",
|
|
161
|
+
description:
|
|
162
|
+
`Which coding CLI to use. Accepts any known alias: ${aliasDesc}. ` +
|
|
163
|
+
"If omitted, uses the configured default.",
|
|
164
|
+
},
|
|
165
|
+
workingDir: {
|
|
166
|
+
type: "string",
|
|
167
|
+
description: "Override working directory (default: /home/claw/ai-workspace).",
|
|
168
|
+
},
|
|
169
|
+
model: {
|
|
170
|
+
type: "string",
|
|
171
|
+
description: "Model override for the coding backend.",
|
|
172
|
+
},
|
|
173
|
+
timeoutMs: {
|
|
174
|
+
type: "number",
|
|
175
|
+
description: "Max runtime in milliseconds (default: 600000 = 10 min).",
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
required: ["prompt"],
|
|
179
|
+
},
|
|
180
|
+
execute: async (toolCallId: string, params: CliToolParams & { backend?: string }, ...rest: unknown[]) => {
|
|
181
|
+
// Resolve backend: explicit alias → per-agent config → global default
|
|
182
|
+
const currentSession = getCurrentSession();
|
|
183
|
+
const agentId = currentSession?.agentId;
|
|
184
|
+
const explicitBackend = params.backend
|
|
185
|
+
? resolveAlias(aliasMap, params.backend)
|
|
186
|
+
: undefined;
|
|
187
|
+
const backend = explicitBackend ?? resolveCodingBackend(codingConfig, agentId);
|
|
188
|
+
const runner = BACKEND_RUNNERS[backend];
|
|
189
|
+
|
|
190
|
+
api.logger.info(`code_run: backend=${backend} agent=${agentId ?? "unknown"}`);
|
|
191
|
+
|
|
192
|
+
const result = await runner(api, params, pluginConfig);
|
|
193
|
+
|
|
194
|
+
return jsonResult({
|
|
195
|
+
success: result.success,
|
|
196
|
+
backend,
|
|
197
|
+
output: result.output,
|
|
198
|
+
...(result.error ? { error: result.error } : {}),
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
} as unknown as AnyAgentTool;
|
|
202
|
+
}
|