@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,75 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { LinearAgentApi } from "./linear-api.js";
|
|
3
|
+
import { resolveLinearToken, LinearAgentApi as LinearAgentApiClass } from "./linear-api.js";
|
|
4
|
+
import { getCurrentSession, getActiveSessionByIdentifier } from "./active-session.js";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_TIMEOUT_MS = 10 * 60_000; // 10 minutes
|
|
7
|
+
export const DEFAULT_BASE_REPO = "/home/claw/ai-workspace";
|
|
8
|
+
|
|
9
|
+
export interface CliToolParams {
|
|
10
|
+
prompt: string;
|
|
11
|
+
workingDir?: string;
|
|
12
|
+
model?: string;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
issueIdentifier?: string;
|
|
15
|
+
agentSessionId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CliResult {
|
|
19
|
+
success: boolean;
|
|
20
|
+
output: string;
|
|
21
|
+
error?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build a LinearAgentApi instance for streaming activities to Linear.
|
|
26
|
+
*/
|
|
27
|
+
export function buildLinearApi(
|
|
28
|
+
api: OpenClawPluginApi,
|
|
29
|
+
agentSessionId?: string,
|
|
30
|
+
): LinearAgentApi | null {
|
|
31
|
+
if (!agentSessionId) return null;
|
|
32
|
+
|
|
33
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
34
|
+
const tokenInfo = resolveLinearToken(pluginConfig);
|
|
35
|
+
if (!tokenInfo.accessToken) return null;
|
|
36
|
+
|
|
37
|
+
const clientId = (pluginConfig?.clientId as string) ?? process.env.LINEAR_CLIENT_ID;
|
|
38
|
+
const clientSecret = (pluginConfig?.clientSecret as string) ?? process.env.LINEAR_CLIENT_SECRET;
|
|
39
|
+
|
|
40
|
+
return new LinearAgentApiClass(tokenInfo.accessToken, {
|
|
41
|
+
refreshToken: tokenInfo.refreshToken,
|
|
42
|
+
expiresAt: tokenInfo.expiresAt,
|
|
43
|
+
clientId: clientId ?? undefined,
|
|
44
|
+
clientSecret: clientSecret ?? undefined,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve session info from explicit params or the active session registry.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveSession(params: CliToolParams): {
|
|
52
|
+
agentSessionId: string | undefined;
|
|
53
|
+
issueIdentifier: string | undefined;
|
|
54
|
+
} {
|
|
55
|
+
let { issueIdentifier, agentSessionId } = params;
|
|
56
|
+
|
|
57
|
+
if (!agentSessionId || !issueIdentifier) {
|
|
58
|
+
const active = issueIdentifier
|
|
59
|
+
? getActiveSessionByIdentifier(issueIdentifier)
|
|
60
|
+
: getCurrentSession();
|
|
61
|
+
if (active) {
|
|
62
|
+
agentSessionId = agentSessionId ?? active.agentSessionId;
|
|
63
|
+
issueIdentifier = issueIdentifier ?? active.issueIdentifier;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { agentSessionId, issueIdentifier };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Robustly extract a prompt from params, handling various key names.
|
|
72
|
+
*/
|
|
73
|
+
export function extractPrompt(params: CliToolParams): string | undefined {
|
|
74
|
+
return params.prompt ?? (params as any).text ?? (params as any).message ?? (params as any).task;
|
|
75
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { exec } from "node:child_process";
|
|
|
8
8
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { resolveLinearToken, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "./linear-api.js";
|
|
10
10
|
import { LINEAR_OAUTH_AUTH_URL, LINEAR_OAUTH_TOKEN_URL, LINEAR_AGENT_SCOPES } from "./auth.js";
|
|
11
|
+
import { listWorktrees } from "./codex-worktree.js";
|
|
11
12
|
|
|
12
13
|
function prompt(question: string): Promise<string> {
|
|
13
14
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -200,4 +201,42 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
|
|
200
201
|
|
|
201
202
|
console.log();
|
|
202
203
|
});
|
|
204
|
+
|
|
205
|
+
// --- openclaw openclaw-linear worktrees ---
|
|
206
|
+
linear
|
|
207
|
+
.command("worktrees")
|
|
208
|
+
.description("List Codex worktrees (use --prune to remove specific ones)")
|
|
209
|
+
.option("--prune <path>", "Remove a specific worktree by path")
|
|
210
|
+
.action(async (opts: { prune?: string }) => {
|
|
211
|
+
if (opts.prune) {
|
|
212
|
+
try {
|
|
213
|
+
const { removeWorktree } = await import("./codex-worktree.js");
|
|
214
|
+
removeWorktree(opts.prune, { deleteBranch: true });
|
|
215
|
+
console.log(`\nRemoved: ${opts.prune}\n`);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
console.error(`\nFailed to remove ${opts.prune}: ${err}\n`);
|
|
218
|
+
process.exitCode = 1;
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const worktrees = listWorktrees();
|
|
224
|
+
|
|
225
|
+
if (worktrees.length === 0) {
|
|
226
|
+
console.log("\nNo Codex worktrees found.\n");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
console.log(`\nCodex Worktrees (${worktrees.length})`);
|
|
231
|
+
console.log("─".repeat(60));
|
|
232
|
+
|
|
233
|
+
for (const wt of worktrees) {
|
|
234
|
+
const ageH = Math.round(wt.ageMs / 3_600_000 * 10) / 10;
|
|
235
|
+
const changes = wt.hasChanges ? " (uncommitted changes)" : "";
|
|
236
|
+
console.log(` ${wt.path}`);
|
|
237
|
+
console.log(` branch: ${wt.branch} age: ${ageH}h${changes}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
console.log(`\nTo remove one: openclaw openclaw-linear worktrees --prune <path>\n`);
|
|
241
|
+
});
|
|
203
242
|
}
|
package/src/client.ts
CHANGED
package/src/code-tool.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
5
|
+
import { jsonResult } from "openclaw/plugin-sdk";
|
|
6
|
+
import { getCurrentSession } from "./active-session.js";
|
|
7
|
+
import { runCodex } from "./codex-tool.js";
|
|
8
|
+
import { runClaude } from "./claude-tool.js";
|
|
9
|
+
import { runGemini } from "./gemini-tool.js";
|
|
10
|
+
import type { CliToolParams, CliResult } from "./cli-shared.js";
|
|
11
|
+
|
|
12
|
+
export type CodingBackend = "claude" | "codex" | "gemini";
|
|
13
|
+
|
|
14
|
+
const BACKEND_LABELS: Record<CodingBackend, string> = {
|
|
15
|
+
claude: "Claude Code (Anthropic)",
|
|
16
|
+
codex: "Codex (OpenAI)",
|
|
17
|
+
gemini: "Gemini CLI (Google)",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const BACKEND_RUNNERS: Record<
|
|
21
|
+
CodingBackend,
|
|
22
|
+
(api: OpenClawPluginApi, params: CliToolParams, pluginConfig?: Record<string, unknown>) => Promise<CliResult>
|
|
23
|
+
> = {
|
|
24
|
+
claude: runClaude,
|
|
25
|
+
codex: runCodex,
|
|
26
|
+
gemini: runGemini,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
interface BackendConfig {
|
|
30
|
+
aliases?: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface CodingToolsConfig {
|
|
34
|
+
codingTool?: string;
|
|
35
|
+
agentCodingTools?: Record<string, string>;
|
|
36
|
+
backends?: Record<string, BackendConfig>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Load coding tool config from the plugin's coding-tools.json file.
|
|
41
|
+
* Falls back to empty config if the file doesn't exist or is invalid.
|
|
42
|
+
*/
|
|
43
|
+
function loadCodingConfig(): CodingToolsConfig {
|
|
44
|
+
try {
|
|
45
|
+
// Resolve relative to the plugin root (one level up from src/)
|
|
46
|
+
const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
47
|
+
const raw = readFileSync(join(pluginRoot, "coding-tools.json"), "utf8");
|
|
48
|
+
return JSON.parse(raw) as CodingToolsConfig;
|
|
49
|
+
} catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build a reverse lookup map: alias (lowercase) → backend ID.
|
|
56
|
+
* Backend IDs themselves are always valid aliases.
|
|
57
|
+
*/
|
|
58
|
+
function buildAliasMap(config: CodingToolsConfig): Map<string, CodingBackend> {
|
|
59
|
+
const map = new Map<string, CodingBackend>();
|
|
60
|
+
|
|
61
|
+
for (const backendId of Object.keys(BACKEND_RUNNERS) as CodingBackend[]) {
|
|
62
|
+
// The backend ID itself is always an alias
|
|
63
|
+
map.set(backendId, backendId);
|
|
64
|
+
|
|
65
|
+
// Add configured aliases
|
|
66
|
+
const aliases = config.backends?.[backendId]?.aliases;
|
|
67
|
+
if (aliases) {
|
|
68
|
+
for (const alias of aliases) {
|
|
69
|
+
map.set(alias.toLowerCase(), backendId);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return map;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve a user-provided alias string to a backend ID.
|
|
79
|
+
* Returns undefined if no match.
|
|
80
|
+
*/
|
|
81
|
+
function resolveAlias(aliasMap: Map<string, CodingBackend>, input: string): CodingBackend | undefined {
|
|
82
|
+
return aliasMap.get(input.toLowerCase());
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolve which coding backend to use for a given agent.
|
|
87
|
+
*
|
|
88
|
+
* Priority:
|
|
89
|
+
* 1. Per-agent override: config.agentCodingTools[agentId]
|
|
90
|
+
* 2. Global default: config.codingTool
|
|
91
|
+
* 3. Hardcoded fallback: "claude"
|
|
92
|
+
*/
|
|
93
|
+
export function resolveCodingBackend(
|
|
94
|
+
config: CodingToolsConfig,
|
|
95
|
+
agentId?: string,
|
|
96
|
+
): CodingBackend {
|
|
97
|
+
// Per-agent override
|
|
98
|
+
if (agentId) {
|
|
99
|
+
const override = config.agentCodingTools?.[agentId];
|
|
100
|
+
if (override && override in BACKEND_RUNNERS) return override as CodingBackend;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Global default
|
|
104
|
+
const global = config.codingTool;
|
|
105
|
+
if (global && global in BACKEND_RUNNERS) return global as CodingBackend;
|
|
106
|
+
|
|
107
|
+
return "claude";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Create the unified `code_run` tool.
|
|
112
|
+
*
|
|
113
|
+
* The tool dispatches to the backend configured in coding-tools.json
|
|
114
|
+
* (codingTool / agentCodingTools). The agent always calls `code_run` —
|
|
115
|
+
* it doesn't need to know which CLI is being used.
|
|
116
|
+
*/
|
|
117
|
+
export function createCodeTool(
|
|
118
|
+
api: OpenClawPluginApi,
|
|
119
|
+
_ctx: Record<string, unknown>,
|
|
120
|
+
): AnyAgentTool {
|
|
121
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
122
|
+
const codingConfig = loadCodingConfig();
|
|
123
|
+
const aliasMap = buildAliasMap(codingConfig);
|
|
124
|
+
|
|
125
|
+
// Resolve the default backend for the tool description (may be overridden at runtime per-agent)
|
|
126
|
+
const defaultBackend = resolveCodingBackend(codingConfig);
|
|
127
|
+
const defaultLabel = BACKEND_LABELS[defaultBackend];
|
|
128
|
+
|
|
129
|
+
// Build alias description for each backend so the LLM knows what names to use
|
|
130
|
+
const aliasDescParts: string[] = [];
|
|
131
|
+
for (const backendId of Object.keys(BACKEND_RUNNERS) as CodingBackend[]) {
|
|
132
|
+
const aliases = codingConfig.backends?.[backendId]?.aliases ?? [backendId];
|
|
133
|
+
aliasDescParts.push(`${BACKEND_LABELS[backendId]}: ${aliases.map(a => `"${a}"`).join(", ")}`);
|
|
134
|
+
}
|
|
135
|
+
const aliasDesc = aliasDescParts.join("; ");
|
|
136
|
+
|
|
137
|
+
api.logger.info(`code_run: default backend=${defaultBackend}, aliases=${JSON.stringify(Object.fromEntries(aliasMap))}, per-agent overrides=${JSON.stringify(codingConfig.agentCodingTools ?? {})}`);
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
name: "code_run",
|
|
141
|
+
label: "Run Coding Agent",
|
|
142
|
+
description:
|
|
143
|
+
`Run an agentic coding CLI to perform a hands-on coding task. ` +
|
|
144
|
+
`Default backend: ${defaultLabel}. You can override with the 'backend' parameter ` +
|
|
145
|
+
`if the user asks for a specific tool. ` +
|
|
146
|
+
`Known aliases — ${aliasDesc}. ` +
|
|
147
|
+
`The CLI can read/write files, run commands, search code, run tests, and more. ` +
|
|
148
|
+
`Streams progress to Linear in real-time. Use this for writing code, debugging, ` +
|
|
149
|
+
`refactoring, creating files, running tests, and other hands-on development work.`,
|
|
150
|
+
parameters: {
|
|
151
|
+
type: "object",
|
|
152
|
+
properties: {
|
|
153
|
+
prompt: {
|
|
154
|
+
type: "string",
|
|
155
|
+
description:
|
|
156
|
+
"What the coding agent should do. Be specific: include file paths, function names, " +
|
|
157
|
+
"expected behavior, and test requirements.",
|
|
158
|
+
},
|
|
159
|
+
backend: {
|
|
160
|
+
type: "string",
|
|
161
|
+
description:
|
|
162
|
+
`Which coding CLI to use. Accepts any known alias: ${aliasDesc}. ` +
|
|
163
|
+
"If omitted, uses the configured default.",
|
|
164
|
+
},
|
|
165
|
+
workingDir: {
|
|
166
|
+
type: "string",
|
|
167
|
+
description: "Override working directory (default: /home/claw/ai-workspace).",
|
|
168
|
+
},
|
|
169
|
+
model: {
|
|
170
|
+
type: "string",
|
|
171
|
+
description: "Model override for the coding backend.",
|
|
172
|
+
},
|
|
173
|
+
timeoutMs: {
|
|
174
|
+
type: "number",
|
|
175
|
+
description: "Max runtime in milliseconds (default: 600000 = 10 min).",
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
required: ["prompt"],
|
|
179
|
+
},
|
|
180
|
+
execute: async (toolCallId: string, params: CliToolParams & { backend?: string }, ...rest: unknown[]) => {
|
|
181
|
+
// Resolve backend: explicit alias → per-agent config → global default
|
|
182
|
+
const currentSession = getCurrentSession();
|
|
183
|
+
const agentId = currentSession?.agentId;
|
|
184
|
+
const explicitBackend = params.backend
|
|
185
|
+
? resolveAlias(aliasMap, params.backend)
|
|
186
|
+
: undefined;
|
|
187
|
+
const backend = explicitBackend ?? resolveCodingBackend(codingConfig, agentId);
|
|
188
|
+
const runner = BACKEND_RUNNERS[backend];
|
|
189
|
+
|
|
190
|
+
api.logger.info(`code_run: backend=${backend} agent=${agentId ?? "unknown"}`);
|
|
191
|
+
|
|
192
|
+
const result = await runner(api, params, pluginConfig);
|
|
193
|
+
|
|
194
|
+
return jsonResult({
|
|
195
|
+
success: result.success,
|
|
196
|
+
backend,
|
|
197
|
+
output: result.output,
|
|
198
|
+
...(result.error ? { error: result.error } : {}),
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
} as unknown as AnyAgentTool;
|
|
202
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
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 CODEX_BIN = "/home/claw/.npm-global/bin/codex";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a JSONL line from `codex exec --json` and map it to a Linear activity.
|
|
19
|
+
*/
|
|
20
|
+
function mapCodexEventToActivity(event: any): ActivityContent | null {
|
|
21
|
+
const eventType = event?.type;
|
|
22
|
+
const item = event?.item;
|
|
23
|
+
|
|
24
|
+
if (item?.type === "reasoning") {
|
|
25
|
+
const text = item.text ?? "";
|
|
26
|
+
return { type: "thought", body: text ? text.slice(0, 500) : "Reasoning..." };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (
|
|
30
|
+
(eventType === "item.completed" || eventType === "item.started") &&
|
|
31
|
+
(item?.type === "agent_message" || item?.type === "message")
|
|
32
|
+
) {
|
|
33
|
+
const text = item.text ?? item.content ?? "";
|
|
34
|
+
if (text) return { type: "thought", body: text.slice(0, 1000) };
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (eventType === "item.started" && item?.type === "command_execution") {
|
|
39
|
+
const cmd = item.command ?? "unknown";
|
|
40
|
+
const cleaned = typeof cmd === "string"
|
|
41
|
+
? cmd.replace(/^\/usr\/bin\/\w+ -lc ['"]?/, "").replace(/['"]?$/, "")
|
|
42
|
+
: JSON.stringify(cmd);
|
|
43
|
+
return { type: "action", action: "Running", parameter: cleaned.slice(0, 200) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (eventType === "item.completed" && item?.type === "command_execution") {
|
|
47
|
+
const cmd = item.command ?? "unknown";
|
|
48
|
+
const exitCode = item.exit_code ?? "?";
|
|
49
|
+
const output = item.aggregated_output ?? item.output ?? "";
|
|
50
|
+
const cleaned = typeof cmd === "string"
|
|
51
|
+
? cmd.replace(/^\/usr\/bin\/\w+ -lc ['"]?/, "").replace(/['"]?$/, "")
|
|
52
|
+
: JSON.stringify(cmd);
|
|
53
|
+
const truncated = output.length > 500 ? output.slice(0, 500) + "..." : output;
|
|
54
|
+
return {
|
|
55
|
+
type: "action",
|
|
56
|
+
action: `${cleaned.slice(0, 150)}`,
|
|
57
|
+
parameter: `exit ${exitCode}`,
|
|
58
|
+
result: truncated || undefined,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (eventType === "item.completed" && item?.type === "file_changes") {
|
|
63
|
+
const files = item.files ?? [];
|
|
64
|
+
const fileList = Array.isArray(files) ? files.join(", ") : String(files);
|
|
65
|
+
return { type: "action", action: "Modified files", parameter: fileList || "unknown files" };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (eventType === "turn.completed") {
|
|
69
|
+
const usage = event.usage;
|
|
70
|
+
if (usage) {
|
|
71
|
+
const input = usage.input_tokens ?? 0;
|
|
72
|
+
const cached = usage.cached_input_tokens ?? 0;
|
|
73
|
+
const output = usage.output_tokens ?? 0;
|
|
74
|
+
return { type: "thought", body: `Codex turn complete (${input} in / ${cached} cached / ${output} out tokens)` };
|
|
75
|
+
}
|
|
76
|
+
return { type: "thought", body: "Codex turn complete" };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Run Codex CLI with JSONL streaming, mapping events to Linear activities in real-time.
|
|
84
|
+
*/
|
|
85
|
+
export async function runCodex(
|
|
86
|
+
api: OpenClawPluginApi,
|
|
87
|
+
params: CliToolParams,
|
|
88
|
+
pluginConfig?: Record<string, unknown>,
|
|
89
|
+
): Promise<CliResult> {
|
|
90
|
+
api.logger.info(`codex_run params: ${JSON.stringify(params).slice(0, 500)}`);
|
|
91
|
+
|
|
92
|
+
const prompt = extractPrompt(params);
|
|
93
|
+
if (!prompt) {
|
|
94
|
+
return {
|
|
95
|
+
success: false,
|
|
96
|
+
output: `codex_run error: no prompt provided. Received keys: ${Object.keys(params).join(", ")}`,
|
|
97
|
+
error: "missing prompt",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { model, timeoutMs } = params;
|
|
102
|
+
const { agentSessionId, issueIdentifier } = resolveSession(params);
|
|
103
|
+
|
|
104
|
+
api.logger.info(`codex_run: session=${agentSessionId ?? "none"}, issue=${issueIdentifier ?? "none"}`);
|
|
105
|
+
|
|
106
|
+
const timeout = timeoutMs ?? (pluginConfig?.codexTimeoutMs as number) ?? DEFAULT_TIMEOUT_MS;
|
|
107
|
+
const workingDir = params.workingDir ?? (pluginConfig?.codexBaseRepo as string) ?? DEFAULT_BASE_REPO;
|
|
108
|
+
|
|
109
|
+
// Build Linear API for activity streaming
|
|
110
|
+
const linearApi = buildLinearApi(api, agentSessionId);
|
|
111
|
+
|
|
112
|
+
if (linearApi && agentSessionId) {
|
|
113
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
114
|
+
type: "thought",
|
|
115
|
+
body: `Starting Codex: "${prompt.slice(0, 100)}${prompt.length > 100 ? "..." : ""}"`,
|
|
116
|
+
}).catch(() => {});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Build codex command
|
|
120
|
+
const args = ["exec", "--full-auto", "--json", "--ephemeral"];
|
|
121
|
+
if (model ?? pluginConfig?.codexModel) {
|
|
122
|
+
args.push("--model", (model ?? pluginConfig?.codexModel) as string);
|
|
123
|
+
}
|
|
124
|
+
args.push("-C", workingDir);
|
|
125
|
+
args.push(prompt);
|
|
126
|
+
|
|
127
|
+
api.logger.info(`Codex exec: ${CODEX_BIN} ${args.join(" ").slice(0, 200)}...`);
|
|
128
|
+
|
|
129
|
+
return new Promise<CliResult>((resolve) => {
|
|
130
|
+
const child = spawn(CODEX_BIN, args, {
|
|
131
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
132
|
+
env: { ...process.env },
|
|
133
|
+
timeout: 0,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
let killed = false;
|
|
137
|
+
const timer = setTimeout(() => {
|
|
138
|
+
killed = true;
|
|
139
|
+
child.kill("SIGTERM");
|
|
140
|
+
setTimeout(() => { if (!child.killed) child.kill("SIGKILL"); }, 5_000);
|
|
141
|
+
}, timeout);
|
|
142
|
+
|
|
143
|
+
const collectedMessages: string[] = [];
|
|
144
|
+
const collectedCommands: string[] = [];
|
|
145
|
+
let stderrOutput = "";
|
|
146
|
+
|
|
147
|
+
const rl = createInterface({ input: child.stdout! });
|
|
148
|
+
rl.on("line", (line) => {
|
|
149
|
+
if (!line.trim()) return;
|
|
150
|
+
|
|
151
|
+
let event: any;
|
|
152
|
+
try {
|
|
153
|
+
event = JSON.parse(line);
|
|
154
|
+
} catch {
|
|
155
|
+
collectedMessages.push(line);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const item = event?.item;
|
|
160
|
+
|
|
161
|
+
if (
|
|
162
|
+
event?.type === "item.completed" &&
|
|
163
|
+
(item?.type === "agent_message" || item?.type === "message")
|
|
164
|
+
) {
|
|
165
|
+
const text = item.text ?? item.content ?? "";
|
|
166
|
+
if (text) collectedMessages.push(text);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Skip reasoning events from final output — they're streamed to
|
|
170
|
+
// Linear as activities but don't belong in the returned result.
|
|
171
|
+
|
|
172
|
+
if (event?.type === "item.completed" && item?.type === "command_execution") {
|
|
173
|
+
const cmd = item.command ?? "unknown";
|
|
174
|
+
const exitCode = item.exit_code ?? "?";
|
|
175
|
+
const output = item.aggregated_output ?? item.output ?? "";
|
|
176
|
+
const cleanCmd = typeof cmd === "string"
|
|
177
|
+
? cmd.replace(/^\/usr\/bin\/\w+ -lc ['"]?/, "").replace(/['"]?$/, "")
|
|
178
|
+
: String(cmd);
|
|
179
|
+
const truncOutput = output.length > 500 ? output.slice(0, 500) + "..." : output;
|
|
180
|
+
collectedCommands.push(`\`${cleanCmd}\` → exit ${exitCode}${truncOutput ? "\n```\n" + truncOutput + "\n```" : ""}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const activity = mapCodexEventToActivity(event);
|
|
184
|
+
if (activity && linearApi && agentSessionId) {
|
|
185
|
+
linearApi.emitActivity(agentSessionId, activity).catch((err) => {
|
|
186
|
+
api.logger.warn(`Failed to emit Codex activity: ${err}`);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
child.stderr?.on("data", (chunk) => {
|
|
192
|
+
stderrOutput += chunk.toString();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
child.on("close", (code) => {
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
rl.close();
|
|
198
|
+
|
|
199
|
+
const parts: string[] = [];
|
|
200
|
+
if (collectedMessages.length > 0) parts.push(collectedMessages.join("\n\n"));
|
|
201
|
+
if (collectedCommands.length > 0) parts.push(collectedCommands.join("\n\n"));
|
|
202
|
+
const output = parts.join("\n\n") || stderrOutput || "(no output)";
|
|
203
|
+
|
|
204
|
+
if (killed) {
|
|
205
|
+
api.logger.warn(`Codex timed out after ${timeout}ms`);
|
|
206
|
+
resolve({
|
|
207
|
+
success: false,
|
|
208
|
+
output: `Codex timed out after ${Math.round(timeout / 1000)}s. Partial output:\n${output}`,
|
|
209
|
+
error: "timeout",
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (code !== 0) {
|
|
215
|
+
api.logger.warn(`Codex exited with code ${code}`);
|
|
216
|
+
resolve({
|
|
217
|
+
success: false,
|
|
218
|
+
output: `Codex failed (exit ${code}):\n${output}`,
|
|
219
|
+
error: `exit ${code}`,
|
|
220
|
+
});
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
api.logger.info(`Codex completed successfully`);
|
|
225
|
+
resolve({ success: true, output });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
child.on("error", (err) => {
|
|
229
|
+
clearTimeout(timer);
|
|
230
|
+
rl.close();
|
|
231
|
+
api.logger.error(`Codex spawn error: ${err}`);
|
|
232
|
+
resolve({
|
|
233
|
+
success: false,
|
|
234
|
+
output: `Failed to start Codex: ${err.message}`,
|
|
235
|
+
error: err.message,
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|