@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/claude-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 CLAUDE_BIN = "claude";
|
|
17
22
|
|
|
@@ -47,7 +52,7 @@ function mapClaudeEventToActivity(event: any): ActivityContent | null {
|
|
|
47
52
|
} else if (input.query) {
|
|
48
53
|
paramSummary = String(input.query).slice(0, 200);
|
|
49
54
|
} else {
|
|
50
|
-
paramSummary = JSON.stringify(input).slice(0,
|
|
55
|
+
paramSummary = JSON.stringify(input).slice(0, 500);
|
|
51
56
|
}
|
|
52
57
|
return { type: "action", action: `Running ${toolName}`, parameter: paramSummary };
|
|
53
58
|
}
|
|
@@ -63,7 +68,7 @@ function mapClaudeEventToActivity(event: any): ActivityContent | null {
|
|
|
63
68
|
for (const block of content) {
|
|
64
69
|
if (block.type === "tool_result") {
|
|
65
70
|
const output = typeof block.content === "string" ? block.content : "";
|
|
66
|
-
const truncated = output.length >
|
|
71
|
+
const truncated = output.length > 1000 ? output.slice(0, 1000) + "..." : output;
|
|
67
72
|
const isError = block.is_error === true;
|
|
68
73
|
return {
|
|
69
74
|
type: "action",
|
|
@@ -100,6 +105,7 @@ export async function runClaude(
|
|
|
100
105
|
api: OpenClawPluginApi,
|
|
101
106
|
params: CliToolParams,
|
|
102
107
|
pluginConfig?: Record<string, unknown>,
|
|
108
|
+
onUpdate?: OnProgressUpdate,
|
|
103
109
|
): Promise<CliResult> {
|
|
104
110
|
api.logger.info(`claude_run params: ${JSON.stringify(params).slice(0, 500)}`);
|
|
105
111
|
|
|
@@ -113,7 +119,7 @@ export async function runClaude(
|
|
|
113
119
|
}
|
|
114
120
|
|
|
115
121
|
const { model, timeoutMs } = params;
|
|
116
|
-
const { agentSessionId, issueIdentifier } = resolveSession(params);
|
|
122
|
+
const { agentSessionId, issueId, issueIdentifier } = resolveSession(params);
|
|
117
123
|
|
|
118
124
|
api.logger.info(`claude_run: session=${agentSessionId ?? "none"}, issue=${issueIdentifier ?? "none"}`);
|
|
119
125
|
|
|
@@ -143,8 +149,54 @@ export async function runClaude(
|
|
|
143
149
|
}
|
|
144
150
|
args.push("-p", prompt);
|
|
145
151
|
|
|
146
|
-
|
|
152
|
+
const fullCommand = `${CLAUDE_BIN} ${args.join(" ")}`;
|
|
153
|
+
api.logger.info(`Claude exec: ${fullCommand.slice(0, 200)}...`);
|
|
154
|
+
|
|
155
|
+
const progressHeader = `[claude] ${workingDir}\n$ ${fullCommand.slice(0, 500)}\n\nPrompt: ${prompt}`;
|
|
156
|
+
|
|
157
|
+
// --- tmux path: run inside a tmux session with pipe-pane streaming ---
|
|
158
|
+
const tmuxEnabled = pluginConfig?.enableTmux !== false;
|
|
159
|
+
if (tmuxEnabled && isTmuxAvailable()) {
|
|
160
|
+
const sessionName = buildSessionName(issueIdentifier ?? "unknown", "claude", 0);
|
|
161
|
+
const tmuxIssueId = issueId ?? sessionName;
|
|
162
|
+
const modelArgs = (model ?? pluginConfig?.claudeModel)
|
|
163
|
+
? `--model ${shellEscape((model ?? pluginConfig?.claudeModel) as string)}`
|
|
164
|
+
: "";
|
|
165
|
+
// Build env prefix: unset CLAUDECODE (avoids "nested session" error)
|
|
166
|
+
// and inject API key from plugin config if configured
|
|
167
|
+
const envParts: string[] = ["unset CLAUDECODE CLAUDE_CODE_ENTRYPOINT;"];
|
|
168
|
+
const claudeApiKey = pluginConfig?.claudeApiKey as string | undefined;
|
|
169
|
+
if (claudeApiKey) {
|
|
170
|
+
envParts.push(`export ANTHROPIC_API_KEY=${shellEscape(claudeApiKey)};`);
|
|
171
|
+
}
|
|
172
|
+
const cmdStr = [
|
|
173
|
+
...envParts,
|
|
174
|
+
CLAUDE_BIN,
|
|
175
|
+
"--print", "--output-format", "stream-json", "--verbose", "--dangerously-skip-permissions",
|
|
176
|
+
modelArgs,
|
|
177
|
+
"-p", shellEscape(prompt),
|
|
178
|
+
].filter(Boolean).join(" ");
|
|
179
|
+
|
|
180
|
+
return runInTmux({
|
|
181
|
+
issueId: tmuxIssueId,
|
|
182
|
+
issueIdentifier: issueIdentifier ?? "unknown",
|
|
183
|
+
sessionName,
|
|
184
|
+
command: cmdStr,
|
|
185
|
+
cwd: workingDir,
|
|
186
|
+
timeoutMs: timeout,
|
|
187
|
+
watchdogMs: wdConfig.inactivityMs,
|
|
188
|
+
logPath: path.join(workingDir, ".claw", `tmux-${sessionName}.jsonl`),
|
|
189
|
+
mapEvent: mapClaudeEventToActivity,
|
|
190
|
+
linearApi: linearApi ?? undefined,
|
|
191
|
+
agentSessionId: agentSessionId ?? undefined,
|
|
192
|
+
steeringMode: "stdin-pipe",
|
|
193
|
+
logger: api.logger,
|
|
194
|
+
onUpdate,
|
|
195
|
+
progressHeader,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
147
198
|
|
|
199
|
+
// --- fallback: direct spawn ---
|
|
148
200
|
return new Promise<CliResult>((resolve) => {
|
|
149
201
|
// Must unset CLAUDECODE to avoid "nested session" error
|
|
150
202
|
const env = { ...process.env };
|
|
@@ -190,6 +242,9 @@ export async function runClaude(
|
|
|
190
242
|
let stderrOutput = "";
|
|
191
243
|
let lastToolName = "";
|
|
192
244
|
|
|
245
|
+
const progress = createProgressEmitter({ header: progressHeader, onUpdate });
|
|
246
|
+
progress.emitHeader();
|
|
247
|
+
|
|
193
248
|
const rl = createInterface({ input: child.stdout! });
|
|
194
249
|
rl.on("line", (line) => {
|
|
195
250
|
if (!line.trim()) return;
|
|
@@ -244,12 +299,15 @@ export async function runClaude(
|
|
|
244
299
|
// (it duplicates the last assistant text message)
|
|
245
300
|
}
|
|
246
301
|
|
|
247
|
-
// Stream activity to Linear
|
|
302
|
+
// Stream activity to Linear + session progress
|
|
248
303
|
const activity = mapClaudeEventToActivity(event);
|
|
249
|
-
if (activity
|
|
250
|
-
linearApi
|
|
251
|
-
|
|
252
|
-
|
|
304
|
+
if (activity) {
|
|
305
|
+
if (linearApi && agentSessionId) {
|
|
306
|
+
linearApi.emitActivity(agentSessionId, activity).catch((err) => {
|
|
307
|
+
api.logger.warn(`Failed to emit Claude activity: ${err}`);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
progress.push(formatActivityLogLine(activity));
|
|
253
311
|
}
|
|
254
312
|
});
|
|
255
313
|
|
package/src/tools/cli-shared.ts
CHANGED
|
@@ -14,6 +14,7 @@ export interface CliToolParams {
|
|
|
14
14
|
workingDir?: string;
|
|
15
15
|
model?: string;
|
|
16
16
|
timeoutMs?: number;
|
|
17
|
+
issueId?: string;
|
|
17
18
|
issueIdentifier?: string;
|
|
18
19
|
agentSessionId?: string;
|
|
19
20
|
}
|
|
@@ -24,6 +25,51 @@ export interface CliResult {
|
|
|
24
25
|
error?: string;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
export type OnProgressUpdate = (update: Record<string, unknown>) => void;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Format a Linear activity as a single streaming log line for session progress.
|
|
32
|
+
*/
|
|
33
|
+
export function formatActivityLogLine(activity: { type: string; body?: string; action?: string; parameter?: string; result?: string }): string {
|
|
34
|
+
if (activity.type === "thought") {
|
|
35
|
+
return `▸ ${(activity.body ?? "").slice(0, 300)}`;
|
|
36
|
+
}
|
|
37
|
+
if (activity.type === "action") {
|
|
38
|
+
const result = activity.result ? `\n → ${activity.result.slice(0, 200)}` : "";
|
|
39
|
+
return `▸ ${activity.action ?? ""}: ${(activity.parameter ?? "").slice(0, 300)}${result}`;
|
|
40
|
+
}
|
|
41
|
+
return `▸ ${JSON.stringify(activity).slice(0, 300)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create a progress emitter that maintains a rolling log of streaming events.
|
|
46
|
+
* Calls onUpdate with the full accumulated log on each new event.
|
|
47
|
+
*/
|
|
48
|
+
export function createProgressEmitter(opts: {
|
|
49
|
+
header: string;
|
|
50
|
+
onUpdate?: OnProgressUpdate;
|
|
51
|
+
maxLines?: number;
|
|
52
|
+
}): { push: (line: string) => void; emitHeader: () => void } {
|
|
53
|
+
const lines: string[] = [];
|
|
54
|
+
const maxLines = opts.maxLines ?? 40;
|
|
55
|
+
const { header, onUpdate } = opts;
|
|
56
|
+
|
|
57
|
+
function emit() {
|
|
58
|
+
if (!onUpdate) return;
|
|
59
|
+
const log = lines.length > 0 ? "\n---\n" + lines.join("\n") : "";
|
|
60
|
+
try { onUpdate({ status: "running", summary: header + log }); } catch {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
emitHeader() { emit(); },
|
|
65
|
+
push(line: string) {
|
|
66
|
+
lines.push(line);
|
|
67
|
+
if (lines.length > maxLines) lines.splice(0, lines.length - maxLines);
|
|
68
|
+
emit();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
27
73
|
/**
|
|
28
74
|
* Build a LinearAgentApi instance for streaming activities to Linear.
|
|
29
75
|
*/
|
|
@@ -53,9 +99,10 @@ export function buildLinearApi(
|
|
|
53
99
|
*/
|
|
54
100
|
export function resolveSession(params: CliToolParams): {
|
|
55
101
|
agentSessionId: string | undefined;
|
|
102
|
+
issueId: string | undefined;
|
|
56
103
|
issueIdentifier: string | undefined;
|
|
57
104
|
} {
|
|
58
|
-
let { issueIdentifier, agentSessionId } = params;
|
|
105
|
+
let { issueId, issueIdentifier, agentSessionId } = params;
|
|
59
106
|
|
|
60
107
|
if (!agentSessionId || !issueIdentifier) {
|
|
61
108
|
const active = issueIdentifier
|
|
@@ -63,11 +110,12 @@ export function resolveSession(params: CliToolParams): {
|
|
|
63
110
|
: getCurrentSession();
|
|
64
111
|
if (active) {
|
|
65
112
|
agentSessionId = agentSessionId ?? active.agentSessionId;
|
|
113
|
+
issueId = issueId ?? active.issueId;
|
|
66
114
|
issueIdentifier = issueIdentifier ?? active.issueIdentifier;
|
|
67
115
|
}
|
|
68
116
|
}
|
|
69
117
|
|
|
70
|
-
return { agentSessionId, issueIdentifier };
|
|
118
|
+
return { agentSessionId, issueId, issueIdentifier };
|
|
71
119
|
}
|
|
72
120
|
|
|
73
121
|
/**
|
package/src/tools/code-tool.ts
CHANGED
|
@@ -1,39 +1,64 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join, dirname } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
4
|
+
import type { AnyAgentTool, OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
|
|
5
5
|
import { jsonResult } from "openclaw/plugin-sdk";
|
|
6
|
-
import { getCurrentSession } from "../pipeline/active-session.js";
|
|
6
|
+
import { getCurrentSession, getActiveSessionByAgentId } from "../pipeline/active-session.js";
|
|
7
7
|
import { runCodex } from "./codex-tool.js";
|
|
8
8
|
import { runClaude } from "./claude-tool.js";
|
|
9
9
|
import { runGemini } from "./gemini-tool.js";
|
|
10
|
-
import type { CliToolParams, CliResult } from "./cli-shared.js";
|
|
10
|
+
import type { CliToolParams, CliResult, OnProgressUpdate } from "./cli-shared.js";
|
|
11
|
+
import { DEFAULT_BASE_REPO } from "./cli-shared.js";
|
|
11
12
|
|
|
12
13
|
export type CodingBackend = "claude" | "codex" | "gemini";
|
|
13
14
|
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
15
|
+
const BACKENDS: Record<CodingBackend, {
|
|
16
|
+
label: string;
|
|
17
|
+
toolName: string;
|
|
18
|
+
runner: (api: OpenClawPluginApi, params: CliToolParams, pluginConfig?: Record<string, unknown>, onUpdate?: OnProgressUpdate) => Promise<CliResult>;
|
|
19
|
+
description: string;
|
|
20
|
+
configKeyTimeout: string;
|
|
21
|
+
configKeyBaseRepo: string;
|
|
22
|
+
}> = {
|
|
23
|
+
codex: {
|
|
24
|
+
label: "Codex CLI (OpenAI)",
|
|
25
|
+
toolName: "cli_codex",
|
|
26
|
+
runner: runCodex,
|
|
27
|
+
description:
|
|
28
|
+
"Run OpenAI Codex CLI to perform a coding task. " +
|
|
29
|
+
"Can read/write files, run commands, search code, run tests. " +
|
|
30
|
+
"Streams progress to Linear in real-time.",
|
|
31
|
+
configKeyTimeout: "codexTimeoutMs",
|
|
32
|
+
configKeyBaseRepo: "codexBaseRepo",
|
|
33
|
+
},
|
|
34
|
+
claude: {
|
|
35
|
+
label: "Claude Code (Anthropic)",
|
|
36
|
+
toolName: "cli_claude",
|
|
37
|
+
runner: runClaude,
|
|
38
|
+
description:
|
|
39
|
+
"Run Anthropic Claude Code CLI to perform a coding task. " +
|
|
40
|
+
"Can read/write files, run commands, search code, run tests. " +
|
|
41
|
+
"Streams progress to Linear in real-time.",
|
|
42
|
+
configKeyTimeout: "claudeTimeoutMs",
|
|
43
|
+
configKeyBaseRepo: "claudeBaseRepo",
|
|
44
|
+
},
|
|
45
|
+
gemini: {
|
|
46
|
+
label: "Gemini CLI (Google)",
|
|
47
|
+
toolName: "cli_gemini",
|
|
48
|
+
runner: runGemini,
|
|
49
|
+
description:
|
|
50
|
+
"Run Google Gemini CLI to perform a coding task. " +
|
|
51
|
+
"Can read/write files, run commands, search code, run tests. " +
|
|
52
|
+
"Streams progress to Linear in real-time.",
|
|
53
|
+
configKeyTimeout: "geminiTimeoutMs",
|
|
54
|
+
configKeyBaseRepo: "geminiBaseRepo",
|
|
55
|
+
},
|
|
27
56
|
};
|
|
28
57
|
|
|
29
|
-
interface BackendConfig {
|
|
30
|
-
aliases?: string[];
|
|
31
|
-
}
|
|
32
|
-
|
|
33
58
|
export interface CodingToolsConfig {
|
|
34
59
|
codingTool?: string;
|
|
35
60
|
agentCodingTools?: Record<string, string>;
|
|
36
|
-
backends?: Record<string,
|
|
61
|
+
backends?: Record<string, { aliases?: string[] }>;
|
|
37
62
|
}
|
|
38
63
|
|
|
39
64
|
/**
|
|
@@ -42,7 +67,6 @@ export interface CodingToolsConfig {
|
|
|
42
67
|
*/
|
|
43
68
|
export function loadCodingConfig(): CodingToolsConfig {
|
|
44
69
|
try {
|
|
45
|
-
// Resolve relative to the plugin root (one level up from src/)
|
|
46
70
|
const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
47
71
|
const raw = readFileSync(join(pluginRoot, "coding-tools.json"), "utf8");
|
|
48
72
|
return JSON.parse(raw) as CodingToolsConfig;
|
|
@@ -51,37 +75,6 @@ export function loadCodingConfig(): CodingToolsConfig {
|
|
|
51
75
|
}
|
|
52
76
|
}
|
|
53
77
|
|
|
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
78
|
/**
|
|
86
79
|
* Resolve which coding backend to use for a given agent.
|
|
87
80
|
*
|
|
@@ -94,121 +87,208 @@ export function resolveCodingBackend(
|
|
|
94
87
|
config: CodingToolsConfig,
|
|
95
88
|
agentId?: string,
|
|
96
89
|
): CodingBackend {
|
|
97
|
-
// Per-agent override
|
|
98
90
|
if (agentId) {
|
|
99
91
|
const override = config.agentCodingTools?.[agentId];
|
|
100
|
-
if (override && override in
|
|
92
|
+
if (override && override in BACKENDS) return override as CodingBackend;
|
|
101
93
|
}
|
|
102
|
-
|
|
103
|
-
// Global default
|
|
104
94
|
const global = config.codingTool;
|
|
105
|
-
if (global && global in
|
|
106
|
-
|
|
95
|
+
if (global && global in BACKENDS) return global as CodingBackend;
|
|
107
96
|
return "codex";
|
|
108
97
|
}
|
|
109
98
|
|
|
110
99
|
/**
|
|
111
|
-
*
|
|
100
|
+
* Resolve the tool name (cli_codex, cli_claude, cli_gemini) for a given agent.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveToolName(config: CodingToolsConfig, agentId?: string): string {
|
|
103
|
+
return BACKENDS[resolveCodingBackend(config, agentId)].toolName;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse a session key to extract channel routing info for progress messages.
|
|
108
|
+
*/
|
|
109
|
+
function parseChannelTarget(sessionKey?: string): {
|
|
110
|
+
provider: string;
|
|
111
|
+
peerId: string;
|
|
112
|
+
} | null {
|
|
113
|
+
if (!sessionKey) return null;
|
|
114
|
+
const parts = sessionKey.split(":");
|
|
115
|
+
if (parts.length < 5 || parts[0] !== "agent") return null;
|
|
116
|
+
const provider = parts[2];
|
|
117
|
+
const kind = parts[3];
|
|
118
|
+
if (!provider || !kind) return null;
|
|
119
|
+
const peerId = parts[4];
|
|
120
|
+
if (!peerId) return null;
|
|
121
|
+
return { provider, peerId };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Create a channel sender that can send messages to the session's channel.
|
|
126
|
+
*/
|
|
127
|
+
function createChannelSender(
|
|
128
|
+
api: OpenClawPluginApi,
|
|
129
|
+
sessionKey?: string,
|
|
130
|
+
): ((text: string) => Promise<void>) | null {
|
|
131
|
+
const target = parseChannelTarget(sessionKey);
|
|
132
|
+
if (!target) return null;
|
|
133
|
+
const { provider, peerId } = target;
|
|
134
|
+
|
|
135
|
+
if (provider === "discord") {
|
|
136
|
+
return async (text: string) => {
|
|
137
|
+
try {
|
|
138
|
+
await api.runtime.channel.discord.sendMessageDiscord(peerId, text, { silent: true });
|
|
139
|
+
} catch (err) {
|
|
140
|
+
api.logger.warn(`cli channel send (discord) failed: ${err}`);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (provider === "telegram") {
|
|
145
|
+
return async (text: string) => {
|
|
146
|
+
try {
|
|
147
|
+
await api.runtime.channel.telegram.sendMessageTelegram(peerId, text, { silent: true });
|
|
148
|
+
} catch (err) {
|
|
149
|
+
api.logger.warn(`cli channel send (telegram) failed: ${err}`);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Inject Linear session info into tool params so backend runners can emit
|
|
158
|
+
* activities to the correct Linear agent session.
|
|
159
|
+
*/
|
|
160
|
+
function injectSessionInfo(
|
|
161
|
+
params: CliToolParams,
|
|
162
|
+
ctx: OpenClawPluginToolContext,
|
|
163
|
+
): void {
|
|
164
|
+
const ctxAgentId = ctx.agentId;
|
|
165
|
+
const activeSession = getCurrentSession()
|
|
166
|
+
?? (ctxAgentId ? getActiveSessionByAgentId(ctxAgentId) : null);
|
|
167
|
+
|
|
168
|
+
if (activeSession) {
|
|
169
|
+
if (!params.agentSessionId) (params as any).agentSessionId = activeSession.agentSessionId;
|
|
170
|
+
if (!params.issueId) (params as any).issueId = activeSession.issueId;
|
|
171
|
+
if (!params.issueIdentifier) (params as any).issueIdentifier = activeSession.issueIdentifier;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Create the three coding CLI tools: cli_codex, cli_claude, cli_gemini.
|
|
112
177
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* it doesn't need to know which CLI is being used.
|
|
178
|
+
* Each tool directly invokes its backend CLI. The tool name shown in Linear
|
|
179
|
+
* reflects which CLI is running (e.g. "Running cli_codex").
|
|
116
180
|
*/
|
|
117
|
-
export function
|
|
181
|
+
export function createCodeTools(
|
|
118
182
|
api: OpenClawPluginApi,
|
|
119
|
-
|
|
120
|
-
): AnyAgentTool {
|
|
183
|
+
rawCtx: Record<string, unknown>,
|
|
184
|
+
): AnyAgentTool[] {
|
|
185
|
+
const ctx = rawCtx as OpenClawPluginToolContext;
|
|
121
186
|
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
122
187
|
const codingConfig = loadCodingConfig();
|
|
123
|
-
const aliasMap = buildAliasMap(codingConfig);
|
|
124
188
|
|
|
125
|
-
|
|
126
|
-
const defaultBackend = resolveCodingBackend(codingConfig);
|
|
127
|
-
const defaultLabel = BACKEND_LABELS[defaultBackend];
|
|
189
|
+
const tools: AnyAgentTool[] = [];
|
|
128
190
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
191
|
+
for (const [backendId, backend] of Object.entries(BACKENDS) as [CodingBackend, typeof BACKENDS[CodingBackend]][]) {
|
|
192
|
+
const tool: AnyAgentTool = {
|
|
193
|
+
name: backend.toolName,
|
|
194
|
+
label: backend.label,
|
|
195
|
+
description: backend.description,
|
|
196
|
+
parameters: {
|
|
197
|
+
type: "object",
|
|
198
|
+
properties: {
|
|
199
|
+
prompt: {
|
|
200
|
+
type: "string",
|
|
201
|
+
description:
|
|
202
|
+
"What the coding agent should do. Be specific: include file paths, function names, " +
|
|
203
|
+
"expected behavior, and test requirements.",
|
|
204
|
+
},
|
|
205
|
+
workingDir: {
|
|
206
|
+
type: "string",
|
|
207
|
+
description: "Override working directory (default: ~/ai-workspace).",
|
|
208
|
+
},
|
|
209
|
+
model: {
|
|
210
|
+
type: "string",
|
|
211
|
+
description: "Model override for the coding backend.",
|
|
212
|
+
},
|
|
213
|
+
timeoutMs: {
|
|
214
|
+
type: "number",
|
|
215
|
+
description: "Max runtime in milliseconds (default: 600000 = 10 min).",
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
required: ["prompt"],
|
|
219
|
+
},
|
|
220
|
+
execute: async (toolCallId: string, params: CliToolParams, ...rest: unknown[]) => {
|
|
221
|
+
const originalOnUpdate = typeof rest[1] === "function"
|
|
222
|
+
? rest[1] as (update: Record<string, unknown>) => void
|
|
223
|
+
: undefined;
|
|
136
224
|
|
|
137
|
-
|
|
225
|
+
// Inject Linear session context
|
|
226
|
+
injectSessionInfo(params, ctx);
|
|
138
227
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
228
|
+
const workingDir = params.workingDir
|
|
229
|
+
?? (pluginConfig?.[backend.configKeyBaseRepo] as string)
|
|
230
|
+
?? DEFAULT_BASE_REPO;
|
|
231
|
+
const prompt = params.prompt ?? "";
|
|
232
|
+
|
|
233
|
+
api.logger.info(`${backend.toolName}: agent=${ctx.agentId ?? "unknown"} dir=${workingDir}`);
|
|
234
|
+
api.logger.info(`${backend.toolName} prompt: ${prompt.slice(0, 200)}`);
|
|
235
|
+
|
|
236
|
+
// Channel progress messaging
|
|
237
|
+
const channelSend = createChannelSender(api, ctx.sessionKey);
|
|
238
|
+
if (channelSend) {
|
|
239
|
+
const initMsg = [
|
|
240
|
+
`**${backend.toolName}** — ${backend.label}`,
|
|
241
|
+
`\`${workingDir}\``,
|
|
242
|
+
`> ${prompt.slice(0, 800)}${prompt.length > 800 ? "..." : ""}`,
|
|
243
|
+
].join("\n");
|
|
244
|
+
channelSend(initMsg).catch(() => {});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Throttled progress forwarding
|
|
248
|
+
let lastForwardMs = 0;
|
|
249
|
+
let lastChannelMs = 0;
|
|
250
|
+
const FORWARD_THROTTLE_MS = 30_000;
|
|
251
|
+
const CHANNEL_THROTTLE_MS = 20_000;
|
|
252
|
+
|
|
253
|
+
const wrappedOnUpdate: OnProgressUpdate = (update) => {
|
|
254
|
+
const now = Date.now();
|
|
255
|
+
if (originalOnUpdate && now - lastForwardMs >= FORWARD_THROTTLE_MS) {
|
|
256
|
+
lastForwardMs = now;
|
|
257
|
+
try { originalOnUpdate(update); } catch {}
|
|
258
|
+
}
|
|
259
|
+
if (channelSend && now - lastChannelMs >= CHANNEL_THROTTLE_MS) {
|
|
260
|
+
lastChannelMs = now;
|
|
261
|
+
const summary = String(update.summary ?? "");
|
|
262
|
+
if (summary) {
|
|
263
|
+
const logIdx = summary.indexOf("\n---\n");
|
|
264
|
+
const logPart = logIdx >= 0 ? summary.slice(logIdx + 5) : "";
|
|
265
|
+
if (logPart.trim()) {
|
|
266
|
+
const tail = logPart.length > 1200 ? "..." + logPart.slice(-1200) : logPart;
|
|
267
|
+
channelSend(`\`\`\`\n${tail}\n\`\`\``).catch(() => {});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const result = await backend.runner(api, params, pluginConfig, wrappedOnUpdate);
|
|
274
|
+
|
|
275
|
+
return jsonResult({
|
|
276
|
+
success: result.success,
|
|
277
|
+
backend: backendId,
|
|
278
|
+
output: result.output,
|
|
279
|
+
...(result.error ? { error: result.error } : {}),
|
|
280
|
+
});
|
|
177
281
|
},
|
|
178
|
-
|
|
179
|
-
},
|
|
180
|
-
execute: async (toolCallId: string, params: CliToolParams & { backend?: string }, ...rest: unknown[]) => {
|
|
181
|
-
// Extract onUpdate callback for progress reporting to Linear
|
|
182
|
-
const onUpdate = typeof rest[1] === "function"
|
|
183
|
-
? rest[1] as (update: Record<string, unknown>) => void
|
|
184
|
-
: undefined;
|
|
185
|
-
|
|
186
|
-
// Resolve backend: explicit alias → per-agent config → global default
|
|
187
|
-
const currentSession = getCurrentSession();
|
|
188
|
-
const agentId = currentSession?.agentId;
|
|
189
|
-
const explicitBackend = params.backend
|
|
190
|
-
? resolveAlias(aliasMap, params.backend)
|
|
191
|
-
: undefined;
|
|
192
|
-
const backend = explicitBackend ?? resolveCodingBackend(codingConfig, agentId);
|
|
193
|
-
const runner = BACKEND_RUNNERS[backend];
|
|
194
|
-
|
|
195
|
-
api.logger.info(`code_run: backend=${backend} agent=${agentId ?? "unknown"}`);
|
|
196
|
-
|
|
197
|
-
// Emit prompt summary so Linear users see what's being built
|
|
198
|
-
const promptSummary = (params.prompt ?? "").slice(0, 200);
|
|
199
|
-
api.logger.info(`code_run prompt: [${backend}] ${promptSummary}`);
|
|
200
|
-
if (onUpdate) {
|
|
201
|
-
try { onUpdate({ status: "running", summary: `[${backend}] ${promptSummary}` }); } catch {}
|
|
202
|
-
}
|
|
282
|
+
} as unknown as AnyAgentTool;
|
|
203
283
|
|
|
204
|
-
|
|
284
|
+
tools.push(tool);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const defaultBackend = resolveCodingBackend(codingConfig, ctx.agentId);
|
|
288
|
+
api.logger.info(`cli tools registered: ${tools.map(t => (t as any).name).join(", ")} (agent default: ${BACKENDS[defaultBackend].toolName})`);
|
|
205
289
|
|
|
206
|
-
|
|
207
|
-
success: result.success,
|
|
208
|
-
backend,
|
|
209
|
-
output: result.output,
|
|
210
|
-
...(result.error ? { error: result.error } : {}),
|
|
211
|
-
});
|
|
212
|
-
},
|
|
213
|
-
} as unknown as AnyAgentTool;
|
|
290
|
+
return tools;
|
|
214
291
|
}
|
|
292
|
+
|
|
293
|
+
// Keep backward-compat export for tests that reference the old name
|
|
294
|
+
export const createCodeTool = createCodeTools;
|