@calltelemetry/openclaw-linear 0.3.0 → 0.4.0
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 +339 -365
- package/index.ts +50 -2
- package/openclaw.plugin.json +7 -1
- package/package.json +3 -2
- package/src/active-session.ts +66 -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 +264 -0
- package/src/gemini-tool.ts +238 -0
- package/src/orchestration-tools.ts +134 -0
- package/src/pipeline.ts +68 -10
- package/src/tools.ts +29 -79
- package/src/webhook.ts +321 -90
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, statSync, readdirSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_BASE_REPO = "/home/claw/ai-workspace";
|
|
5
|
+
|
|
6
|
+
export interface WorktreeInfo {
|
|
7
|
+
path: string;
|
|
8
|
+
branch: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface WorktreeStatus {
|
|
12
|
+
filesChanged: string[];
|
|
13
|
+
hasUncommitted: boolean;
|
|
14
|
+
lastCommit: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function git(args: string[], cwd: string): string {
|
|
18
|
+
return execFileSync("git", args, {
|
|
19
|
+
cwd,
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
timeout: 30_000,
|
|
22
|
+
}).trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create a git worktree for isolated Codex work.
|
|
27
|
+
* Branch: codex/{issueIdentifier}
|
|
28
|
+
* Path: /tmp/codex-{issueIdentifier}-{timestamp}
|
|
29
|
+
*/
|
|
30
|
+
export function createWorktree(
|
|
31
|
+
issueIdentifier: string,
|
|
32
|
+
baseRepo?: string,
|
|
33
|
+
): WorktreeInfo {
|
|
34
|
+
const repo = baseRepo ?? DEFAULT_BASE_REPO;
|
|
35
|
+
if (!existsSync(repo)) {
|
|
36
|
+
throw new Error(`Base repo not found: ${repo}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const branch = `codex/${issueIdentifier}`;
|
|
40
|
+
const ts = Date.now();
|
|
41
|
+
const worktreePath = `/tmp/codex-${issueIdentifier}-${ts}`;
|
|
42
|
+
|
|
43
|
+
// Ensure we're on a clean base — fetch latest
|
|
44
|
+
try {
|
|
45
|
+
git(["fetch", "origin"], repo);
|
|
46
|
+
} catch {
|
|
47
|
+
// Offline or no remote — continue with local state
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Delete stale branch if it exists (from a previous run)
|
|
51
|
+
try {
|
|
52
|
+
git(["branch", "-D", branch], repo);
|
|
53
|
+
} catch {
|
|
54
|
+
// Branch doesn't exist — fine
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Create worktree with new branch off HEAD
|
|
58
|
+
git(["worktree", "add", "-b", branch, worktreePath], repo);
|
|
59
|
+
|
|
60
|
+
return { path: worktreePath, branch };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get the status of a worktree: changed files, uncommitted work, last commit.
|
|
65
|
+
*/
|
|
66
|
+
export function getWorktreeStatus(worktreePath: string): WorktreeStatus {
|
|
67
|
+
if (!existsSync(worktreePath)) {
|
|
68
|
+
return { filesChanged: [], hasUncommitted: false, lastCommit: null };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const diffOutput = git(
|
|
72
|
+
["diff", "--name-only", "HEAD"],
|
|
73
|
+
worktreePath,
|
|
74
|
+
);
|
|
75
|
+
const stagedOutput = git(
|
|
76
|
+
["diff", "--name-only", "--cached"],
|
|
77
|
+
worktreePath,
|
|
78
|
+
);
|
|
79
|
+
const untrackedOutput = git(
|
|
80
|
+
["ls-files", "--others", "--exclude-standard"],
|
|
81
|
+
worktreePath,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const allFiles = new Set<string>();
|
|
85
|
+
for (const line of [...diffOutput.split("\n"), ...stagedOutput.split("\n"), ...untrackedOutput.split("\n")]) {
|
|
86
|
+
const trimmed = line.trim();
|
|
87
|
+
if (trimmed) allFiles.add(trimmed);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const hasUncommitted = allFiles.size > 0;
|
|
91
|
+
|
|
92
|
+
let lastCommit: string | null = null;
|
|
93
|
+
try {
|
|
94
|
+
lastCommit = git(["log", "-1", "--oneline"], worktreePath);
|
|
95
|
+
} catch {
|
|
96
|
+
// No commits yet
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
filesChanged: [...allFiles],
|
|
101
|
+
hasUncommitted,
|
|
102
|
+
lastCommit,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Remove a worktree and optionally delete its branch.
|
|
108
|
+
*/
|
|
109
|
+
export function removeWorktree(
|
|
110
|
+
worktreePath: string,
|
|
111
|
+
opts?: { deleteBranch?: boolean; baseRepo?: string },
|
|
112
|
+
): void {
|
|
113
|
+
const repo = opts?.baseRepo ?? DEFAULT_BASE_REPO;
|
|
114
|
+
|
|
115
|
+
if (existsSync(worktreePath)) {
|
|
116
|
+
git(["worktree", "remove", "--force", worktreePath], repo);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (opts?.deleteBranch) {
|
|
120
|
+
// Extract branch name from the worktree path convention
|
|
121
|
+
try {
|
|
122
|
+
const branches = git(["branch", "--list", "codex/*"], repo);
|
|
123
|
+
// Only delete if it looks like a codex branch
|
|
124
|
+
for (const b of branches.split("\n")) {
|
|
125
|
+
const name = b.trim().replace(/^\* /, "");
|
|
126
|
+
if (name && worktreePath.includes(name.replace("codex/", ""))) {
|
|
127
|
+
git(["branch", "-D", name], repo);
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// Best effort
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Push the worktree branch and create a GitHub PR via `gh`.
|
|
139
|
+
*/
|
|
140
|
+
export function createPullRequest(
|
|
141
|
+
worktreePath: string,
|
|
142
|
+
title: string,
|
|
143
|
+
body: string,
|
|
144
|
+
): { prUrl: string } {
|
|
145
|
+
// Commit any uncommitted changes first
|
|
146
|
+
const status = getWorktreeStatus(worktreePath);
|
|
147
|
+
if (status.hasUncommitted) {
|
|
148
|
+
git(["add", "-A"], worktreePath);
|
|
149
|
+
git(
|
|
150
|
+
[
|
|
151
|
+
"-c", "user.name=claw",
|
|
152
|
+
"-c", "user.email=claw@calltelemetry.com",
|
|
153
|
+
"commit", "-m", title,
|
|
154
|
+
],
|
|
155
|
+
worktreePath,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Get branch name
|
|
160
|
+
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], worktreePath);
|
|
161
|
+
|
|
162
|
+
// Push branch
|
|
163
|
+
git(["push", "-u", "origin", branch], worktreePath);
|
|
164
|
+
|
|
165
|
+
// Create PR via gh CLI
|
|
166
|
+
const prUrl = execFileSync(
|
|
167
|
+
"gh",
|
|
168
|
+
["pr", "create", "--title", title, "--body", body, "--head", branch],
|
|
169
|
+
{ cwd: worktreePath, encoding: "utf8", timeout: 30_000 },
|
|
170
|
+
).trim();
|
|
171
|
+
|
|
172
|
+
return { prUrl };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface WorktreeEntry {
|
|
176
|
+
path: string;
|
|
177
|
+
branch: string;
|
|
178
|
+
ageMs: number;
|
|
179
|
+
hasChanges: boolean;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* List all codex worktrees under /tmp.
|
|
184
|
+
*/
|
|
185
|
+
export function listWorktrees(baseRepo?: string): WorktreeEntry[] {
|
|
186
|
+
const repo = baseRepo ?? DEFAULT_BASE_REPO;
|
|
187
|
+
const entries: WorktreeEntry[] = [];
|
|
188
|
+
|
|
189
|
+
// Find /tmp/codex-* directories
|
|
190
|
+
let dirs: string[];
|
|
191
|
+
try {
|
|
192
|
+
dirs = readdirSync("/tmp")
|
|
193
|
+
.filter((d) => d.startsWith("codex-"))
|
|
194
|
+
.map((d) => `/tmp/${d}`);
|
|
195
|
+
} catch {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
for (const dir of dirs) {
|
|
200
|
+
if (!existsSync(dir)) continue;
|
|
201
|
+
try {
|
|
202
|
+
const stat = statSync(dir);
|
|
203
|
+
if (!stat.isDirectory()) continue;
|
|
204
|
+
|
|
205
|
+
let branch = "unknown";
|
|
206
|
+
try {
|
|
207
|
+
branch = git(["rev-parse", "--abbrev-ref", "HEAD"], dir);
|
|
208
|
+
} catch {}
|
|
209
|
+
|
|
210
|
+
let hasChanges = false;
|
|
211
|
+
try {
|
|
212
|
+
const status = getWorktreeStatus(dir);
|
|
213
|
+
hasChanges = status.hasUncommitted;
|
|
214
|
+
} catch {}
|
|
215
|
+
|
|
216
|
+
entries.push({
|
|
217
|
+
path: dir,
|
|
218
|
+
branch,
|
|
219
|
+
ageMs: Date.now() - stat.mtimeMs,
|
|
220
|
+
hasChanges,
|
|
221
|
+
});
|
|
222
|
+
} catch {
|
|
223
|
+
// Skip unreadable dirs
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return entries.sort((a, b) => b.ageMs - a.ageMs);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Remove codex worktrees older than maxAgeMs.
|
|
232
|
+
* Returns list of removed paths.
|
|
233
|
+
*/
|
|
234
|
+
export function pruneStaleWorktrees(
|
|
235
|
+
maxAgeMs: number = 24 * 60 * 60_000,
|
|
236
|
+
opts?: { baseRepo?: string; dryRun?: boolean },
|
|
237
|
+
): { removed: string[]; skipped: string[]; errors: string[] } {
|
|
238
|
+
const repo = opts?.baseRepo ?? DEFAULT_BASE_REPO;
|
|
239
|
+
const worktrees = listWorktrees(repo);
|
|
240
|
+
const removed: string[] = [];
|
|
241
|
+
const skipped: string[] = [];
|
|
242
|
+
const errors: string[] = [];
|
|
243
|
+
|
|
244
|
+
for (const wt of worktrees) {
|
|
245
|
+
if (wt.ageMs < maxAgeMs) {
|
|
246
|
+
skipped.push(wt.path);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (opts?.dryRun) {
|
|
251
|
+
removed.push(wt.path);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
removeWorktree(wt.path, { deleteBranch: true, baseRepo: repo });
|
|
257
|
+
removed.push(wt.path);
|
|
258
|
+
} catch (err) {
|
|
259
|
+
errors.push(`${wt.path}: ${err}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return { removed, skipped, errors };
|
|
264
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
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 GEMINI_BIN = "/home/claw/.npm-global/bin/gemini";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Map a Gemini CLI stream-json JSONL event to a Linear activity.
|
|
19
|
+
*
|
|
20
|
+
* Gemini event types:
|
|
21
|
+
* init → message(user) → message(assistant) → tool_use → tool_result → result
|
|
22
|
+
*/
|
|
23
|
+
function mapGeminiEventToActivity(event: any): ActivityContent | null {
|
|
24
|
+
const type = event?.type;
|
|
25
|
+
|
|
26
|
+
// Assistant message (delta text)
|
|
27
|
+
if (type === "message" && event.role === "assistant") {
|
|
28
|
+
const text = event.content;
|
|
29
|
+
if (text) return { type: "thought", body: text.slice(0, 1000) };
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Tool use — running a command or tool
|
|
34
|
+
if (type === "tool_use") {
|
|
35
|
+
const toolName = event.tool_name ?? "tool";
|
|
36
|
+
const params = event.parameters ?? {};
|
|
37
|
+
let paramSummary: string;
|
|
38
|
+
if (params.command) {
|
|
39
|
+
paramSummary = String(params.command).slice(0, 200);
|
|
40
|
+
} else if (params.file_path) {
|
|
41
|
+
paramSummary = String(params.file_path);
|
|
42
|
+
} else if (params.description) {
|
|
43
|
+
paramSummary = String(params.description).slice(0, 200);
|
|
44
|
+
} else {
|
|
45
|
+
paramSummary = JSON.stringify(params).slice(0, 200);
|
|
46
|
+
}
|
|
47
|
+
return { type: "action", action: `Running ${toolName}`, parameter: paramSummary };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Tool result
|
|
51
|
+
if (type === "tool_result") {
|
|
52
|
+
const status = event.status ?? "unknown";
|
|
53
|
+
const output = event.output ?? "";
|
|
54
|
+
const truncated = output.length > 300 ? output.slice(0, 300) + "..." : output;
|
|
55
|
+
return {
|
|
56
|
+
type: "action",
|
|
57
|
+
action: `Tool ${status}`,
|
|
58
|
+
parameter: truncated || "(no output)",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Final result
|
|
63
|
+
if (type === "result") {
|
|
64
|
+
const stats = event.stats;
|
|
65
|
+
const parts: string[] = ["Gemini completed"];
|
|
66
|
+
if (stats) {
|
|
67
|
+
if (stats.duration_ms) parts.push(`${Math.round(stats.duration_ms / 1000)}s`);
|
|
68
|
+
if (stats.total_tokens) parts.push(`${stats.total_tokens} tokens`);
|
|
69
|
+
if (stats.tool_calls) parts.push(`${stats.tool_calls} tool calls`);
|
|
70
|
+
}
|
|
71
|
+
return { type: "thought", body: parts.join(" — ") };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Run Gemini CLI with JSONL streaming, mapping events to Linear activities.
|
|
79
|
+
*/
|
|
80
|
+
export async function runGemini(
|
|
81
|
+
api: OpenClawPluginApi,
|
|
82
|
+
params: CliToolParams,
|
|
83
|
+
pluginConfig?: Record<string, unknown>,
|
|
84
|
+
): Promise<CliResult> {
|
|
85
|
+
api.logger.info(`gemini_run params: ${JSON.stringify(params).slice(0, 500)}`);
|
|
86
|
+
|
|
87
|
+
const prompt = extractPrompt(params);
|
|
88
|
+
if (!prompt) {
|
|
89
|
+
return {
|
|
90
|
+
success: false,
|
|
91
|
+
output: `gemini_run error: no prompt provided. Received keys: ${Object.keys(params).join(", ")}`,
|
|
92
|
+
error: "missing prompt",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const { model, timeoutMs } = params;
|
|
97
|
+
const { agentSessionId, issueIdentifier } = resolveSession(params);
|
|
98
|
+
|
|
99
|
+
api.logger.info(`gemini_run: session=${agentSessionId ?? "none"}, issue=${issueIdentifier ?? "none"}`);
|
|
100
|
+
|
|
101
|
+
const timeout = timeoutMs ?? (pluginConfig?.geminiTimeoutMs as number) ?? DEFAULT_TIMEOUT_MS;
|
|
102
|
+
const workingDir = params.workingDir ?? (pluginConfig?.geminiBaseRepo as string) ?? DEFAULT_BASE_REPO;
|
|
103
|
+
|
|
104
|
+
const linearApi = buildLinearApi(api, agentSessionId);
|
|
105
|
+
|
|
106
|
+
if (linearApi && agentSessionId) {
|
|
107
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
108
|
+
type: "thought",
|
|
109
|
+
body: `Starting Gemini: "${prompt.slice(0, 100)}${prompt.length > 100 ? "..." : ""}"`,
|
|
110
|
+
}).catch(() => {});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Build gemini command — no -C flag, use cwd in spawn options
|
|
114
|
+
const args = [
|
|
115
|
+
"-p", prompt,
|
|
116
|
+
"-o", "stream-json",
|
|
117
|
+
"--yolo",
|
|
118
|
+
];
|
|
119
|
+
if (model ?? pluginConfig?.geminiModel) {
|
|
120
|
+
args.push("-m", (model ?? pluginConfig?.geminiModel) as string);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
api.logger.info(`Gemini exec: ${GEMINI_BIN} ${args.join(" ").slice(0, 200)}...`);
|
|
124
|
+
|
|
125
|
+
return new Promise<CliResult>((resolve) => {
|
|
126
|
+
const child = spawn(GEMINI_BIN, args, {
|
|
127
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
128
|
+
cwd: workingDir,
|
|
129
|
+
env: { ...process.env },
|
|
130
|
+
timeout: 0,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
let killed = false;
|
|
134
|
+
const timer = setTimeout(() => {
|
|
135
|
+
killed = true;
|
|
136
|
+
child.kill("SIGTERM");
|
|
137
|
+
setTimeout(() => { if (!child.killed) child.kill("SIGKILL"); }, 5_000);
|
|
138
|
+
}, timeout);
|
|
139
|
+
|
|
140
|
+
const collectedMessages: string[] = [];
|
|
141
|
+
const collectedCommands: string[] = [];
|
|
142
|
+
let stderrOutput = "";
|
|
143
|
+
|
|
144
|
+
const rl = createInterface({ input: child.stdout! });
|
|
145
|
+
rl.on("line", (line) => {
|
|
146
|
+
if (!line.trim()) return;
|
|
147
|
+
|
|
148
|
+
let event: any;
|
|
149
|
+
try {
|
|
150
|
+
event = JSON.parse(line);
|
|
151
|
+
} catch {
|
|
152
|
+
// Non-JSON lines (e.g. "YOLO mode" warnings) — skip
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Collect assistant text for final output
|
|
157
|
+
if (event.type === "message" && event.role === "assistant") {
|
|
158
|
+
const text = event.content;
|
|
159
|
+
if (text) collectedMessages.push(text);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Collect tool use/result for final output
|
|
163
|
+
if (event.type === "tool_use") {
|
|
164
|
+
const toolName = event.tool_name ?? "tool";
|
|
165
|
+
const cmd = event.parameters?.command ?? event.parameters?.description ?? "";
|
|
166
|
+
if (cmd) collectedCommands.push(`\`${toolName}\`: ${String(cmd).slice(0, 200)}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (event.type === "tool_result") {
|
|
170
|
+
const output = event.output ?? "";
|
|
171
|
+
const status = event.status ?? "unknown";
|
|
172
|
+
const truncOutput = output.length > 500 ? output.slice(0, 500) + "..." : output;
|
|
173
|
+
if (truncOutput) {
|
|
174
|
+
collectedCommands.push(
|
|
175
|
+
`→ ${status}${truncOutput ? "\n```\n" + truncOutput + "\n```" : ""}`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Stream activity to Linear
|
|
181
|
+
const activity = mapGeminiEventToActivity(event);
|
|
182
|
+
if (activity && linearApi && agentSessionId) {
|
|
183
|
+
linearApi.emitActivity(agentSessionId, activity).catch((err) => {
|
|
184
|
+
api.logger.warn(`Failed to emit Gemini activity: ${err}`);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
child.stderr?.on("data", (chunk) => {
|
|
190
|
+
stderrOutput += chunk.toString();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
child.on("close", (code) => {
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
rl.close();
|
|
196
|
+
|
|
197
|
+
const parts: string[] = [];
|
|
198
|
+
if (collectedMessages.length > 0) parts.push(collectedMessages.join("\n\n"));
|
|
199
|
+
if (collectedCommands.length > 0) parts.push(collectedCommands.join("\n\n"));
|
|
200
|
+
const output = parts.join("\n\n") || stderrOutput || "(no output)";
|
|
201
|
+
|
|
202
|
+
if (killed) {
|
|
203
|
+
api.logger.warn(`Gemini timed out after ${timeout}ms`);
|
|
204
|
+
resolve({
|
|
205
|
+
success: false,
|
|
206
|
+
output: `Gemini timed out after ${Math.round(timeout / 1000)}s. Partial output:\n${output}`,
|
|
207
|
+
error: "timeout",
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (code !== 0) {
|
|
213
|
+
api.logger.warn(`Gemini exited with code ${code}`);
|
|
214
|
+
resolve({
|
|
215
|
+
success: false,
|
|
216
|
+
output: `Gemini failed (exit ${code}):\n${output}`,
|
|
217
|
+
error: `exit ${code}`,
|
|
218
|
+
});
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
api.logger.info(`Gemini completed successfully`);
|
|
223
|
+
resolve({ success: true, output });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
child.on("error", (err) => {
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
rl.close();
|
|
229
|
+
api.logger.error(`Gemini spawn error: ${err}`);
|
|
230
|
+
resolve({
|
|
231
|
+
success: false,
|
|
232
|
+
output: `Failed to start Gemini: ${err.message}`,
|
|
233
|
+
error: err.message,
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { jsonResult } from "openclaw/plugin-sdk";
|
|
3
|
+
import { runAgent } from "./agent.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create orchestration tools that let agents delegate work to other crew agents.
|
|
7
|
+
*
|
|
8
|
+
* - spawn_agent: Fire-and-forget parallel delegation (non-blocking)
|
|
9
|
+
* - ask_agent: Synchronous question-answer with another agent
|
|
10
|
+
*/
|
|
11
|
+
export function createOrchestrationTools(
|
|
12
|
+
api: OpenClawPluginApi,
|
|
13
|
+
_ctx: Record<string, unknown>,
|
|
14
|
+
): AnyAgentTool[] {
|
|
15
|
+
return [
|
|
16
|
+
{
|
|
17
|
+
name: "spawn_agent",
|
|
18
|
+
label: "Spawn Agent",
|
|
19
|
+
description:
|
|
20
|
+
"Delegate a task to another crew agent. Runs in the background — does not block. " +
|
|
21
|
+
"Use this when you want to parallelize work (e.g., ask kaylee to investigate DB performance " +
|
|
22
|
+
"while you continue working on something else).",
|
|
23
|
+
parameters: {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
agentId: {
|
|
27
|
+
type: "string",
|
|
28
|
+
description:
|
|
29
|
+
"Which agent to dispatch (e.g., 'kaylee', 'inara', 'mal'). Must match an agent ID in openclaw.json.",
|
|
30
|
+
},
|
|
31
|
+
task: {
|
|
32
|
+
type: "string",
|
|
33
|
+
description: "Description of what the sub-agent should do.",
|
|
34
|
+
},
|
|
35
|
+
timeoutSeconds: {
|
|
36
|
+
type: "number",
|
|
37
|
+
description: "Max runtime in seconds (default: 300).",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
required: ["agentId", "task"],
|
|
41
|
+
},
|
|
42
|
+
execute: async (_toolCallId: string, { agentId, task, timeoutSeconds }: {
|
|
43
|
+
agentId: string;
|
|
44
|
+
task: string;
|
|
45
|
+
timeoutSeconds?: number;
|
|
46
|
+
}) => {
|
|
47
|
+
const timeout = (timeoutSeconds ?? 300) * 1000;
|
|
48
|
+
const sessionId = `spawn-${agentId}-${Date.now()}`;
|
|
49
|
+
|
|
50
|
+
api.logger.info(`spawn_agent: dispatching ${agentId} — "${task.slice(0, 80)}..."`);
|
|
51
|
+
|
|
52
|
+
// Fire and forget — don't await the full result
|
|
53
|
+
const resultPromise = runAgent({
|
|
54
|
+
api,
|
|
55
|
+
agentId,
|
|
56
|
+
sessionId,
|
|
57
|
+
message: task,
|
|
58
|
+
timeoutMs: timeout,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Store the promise so it can be retrieved later if needed
|
|
62
|
+
resultPromise.catch((err) => {
|
|
63
|
+
api.logger.error(`spawn_agent ${agentId} failed: ${err}`);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return jsonResult({
|
|
67
|
+
message: `Dispatched task to agent '${agentId}'. It is running in the background.`,
|
|
68
|
+
agentId,
|
|
69
|
+
sessionId,
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
} as unknown as AnyAgentTool,
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
name: "ask_agent",
|
|
76
|
+
label: "Ask Agent",
|
|
77
|
+
description:
|
|
78
|
+
"Ask another crew agent a question and wait for their reply. " +
|
|
79
|
+
"Use this when you need a specific answer before proceeding " +
|
|
80
|
+
"(e.g., 'wash, would this schema change break existing tests?').",
|
|
81
|
+
parameters: {
|
|
82
|
+
type: "object",
|
|
83
|
+
properties: {
|
|
84
|
+
agentId: {
|
|
85
|
+
type: "string",
|
|
86
|
+
description:
|
|
87
|
+
"Which agent to ask (e.g., 'kaylee', 'inara', 'mal'). Must match an agent ID in openclaw.json.",
|
|
88
|
+
},
|
|
89
|
+
message: {
|
|
90
|
+
type: "string",
|
|
91
|
+
description: "The question or request for the other agent.",
|
|
92
|
+
},
|
|
93
|
+
timeoutSeconds: {
|
|
94
|
+
type: "number",
|
|
95
|
+
description: "How long to wait for a reply in seconds (default: 120).",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
required: ["agentId", "message"],
|
|
99
|
+
},
|
|
100
|
+
execute: async (_toolCallId: string, { agentId, message, timeoutSeconds }: {
|
|
101
|
+
agentId: string;
|
|
102
|
+
message: string;
|
|
103
|
+
timeoutSeconds?: number;
|
|
104
|
+
}) => {
|
|
105
|
+
const timeout = (timeoutSeconds ?? 120) * 1000;
|
|
106
|
+
const sessionId = `ask-${agentId}-${Date.now()}`;
|
|
107
|
+
|
|
108
|
+
api.logger.info(`ask_agent: asking ${agentId} — "${message.slice(0, 80)}..."`);
|
|
109
|
+
|
|
110
|
+
const result = await runAgent({
|
|
111
|
+
api,
|
|
112
|
+
agentId,
|
|
113
|
+
sessionId,
|
|
114
|
+
message,
|
|
115
|
+
timeoutMs: timeout,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (!result.success) {
|
|
119
|
+
return jsonResult({
|
|
120
|
+
message: `Agent '${agentId}' failed to respond.`,
|
|
121
|
+
error: result.output.slice(0, 1000),
|
|
122
|
+
agentId,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return jsonResult({
|
|
127
|
+
message: `Response from agent '${agentId}':`,
|
|
128
|
+
agentId,
|
|
129
|
+
response: result.output,
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
} as unknown as AnyAgentTool,
|
|
133
|
+
];
|
|
134
|
+
}
|