@calltelemetry/openclaw-linear 0.9.15 → 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 +7 -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 +1 -1
- package/src/pipeline/webhook.ts +271 -30
- 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/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;
|
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
|
|