@calltelemetry/openclaw-linear 0.9.14 → 0.9.16
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 +104 -48
- package/index.ts +57 -0
- package/openclaw.plugin.json +44 -2
- package/package.json +1 -1
- package/prompts.yaml +3 -1
- package/src/__test__/fixtures/recorded-sub-issue-flow.ts +37 -50
- package/src/agent/agent.test.ts +1 -1
- package/src/agent/agent.ts +39 -6
- package/src/api/linear-api.test.ts +188 -1
- package/src/api/linear-api.ts +114 -5
- package/src/infra/multi-repo.test.ts +127 -1
- package/src/infra/multi-repo.ts +74 -6
- package/src/infra/tmux-runner.ts +599 -0
- package/src/infra/tmux.ts +158 -0
- package/src/infra/token-refresh-timer.ts +44 -0
- package/src/pipeline/active-session.ts +19 -1
- package/src/pipeline/artifacts.ts +42 -0
- package/src/pipeline/dispatch-state.ts +3 -0
- package/src/pipeline/guidance.test.ts +53 -0
- package/src/pipeline/guidance.ts +38 -0
- package/src/pipeline/memory-search.ts +40 -0
- package/src/pipeline/pipeline.ts +184 -17
- package/src/pipeline/retro.ts +231 -0
- package/src/pipeline/webhook.test.ts +8 -8
- package/src/pipeline/webhook.ts +408 -29
- package/src/tools/claude-tool.ts +68 -10
- package/src/tools/cli-shared.ts +50 -2
- package/src/tools/code-tool.ts +230 -150
- package/src/tools/codex-tool.ts +61 -9
- package/src/tools/gemini-tool.ts +61 -10
- package/src/tools/steering-tools.ts +176 -0
- package/src/tools/tools.test.ts +47 -15
- package/src/tools/tools.ts +17 -4
- package/src/__test__/smoke-linear-api.test.ts +0 -847
package/src/tools/codex-tool.ts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
|
+
import path from "node:path";
|
|
3
4
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
4
5
|
import type { ActivityContent } from "../api/linear-api.js";
|
|
5
6
|
import {
|
|
6
7
|
buildLinearApi,
|
|
7
8
|
resolveSession,
|
|
8
9
|
extractPrompt,
|
|
9
|
-
DEFAULT_TIMEOUT_MS,
|
|
10
10
|
DEFAULT_BASE_REPO,
|
|
11
|
+
formatActivityLogLine,
|
|
12
|
+
createProgressEmitter,
|
|
11
13
|
type CliToolParams,
|
|
12
14
|
type CliResult,
|
|
15
|
+
type OnProgressUpdate,
|
|
13
16
|
} from "./cli-shared.js";
|
|
14
17
|
import { InactivityWatchdog, resolveWatchdogConfig } from "../agent/watchdog.js";
|
|
18
|
+
import { isTmuxAvailable, buildSessionName, shellEscape } from "../infra/tmux.js";
|
|
19
|
+
import { runInTmux } from "../infra/tmux-runner.js";
|
|
15
20
|
|
|
16
21
|
const CODEX_BIN = "codex";
|
|
17
22
|
|
|
@@ -51,7 +56,7 @@ function mapCodexEventToActivity(event: any): ActivityContent | null {
|
|
|
51
56
|
const cleaned = typeof cmd === "string"
|
|
52
57
|
? cmd.replace(/^\/usr\/bin\/\w+ -lc ['"]?/, "").replace(/['"]?$/, "")
|
|
53
58
|
: JSON.stringify(cmd);
|
|
54
|
-
const truncated = output.length >
|
|
59
|
+
const truncated = output.length > 1000 ? output.slice(0, 1000) + "..." : output;
|
|
55
60
|
return {
|
|
56
61
|
type: "action",
|
|
57
62
|
action: `${cleaned.slice(0, 150)}`,
|
|
@@ -63,7 +68,8 @@ function mapCodexEventToActivity(event: any): ActivityContent | null {
|
|
|
63
68
|
if (eventType === "item.completed" && item?.type === "file_changes") {
|
|
64
69
|
const files = item.files ?? [];
|
|
65
70
|
const fileList = Array.isArray(files) ? files.join(", ") : String(files);
|
|
66
|
-
|
|
71
|
+
const preview = (item.diff ?? item.content ?? "").slice(0, 500) || undefined;
|
|
72
|
+
return { type: "action", action: "Modified files", parameter: fileList || "unknown files", result: preview };
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
if (eventType === "turn.completed") {
|
|
@@ -87,6 +93,7 @@ export async function runCodex(
|
|
|
87
93
|
api: OpenClawPluginApi,
|
|
88
94
|
params: CliToolParams,
|
|
89
95
|
pluginConfig?: Record<string, unknown>,
|
|
96
|
+
onUpdate?: OnProgressUpdate,
|
|
90
97
|
): Promise<CliResult> {
|
|
91
98
|
api.logger.info(`codex_run params: ${JSON.stringify(params).slice(0, 500)}`);
|
|
92
99
|
|
|
@@ -100,7 +107,7 @@ export async function runCodex(
|
|
|
100
107
|
}
|
|
101
108
|
|
|
102
109
|
const { model, timeoutMs } = params;
|
|
103
|
-
const { agentSessionId, issueIdentifier } = resolveSession(params);
|
|
110
|
+
const { agentSessionId, issueId, issueIdentifier } = resolveSession(params);
|
|
104
111
|
|
|
105
112
|
api.logger.info(`codex_run: session=${agentSessionId ?? "none"}, issue=${issueIdentifier ?? "none"}`);
|
|
106
113
|
|
|
@@ -127,11 +134,50 @@ export async function runCodex(
|
|
|
127
134
|
args.push("-C", workingDir);
|
|
128
135
|
args.push(prompt);
|
|
129
136
|
|
|
130
|
-
|
|
137
|
+
const fullCommand = `${CODEX_BIN} ${args.join(" ")}`;
|
|
138
|
+
api.logger.info(`Codex exec: ${fullCommand.slice(0, 200)}...`);
|
|
139
|
+
|
|
140
|
+
const progressHeader = `[codex] ${workingDir}\n$ ${fullCommand.slice(0, 500)}\n\nPrompt: ${prompt}`;
|
|
141
|
+
|
|
142
|
+
// --- tmux path: run inside a tmux session with pipe-pane streaming ---
|
|
143
|
+
const tmuxEnabled = pluginConfig?.enableTmux !== false;
|
|
144
|
+
if (tmuxEnabled && isTmuxAvailable()) {
|
|
145
|
+
const sessionName = buildSessionName(issueIdentifier ?? "unknown", "codex", 0);
|
|
146
|
+
const tmuxIssueId = issueId ?? sessionName;
|
|
147
|
+
const modelArgs = (model ?? pluginConfig?.codexModel)
|
|
148
|
+
? `--model ${shellEscape((model ?? pluginConfig?.codexModel) as string)}`
|
|
149
|
+
: "";
|
|
150
|
+
const cmdStr = [
|
|
151
|
+
CODEX_BIN, "exec", "--full-auto", "--json", "--ephemeral",
|
|
152
|
+
modelArgs,
|
|
153
|
+
"-C", shellEscape(workingDir),
|
|
154
|
+
shellEscape(prompt),
|
|
155
|
+
].filter(Boolean).join(" ");
|
|
156
|
+
|
|
157
|
+
return runInTmux({
|
|
158
|
+
issueId: tmuxIssueId,
|
|
159
|
+
issueIdentifier: issueIdentifier ?? "unknown",
|
|
160
|
+
sessionName,
|
|
161
|
+
command: cmdStr,
|
|
162
|
+
cwd: workingDir,
|
|
163
|
+
timeoutMs: timeout,
|
|
164
|
+
watchdogMs: wdConfig.inactivityMs,
|
|
165
|
+
logPath: path.join(workingDir, ".claw", `tmux-${sessionName}.jsonl`),
|
|
166
|
+
mapEvent: mapCodexEventToActivity,
|
|
167
|
+
linearApi: linearApi ?? undefined,
|
|
168
|
+
agentSessionId: agentSessionId ?? undefined,
|
|
169
|
+
steeringMode: "one-shot",
|
|
170
|
+
logger: api.logger,
|
|
171
|
+
onUpdate,
|
|
172
|
+
progressHeader,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
131
175
|
|
|
176
|
+
// --- fallback: direct spawn ---
|
|
132
177
|
return new Promise<CliResult>((resolve) => {
|
|
133
178
|
const child = spawn(CODEX_BIN, args, {
|
|
134
179
|
stdio: ["ignore", "pipe", "pipe"],
|
|
180
|
+
cwd: workingDir,
|
|
135
181
|
env: { ...process.env },
|
|
136
182
|
timeout: 0,
|
|
137
183
|
});
|
|
@@ -162,6 +208,9 @@ export async function runCodex(
|
|
|
162
208
|
const collectedCommands: string[] = [];
|
|
163
209
|
let stderrOutput = "";
|
|
164
210
|
|
|
211
|
+
const progress = createProgressEmitter({ header: progressHeader, onUpdate });
|
|
212
|
+
progress.emitHeader();
|
|
213
|
+
|
|
165
214
|
const rl = createInterface({ input: child.stdout! });
|
|
166
215
|
rl.on("line", (line) => {
|
|
167
216
|
if (!line.trim()) return;
|
|
@@ -200,10 +249,13 @@ export async function runCodex(
|
|
|
200
249
|
}
|
|
201
250
|
|
|
202
251
|
const activity = mapCodexEventToActivity(event);
|
|
203
|
-
if (activity
|
|
204
|
-
linearApi
|
|
205
|
-
|
|
206
|
-
|
|
252
|
+
if (activity) {
|
|
253
|
+
if (linearApi && agentSessionId) {
|
|
254
|
+
linearApi.emitActivity(agentSessionId, activity).catch((err) => {
|
|
255
|
+
api.logger.warn(`Failed to emit Codex activity: ${err}`);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
progress.push(formatActivityLogLine(activity));
|
|
207
259
|
}
|
|
208
260
|
});
|
|
209
261
|
|
package/src/tools/gemini-tool.ts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
|
+
import path from "node:path";
|
|
3
4
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
4
5
|
import type { ActivityContent } from "../api/linear-api.js";
|
|
5
6
|
import {
|
|
6
7
|
buildLinearApi,
|
|
7
8
|
resolveSession,
|
|
8
9
|
extractPrompt,
|
|
9
|
-
DEFAULT_TIMEOUT_MS,
|
|
10
10
|
DEFAULT_BASE_REPO,
|
|
11
|
+
formatActivityLogLine,
|
|
12
|
+
createProgressEmitter,
|
|
11
13
|
type CliToolParams,
|
|
12
14
|
type CliResult,
|
|
15
|
+
type OnProgressUpdate,
|
|
13
16
|
} from "./cli-shared.js";
|
|
14
17
|
import { InactivityWatchdog, resolveWatchdogConfig } from "../agent/watchdog.js";
|
|
18
|
+
import { isTmuxAvailable, buildSessionName, shellEscape } from "../infra/tmux.js";
|
|
19
|
+
import { runInTmux } from "../infra/tmux-runner.js";
|
|
15
20
|
|
|
16
21
|
const GEMINI_BIN = "gemini";
|
|
17
22
|
|
|
@@ -43,7 +48,7 @@ function mapGeminiEventToActivity(event: any): ActivityContent | null {
|
|
|
43
48
|
} else if (params.description) {
|
|
44
49
|
paramSummary = String(params.description).slice(0, 200);
|
|
45
50
|
} else {
|
|
46
|
-
paramSummary = JSON.stringify(params).slice(0,
|
|
51
|
+
paramSummary = JSON.stringify(params).slice(0, 500);
|
|
47
52
|
}
|
|
48
53
|
return { type: "action", action: `Running ${toolName}`, parameter: paramSummary };
|
|
49
54
|
}
|
|
@@ -52,7 +57,7 @@ function mapGeminiEventToActivity(event: any): ActivityContent | null {
|
|
|
52
57
|
if (type === "tool_result") {
|
|
53
58
|
const status = event.status ?? "unknown";
|
|
54
59
|
const output = event.output ?? "";
|
|
55
|
-
const truncated = output.length >
|
|
60
|
+
const truncated = output.length > 1000 ? output.slice(0, 1000) + "..." : output;
|
|
56
61
|
return {
|
|
57
62
|
type: "action",
|
|
58
63
|
action: `Tool ${status}`,
|
|
@@ -82,6 +87,7 @@ export async function runGemini(
|
|
|
82
87
|
api: OpenClawPluginApi,
|
|
83
88
|
params: CliToolParams,
|
|
84
89
|
pluginConfig?: Record<string, unknown>,
|
|
90
|
+
onUpdate?: OnProgressUpdate,
|
|
85
91
|
): Promise<CliResult> {
|
|
86
92
|
api.logger.info(`gemini_run params: ${JSON.stringify(params).slice(0, 500)}`);
|
|
87
93
|
|
|
@@ -95,7 +101,7 @@ export async function runGemini(
|
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
const { model, timeoutMs } = params;
|
|
98
|
-
const { agentSessionId, issueIdentifier } = resolveSession(params);
|
|
104
|
+
const { agentSessionId, issueId, issueIdentifier } = resolveSession(params);
|
|
99
105
|
|
|
100
106
|
api.logger.info(`gemini_run: session=${agentSessionId ?? "none"}, issue=${issueIdentifier ?? "none"}`);
|
|
101
107
|
|
|
@@ -123,8 +129,47 @@ export async function runGemini(
|
|
|
123
129
|
args.push("-m", (model ?? pluginConfig?.geminiModel) as string);
|
|
124
130
|
}
|
|
125
131
|
|
|
126
|
-
|
|
132
|
+
const fullCommand = `${GEMINI_BIN} ${args.join(" ")}`;
|
|
133
|
+
api.logger.info(`Gemini exec: ${fullCommand.slice(0, 200)}...`);
|
|
134
|
+
|
|
135
|
+
const progressHeader = `[gemini] ${workingDir}\n$ ${fullCommand.slice(0, 500)}\n\nPrompt: ${prompt}`;
|
|
136
|
+
|
|
137
|
+
// --- tmux path: run inside a tmux session with pipe-pane streaming ---
|
|
138
|
+
const tmuxEnabled = pluginConfig?.enableTmux !== false;
|
|
139
|
+
if (tmuxEnabled && isTmuxAvailable()) {
|
|
140
|
+
const sessionName = buildSessionName(issueIdentifier ?? "unknown", "gemini", 0);
|
|
141
|
+
const tmuxIssueId = issueId ?? sessionName;
|
|
142
|
+
const modelArgs = (model ?? pluginConfig?.geminiModel)
|
|
143
|
+
? `-m ${shellEscape((model ?? pluginConfig?.geminiModel) as string)}`
|
|
144
|
+
: "";
|
|
145
|
+
const cmdStr = [
|
|
146
|
+
GEMINI_BIN,
|
|
147
|
+
"-p", shellEscape(prompt),
|
|
148
|
+
"-o", "stream-json",
|
|
149
|
+
"--yolo",
|
|
150
|
+
modelArgs,
|
|
151
|
+
].filter(Boolean).join(" ");
|
|
152
|
+
|
|
153
|
+
return runInTmux({
|
|
154
|
+
issueId: tmuxIssueId,
|
|
155
|
+
issueIdentifier: issueIdentifier ?? "unknown",
|
|
156
|
+
sessionName,
|
|
157
|
+
command: cmdStr,
|
|
158
|
+
cwd: workingDir,
|
|
159
|
+
timeoutMs: timeout,
|
|
160
|
+
watchdogMs: wdConfig.inactivityMs,
|
|
161
|
+
logPath: path.join(workingDir, ".claw", `tmux-${sessionName}.jsonl`),
|
|
162
|
+
mapEvent: mapGeminiEventToActivity,
|
|
163
|
+
linearApi: linearApi ?? undefined,
|
|
164
|
+
agentSessionId: agentSessionId ?? undefined,
|
|
165
|
+
steeringMode: "stdin-pipe",
|
|
166
|
+
logger: api.logger,
|
|
167
|
+
onUpdate,
|
|
168
|
+
progressHeader,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
127
171
|
|
|
172
|
+
// --- fallback: direct spawn ---
|
|
128
173
|
return new Promise<CliResult>((resolve) => {
|
|
129
174
|
const child = spawn(GEMINI_BIN, args, {
|
|
130
175
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -159,6 +204,9 @@ export async function runGemini(
|
|
|
159
204
|
const collectedCommands: string[] = [];
|
|
160
205
|
let stderrOutput = "";
|
|
161
206
|
|
|
207
|
+
const progress = createProgressEmitter({ header: progressHeader, onUpdate });
|
|
208
|
+
progress.emitHeader();
|
|
209
|
+
|
|
162
210
|
const rl = createInterface({ input: child.stdout! });
|
|
163
211
|
rl.on("line", (line) => {
|
|
164
212
|
if (!line.trim()) return;
|
|
@@ -196,12 +244,15 @@ export async function runGemini(
|
|
|
196
244
|
}
|
|
197
245
|
}
|
|
198
246
|
|
|
199
|
-
// Stream activity to Linear
|
|
247
|
+
// Stream activity to Linear + session progress
|
|
200
248
|
const activity = mapGeminiEventToActivity(event);
|
|
201
|
-
if (activity
|
|
202
|
-
linearApi
|
|
203
|
-
|
|
204
|
-
|
|
249
|
+
if (activity) {
|
|
250
|
+
if (linearApi && agentSessionId) {
|
|
251
|
+
linearApi.emitActivity(agentSessionId, activity).catch((err) => {
|
|
252
|
+
api.logger.warn(`Failed to emit Gemini activity: ${err}`);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
progress.push(formatActivityLogLine(activity));
|
|
205
256
|
}
|
|
206
257
|
});
|
|
207
258
|
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { jsonResult } from "openclaw/plugin-sdk";
|
|
3
|
+
import { getActiveTmuxSession, unregisterTmuxSession } from "../infra/tmux-runner.js";
|
|
4
|
+
import { sendKeys, capturePane, killSession } from "../infra/tmux.js";
|
|
5
|
+
|
|
6
|
+
export function createSteeringTools(
|
|
7
|
+
api: OpenClawPluginApi,
|
|
8
|
+
_ctx: Record<string, unknown>,
|
|
9
|
+
): AnyAgentTool[] {
|
|
10
|
+
return [
|
|
11
|
+
// Tool 1: steer_agent
|
|
12
|
+
{
|
|
13
|
+
name: "steer_agent",
|
|
14
|
+
label: "Steer Agent",
|
|
15
|
+
description:
|
|
16
|
+
"Send a message to the running coding agent. Only works for Claude/Gemini " +
|
|
17
|
+
"(stdin-pipe mode). Codex is one-shot and cannot be steered mid-run. " +
|
|
18
|
+
"Use this to inject precise, actionable instructions — do NOT forward raw user text.",
|
|
19
|
+
parameters: {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
text: {
|
|
23
|
+
type: "string",
|
|
24
|
+
description: "The text to inject into the agent's stdin. Should be a precise, actionable instruction.",
|
|
25
|
+
},
|
|
26
|
+
issueId: {
|
|
27
|
+
type: "string",
|
|
28
|
+
description: "Linear issue UUID of the running agent session.",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
required: ["text", "issueId"],
|
|
32
|
+
},
|
|
33
|
+
execute: async (_toolCallId: string, params: { text: string; issueId: string }) => {
|
|
34
|
+
const { text, issueId } = params;
|
|
35
|
+
const session = getActiveTmuxSession(issueId);
|
|
36
|
+
if (!session) {
|
|
37
|
+
return jsonResult({
|
|
38
|
+
success: false,
|
|
39
|
+
error: "no_active_session",
|
|
40
|
+
message: `No active tmux session found for issue ${issueId}. The agent may have already completed.`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (session.steeringMode === "one-shot") {
|
|
44
|
+
return jsonResult({
|
|
45
|
+
success: false,
|
|
46
|
+
error: "one_shot_mode",
|
|
47
|
+
message: `Cannot steer ${session.backend} — it runs in one-shot mode (Codex exec). ` +
|
|
48
|
+
`You can abort and re-dispatch with updated instructions, or wait for it to complete.`,
|
|
49
|
+
backend: session.backend,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
sendKeys(session.sessionName, text);
|
|
54
|
+
api.logger.info(`steer_agent: sent ${text.length} chars to ${session.sessionName}`);
|
|
55
|
+
return jsonResult({
|
|
56
|
+
success: true,
|
|
57
|
+
message: `Sent steering input to ${session.backend} agent (${session.issueIdentifier}).`,
|
|
58
|
+
backend: session.backend,
|
|
59
|
+
sessionName: session.sessionName,
|
|
60
|
+
});
|
|
61
|
+
} catch (err) {
|
|
62
|
+
api.logger.error(`steer_agent error: ${err}`);
|
|
63
|
+
return jsonResult({
|
|
64
|
+
success: false,
|
|
65
|
+
error: "send_failed",
|
|
66
|
+
message: `Failed to send keys to tmux session: ${err}`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
} as unknown as AnyAgentTool,
|
|
71
|
+
|
|
72
|
+
// Tool 2: capture_agent_output
|
|
73
|
+
{
|
|
74
|
+
name: "capture_agent_output",
|
|
75
|
+
label: "Capture Agent Output",
|
|
76
|
+
description:
|
|
77
|
+
"Capture the last N lines of terminal output from the running coding agent. " +
|
|
78
|
+
"Use this to check what the agent is doing before deciding whether to steer, respond, or abort.",
|
|
79
|
+
parameters: {
|
|
80
|
+
type: "object",
|
|
81
|
+
properties: {
|
|
82
|
+
issueId: {
|
|
83
|
+
type: "string",
|
|
84
|
+
description: "Linear issue UUID of the running agent session.",
|
|
85
|
+
},
|
|
86
|
+
lines: {
|
|
87
|
+
type: "number",
|
|
88
|
+
description: "Number of lines to capture (default: 50, max: 200).",
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
required: ["issueId"],
|
|
92
|
+
},
|
|
93
|
+
execute: async (_toolCallId: string, params: { issueId: string; lines?: number }) => {
|
|
94
|
+
const { issueId, lines } = params;
|
|
95
|
+
const session = getActiveTmuxSession(issueId);
|
|
96
|
+
if (!session) {
|
|
97
|
+
return jsonResult({
|
|
98
|
+
success: false,
|
|
99
|
+
error: "no_active_session",
|
|
100
|
+
message: `No active tmux session found for issue ${issueId}.`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const lineCount = Math.min(lines ?? 50, 200);
|
|
105
|
+
const output = capturePane(session.sessionName, lineCount);
|
|
106
|
+
return jsonResult({
|
|
107
|
+
success: true,
|
|
108
|
+
backend: session.backend,
|
|
109
|
+
issueIdentifier: session.issueIdentifier,
|
|
110
|
+
sessionName: session.sessionName,
|
|
111
|
+
output: output || "(no output captured)",
|
|
112
|
+
linesCaptured: lineCount,
|
|
113
|
+
});
|
|
114
|
+
} catch (err) {
|
|
115
|
+
api.logger.error(`capture_agent_output error: ${err}`);
|
|
116
|
+
return jsonResult({
|
|
117
|
+
success: false,
|
|
118
|
+
error: "capture_failed",
|
|
119
|
+
message: `Failed to capture pane output: ${err}`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
} as unknown as AnyAgentTool,
|
|
124
|
+
|
|
125
|
+
// Tool 3: abort_agent
|
|
126
|
+
{
|
|
127
|
+
name: "abort_agent",
|
|
128
|
+
label: "Abort Agent",
|
|
129
|
+
description:
|
|
130
|
+
"Kill the running coding agent session. Use when the user wants to stop, retry with different instructions, " +
|
|
131
|
+
"or when the agent is stuck. Works for all backends (Claude, Codex, Gemini).",
|
|
132
|
+
parameters: {
|
|
133
|
+
type: "object",
|
|
134
|
+
properties: {
|
|
135
|
+
issueId: {
|
|
136
|
+
type: "string",
|
|
137
|
+
description: "Linear issue UUID of the running agent session.",
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
required: ["issueId"],
|
|
141
|
+
},
|
|
142
|
+
execute: async (_toolCallId: string, params: { issueId: string }) => {
|
|
143
|
+
const { issueId } = params;
|
|
144
|
+
const session = getActiveTmuxSession(issueId);
|
|
145
|
+
if (!session) {
|
|
146
|
+
return jsonResult({
|
|
147
|
+
success: false,
|
|
148
|
+
error: "no_active_session",
|
|
149
|
+
message: `No active tmux session found for issue ${issueId}. It may have already completed.`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
killSession(session.sessionName);
|
|
154
|
+
unregisterTmuxSession(issueId);
|
|
155
|
+
api.logger.info(`abort_agent: killed ${session.sessionName} (${session.backend})`);
|
|
156
|
+
return jsonResult({
|
|
157
|
+
success: true,
|
|
158
|
+
message: `Killed ${session.backend} agent for ${session.issueIdentifier}. ` +
|
|
159
|
+
`The session has been terminated. You can re-dispatch with updated instructions.`,
|
|
160
|
+
backend: session.backend,
|
|
161
|
+
issueIdentifier: session.issueIdentifier,
|
|
162
|
+
});
|
|
163
|
+
} catch (err) {
|
|
164
|
+
api.logger.error(`abort_agent error: ${err}`);
|
|
165
|
+
// Still try to unregister even if kill fails
|
|
166
|
+
unregisterTmuxSession(issueId);
|
|
167
|
+
return jsonResult({
|
|
168
|
+
success: false,
|
|
169
|
+
error: "kill_failed",
|
|
170
|
+
message: `Failed to kill tmux session: ${err}. Session unregistered from registry.`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
} as unknown as AnyAgentTool,
|
|
175
|
+
];
|
|
176
|
+
}
|
package/src/tools/tools.test.ts
CHANGED
|
@@ -7,7 +7,16 @@
|
|
|
7
7
|
import { describe, expect, it, vi } from "vitest";
|
|
8
8
|
|
|
9
9
|
vi.mock("./code-tool.js", () => ({
|
|
10
|
-
|
|
10
|
+
createCodeTools: vi.fn(() => [
|
|
11
|
+
{ name: "cli_codex", execute: vi.fn() },
|
|
12
|
+
{ name: "cli_claude", execute: vi.fn() },
|
|
13
|
+
{ name: "cli_gemini", execute: vi.fn() },
|
|
14
|
+
]),
|
|
15
|
+
createCodeTool: vi.fn(() => [
|
|
16
|
+
{ name: "cli_codex", execute: vi.fn() },
|
|
17
|
+
{ name: "cli_claude", execute: vi.fn() },
|
|
18
|
+
{ name: "cli_gemini", execute: vi.fn() },
|
|
19
|
+
]),
|
|
11
20
|
}));
|
|
12
21
|
|
|
13
22
|
vi.mock("./orchestration-tools.js", () => ({
|
|
@@ -21,8 +30,16 @@ vi.mock("./linear-issues-tool.js", () => ({
|
|
|
21
30
|
createLinearIssuesTool: vi.fn(() => ({ name: "linear_issues", execute: vi.fn() })),
|
|
22
31
|
}));
|
|
23
32
|
|
|
33
|
+
vi.mock("./steering-tools.js", () => ({
|
|
34
|
+
createSteeringTools: vi.fn(() => [
|
|
35
|
+
{ name: "steer_agent", execute: vi.fn() },
|
|
36
|
+
{ name: "capture_agent_output", execute: vi.fn() },
|
|
37
|
+
{ name: "abort_agent", execute: vi.fn() },
|
|
38
|
+
]),
|
|
39
|
+
}));
|
|
40
|
+
|
|
24
41
|
import { createLinearTools } from "./tools.js";
|
|
25
|
-
import {
|
|
42
|
+
import { createCodeTools } from "./code-tool.js";
|
|
26
43
|
import { createOrchestrationTools } from "./orchestration-tools.js";
|
|
27
44
|
import { createLinearIssuesTool } from "./linear-issues-tool.js";
|
|
28
45
|
|
|
@@ -43,16 +60,21 @@ function makeApi(pluginConfig?: Record<string, unknown>) {
|
|
|
43
60
|
// ── Tests ──────────────────────────────────────────────────────────
|
|
44
61
|
|
|
45
62
|
describe("createLinearTools", () => {
|
|
46
|
-
it("returns
|
|
63
|
+
it("returns cli_codex, cli_claude, cli_gemini, orchestration, linear_issues, and steering tools", () => {
|
|
47
64
|
const api = makeApi();
|
|
48
65
|
const tools = createLinearTools(api, {});
|
|
49
66
|
|
|
50
|
-
expect(tools).toHaveLength(
|
|
67
|
+
expect(tools).toHaveLength(9);
|
|
51
68
|
const names = tools.map((t: any) => t.name);
|
|
52
|
-
expect(names).toContain("
|
|
69
|
+
expect(names).toContain("cli_codex");
|
|
70
|
+
expect(names).toContain("cli_claude");
|
|
71
|
+
expect(names).toContain("cli_gemini");
|
|
53
72
|
expect(names).toContain("spawn_agent");
|
|
54
73
|
expect(names).toContain("ask_agent");
|
|
55
74
|
expect(names).toContain("linear_issues");
|
|
75
|
+
expect(names).toContain("steer_agent");
|
|
76
|
+
expect(names).toContain("capture_agent_output");
|
|
77
|
+
expect(names).toContain("abort_agent");
|
|
56
78
|
});
|
|
57
79
|
|
|
58
80
|
it("includes orchestration tools by default", () => {
|
|
@@ -67,28 +89,32 @@ describe("createLinearTools", () => {
|
|
|
67
89
|
const api = makeApi({ enableOrchestration: false });
|
|
68
90
|
const tools = createLinearTools(api, {});
|
|
69
91
|
|
|
70
|
-
expect(tools).toHaveLength(
|
|
92
|
+
expect(tools).toHaveLength(7);
|
|
71
93
|
const names = tools.map((t: any) => t.name);
|
|
72
|
-
expect(names).toContain("
|
|
94
|
+
expect(names).toContain("cli_codex");
|
|
95
|
+
expect(names).toContain("cli_claude");
|
|
96
|
+
expect(names).toContain("cli_gemini");
|
|
73
97
|
expect(names).toContain("linear_issues");
|
|
98
|
+
expect(names).toContain("steer_agent");
|
|
74
99
|
expect(createOrchestrationTools).not.toHaveBeenCalled();
|
|
75
100
|
});
|
|
76
101
|
|
|
77
|
-
it("handles
|
|
78
|
-
vi.mocked(
|
|
102
|
+
it("handles CLI tools creation failure gracefully", () => {
|
|
103
|
+
vi.mocked(createCodeTools).mockImplementationOnce(() => {
|
|
79
104
|
throw new Error("CLI not found");
|
|
80
105
|
});
|
|
81
106
|
|
|
82
107
|
const api = makeApi();
|
|
83
108
|
const tools = createLinearTools(api, {});
|
|
84
109
|
|
|
85
|
-
expect(tools).toHaveLength(
|
|
110
|
+
expect(tools).toHaveLength(6);
|
|
86
111
|
const names = tools.map((t: any) => t.name);
|
|
87
112
|
expect(names).toContain("spawn_agent");
|
|
88
113
|
expect(names).toContain("ask_agent");
|
|
89
114
|
expect(names).toContain("linear_issues");
|
|
115
|
+
expect(names).toContain("steer_agent");
|
|
90
116
|
expect(api.logger.warn).toHaveBeenCalledWith(
|
|
91
|
-
expect.stringContaining("
|
|
117
|
+
expect.stringContaining("CLI coding tools not available"),
|
|
92
118
|
);
|
|
93
119
|
});
|
|
94
120
|
|
|
@@ -100,10 +126,13 @@ describe("createLinearTools", () => {
|
|
|
100
126
|
const api = makeApi();
|
|
101
127
|
const tools = createLinearTools(api, {});
|
|
102
128
|
|
|
103
|
-
expect(tools).toHaveLength(
|
|
129
|
+
expect(tools).toHaveLength(7);
|
|
104
130
|
const names = tools.map((t: any) => t.name);
|
|
105
|
-
expect(names).toContain("
|
|
131
|
+
expect(names).toContain("cli_codex");
|
|
132
|
+
expect(names).toContain("cli_claude");
|
|
133
|
+
expect(names).toContain("cli_gemini");
|
|
106
134
|
expect(names).toContain("linear_issues");
|
|
135
|
+
expect(names).toContain("steer_agent");
|
|
107
136
|
expect(api.logger.warn).toHaveBeenCalledWith(
|
|
108
137
|
expect.stringContaining("Orchestration tools not available"),
|
|
109
138
|
);
|
|
@@ -117,11 +146,14 @@ describe("createLinearTools", () => {
|
|
|
117
146
|
const api = makeApi();
|
|
118
147
|
const tools = createLinearTools(api, {});
|
|
119
148
|
|
|
120
|
-
expect(tools).toHaveLength(
|
|
149
|
+
expect(tools).toHaveLength(8);
|
|
121
150
|
const names = tools.map((t: any) => t.name);
|
|
122
|
-
expect(names).toContain("
|
|
151
|
+
expect(names).toContain("cli_codex");
|
|
152
|
+
expect(names).toContain("cli_claude");
|
|
153
|
+
expect(names).toContain("cli_gemini");
|
|
123
154
|
expect(names).toContain("spawn_agent");
|
|
124
155
|
expect(names).toContain("ask_agent");
|
|
156
|
+
expect(names).toContain("steer_agent");
|
|
125
157
|
expect(api.logger.warn).toHaveBeenCalledWith(
|
|
126
158
|
expect.stringContaining("linear_issues tool not available"),
|
|
127
159
|
);
|
package/src/tools/tools.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
-
import {
|
|
2
|
+
import { createCodeTools } from "./code-tool.js";
|
|
3
3
|
import { createOrchestrationTools } from "./orchestration-tools.js";
|
|
4
4
|
import { createLinearIssuesTool } from "./linear-issues-tool.js";
|
|
5
|
+
import { createSteeringTools } from "./steering-tools.js";
|
|
5
6
|
|
|
6
7
|
export function createLinearTools(api: OpenClawPluginApi, ctx: Record<string, unknown>): any[] {
|
|
7
8
|
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
8
9
|
|
|
9
|
-
//
|
|
10
|
+
// Per-backend coding CLI tools: cli_codex, cli_claude, cli_gemini
|
|
10
11
|
const codeTools: AnyAgentTool[] = [];
|
|
11
12
|
try {
|
|
12
|
-
codeTools.push(
|
|
13
|
+
codeTools.push(...createCodeTools(api, ctx));
|
|
13
14
|
} catch (err) {
|
|
14
|
-
api.logger.warn(`
|
|
15
|
+
api.logger.warn(`CLI coding tools not available: ${err}`);
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
// Orchestration tools (conditional on config — defaults to enabled)
|
|
@@ -33,9 +34,21 @@ export function createLinearTools(api: OpenClawPluginApi, ctx: Record<string, un
|
|
|
33
34
|
api.logger.warn(`linear_issues tool not available: ${err}`);
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
// Steering tools (steer/capture/abort active tmux agent sessions)
|
|
38
|
+
const steeringTools: AnyAgentTool[] = [];
|
|
39
|
+
const enableTmux = pluginConfig?.enableTmux !== false;
|
|
40
|
+
if (enableTmux) {
|
|
41
|
+
try {
|
|
42
|
+
steeringTools.push(...createSteeringTools(api, ctx));
|
|
43
|
+
} catch (err) {
|
|
44
|
+
api.logger.warn(`Steering tools not available: ${err}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
return [
|
|
37
49
|
...codeTools,
|
|
38
50
|
...orchestrationTools,
|
|
39
51
|
...linearIssuesTools,
|
|
52
|
+
...steeringTools,
|
|
40
53
|
];
|
|
41
54
|
}
|