@calltelemetry/openclaw-linear 0.3.1 → 0.4.1

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.
@@ -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
+
@@ -0,0 +1,390 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, statSync, readdirSync, mkdirSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+
6
+ const DEFAULT_BASE_REPO = "/home/claw/ai-workspace";
7
+ const DEFAULT_WORKTREE_BASE_DIR = path.join(homedir(), ".openclaw", "worktrees");
8
+
9
+ export interface WorktreeInfo {
10
+ path: string;
11
+ branch: string;
12
+ /** True if the worktree already existed and was resumed, not freshly created. */
13
+ resumed: boolean;
14
+ }
15
+
16
+ export interface WorktreeStatus {
17
+ filesChanged: string[];
18
+ hasUncommitted: boolean;
19
+ lastCommit: string | null;
20
+ }
21
+
22
+ export interface WorktreeOptions {
23
+ /** Base git repo to create worktrees from. Default: /home/claw/ai-workspace */
24
+ baseRepo?: string;
25
+ /** Directory under which worktrees are created. Default: ~/.openclaw/worktrees */
26
+ baseDir?: string;
27
+ }
28
+
29
+ function resolveBaseDir(baseDir?: string): string {
30
+ if (!baseDir) return DEFAULT_WORKTREE_BASE_DIR;
31
+ if (baseDir.startsWith("~/")) return baseDir.replace("~", homedir());
32
+ return baseDir;
33
+ }
34
+
35
+ function git(args: string[], cwd: string): string {
36
+ return execFileSync("git", args, {
37
+ cwd,
38
+ encoding: "utf8",
39
+ timeout: 30_000,
40
+ }).trim();
41
+ }
42
+
43
+ function gitLong(args: string[], cwd: string, timeout = 120_000): string {
44
+ return execFileSync("git", args, {
45
+ cwd,
46
+ encoding: "utf8",
47
+ timeout,
48
+ }).trim();
49
+ }
50
+
51
+ /**
52
+ * Create a git worktree for isolated work on a Linear issue.
53
+ *
54
+ * Path: {baseDir}/{issueIdentifier}/ — deterministic, persistent.
55
+ * Branch: codex/{issueIdentifier}
56
+ *
57
+ * Idempotent: if the worktree already exists, returns it without recreating.
58
+ * If the branch exists but the worktree is gone, recreates the worktree from
59
+ * the existing branch (resume scenario).
60
+ */
61
+ export function createWorktree(
62
+ issueIdentifier: string,
63
+ opts?: WorktreeOptions,
64
+ ): WorktreeInfo {
65
+ const repo = opts?.baseRepo ?? DEFAULT_BASE_REPO;
66
+ const baseDir = resolveBaseDir(opts?.baseDir);
67
+
68
+ if (!existsSync(repo)) {
69
+ throw new Error(`Base repo not found: ${repo}`);
70
+ }
71
+
72
+ // Ensure base directory exists
73
+ if (!existsSync(baseDir)) {
74
+ mkdirSync(baseDir, { recursive: true });
75
+ }
76
+
77
+ const branch = `codex/${issueIdentifier}`;
78
+ const worktreePath = path.join(baseDir, issueIdentifier);
79
+
80
+ // Fetch latest from origin (best effort) — do this early so both
81
+ // resume and fresh paths have up-to-date refs.
82
+ try {
83
+ git(["fetch", "origin"], repo);
84
+ } catch {
85
+ // Offline or no remote — continue with local state
86
+ }
87
+
88
+ // Idempotent: if worktree already exists, return it
89
+ if (existsSync(worktreePath)) {
90
+ try {
91
+ // Verify it's a valid git worktree
92
+ git(["rev-parse", "--git-dir"], worktreePath);
93
+ return { path: worktreePath, branch, resumed: true };
94
+ } catch {
95
+ // Directory exists but isn't a valid worktree — remove and recreate
96
+ try {
97
+ git(["worktree", "remove", "--force", worktreePath], repo);
98
+ } catch { /* best effort */ }
99
+ }
100
+ }
101
+
102
+ // Check if branch already exists (resume scenario)
103
+ const branchExists = branchExistsInRepo(branch, repo);
104
+
105
+ if (branchExists) {
106
+ // Recreate worktree from existing branch — preserves previous work
107
+ git(["worktree", "add", worktreePath, branch], repo);
108
+ return { path: worktreePath, branch, resumed: true };
109
+ }
110
+
111
+ // Fresh start: new branch off HEAD
112
+ git(["worktree", "add", "-b", branch, worktreePath], repo);
113
+ return { path: worktreePath, branch, resumed: false };
114
+ }
115
+
116
+ /**
117
+ * Check if a branch exists in the repo.
118
+ */
119
+ function branchExistsInRepo(branch: string, repo: string): boolean {
120
+ try {
121
+ const result = git(["branch", "--list", branch], repo);
122
+ return result.trim().length > 0;
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Get the status of a worktree: changed files, uncommitted work, last commit.
130
+ */
131
+ export function getWorktreeStatus(worktreePath: string): WorktreeStatus {
132
+ if (!existsSync(worktreePath)) {
133
+ return { filesChanged: [], hasUncommitted: false, lastCommit: null };
134
+ }
135
+
136
+ const diffOutput = git(
137
+ ["diff", "--name-only", "HEAD"],
138
+ worktreePath,
139
+ );
140
+ const stagedOutput = git(
141
+ ["diff", "--name-only", "--cached"],
142
+ worktreePath,
143
+ );
144
+ const untrackedOutput = git(
145
+ ["ls-files", "--others", "--exclude-standard"],
146
+ worktreePath,
147
+ );
148
+
149
+ const allFiles = new Set<string>();
150
+ for (const line of [...diffOutput.split("\n"), ...stagedOutput.split("\n"), ...untrackedOutput.split("\n")]) {
151
+ const trimmed = line.trim();
152
+ if (trimmed) allFiles.add(trimmed);
153
+ }
154
+
155
+ const hasUncommitted = allFiles.size > 0;
156
+
157
+ let lastCommit: string | null = null;
158
+ try {
159
+ lastCommit = git(["log", "-1", "--oneline"], worktreePath);
160
+ } catch {
161
+ // No commits yet
162
+ }
163
+
164
+ return {
165
+ filesChanged: [...allFiles],
166
+ hasUncommitted,
167
+ lastCommit,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Remove a worktree and optionally delete its branch.
173
+ */
174
+ export function removeWorktree(
175
+ worktreePath: string,
176
+ opts?: { deleteBranch?: boolean; baseRepo?: string },
177
+ ): void {
178
+ const repo = opts?.baseRepo ?? DEFAULT_BASE_REPO;
179
+
180
+ if (existsSync(worktreePath)) {
181
+ git(["worktree", "remove", "--force", worktreePath], repo);
182
+ }
183
+
184
+ if (opts?.deleteBranch) {
185
+ // Extract issue identifier from worktree path to find matching branch
186
+ const dirName = path.basename(worktreePath);
187
+ const branch = `codex/${dirName}`;
188
+ try {
189
+ git(["branch", "-D", branch], repo);
190
+ } catch {
191
+ // Branch doesn't exist or already deleted
192
+ }
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Push the worktree branch and create a GitHub PR via `gh`.
198
+ */
199
+ export function createPullRequest(
200
+ worktreePath: string,
201
+ title: string,
202
+ body: string,
203
+ ): { prUrl: string } {
204
+ // Commit any uncommitted changes first
205
+ const status = getWorktreeStatus(worktreePath);
206
+ if (status.hasUncommitted) {
207
+ git(["add", "-A"], worktreePath);
208
+ git(
209
+ [
210
+ "-c", "user.name=claw",
211
+ "-c", "user.email=claw@calltelemetry.com",
212
+ "commit", "-m", title,
213
+ ],
214
+ worktreePath,
215
+ );
216
+ }
217
+
218
+ // Get branch name
219
+ const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], worktreePath);
220
+
221
+ // Push branch
222
+ git(["push", "-u", "origin", branch], worktreePath);
223
+
224
+ // Create PR via gh CLI
225
+ const prUrl = execFileSync(
226
+ "gh",
227
+ ["pr", "create", "--title", title, "--body", body, "--head", branch],
228
+ { cwd: worktreePath, encoding: "utf8", timeout: 30_000 },
229
+ ).trim();
230
+
231
+ return { prUrl };
232
+ }
233
+
234
+ export interface WorktreeEntry {
235
+ path: string;
236
+ branch: string;
237
+ issueIdentifier: string;
238
+ ageMs: number;
239
+ hasChanges: boolean;
240
+ }
241
+
242
+ /**
243
+ * List all worktrees in the configured base directory.
244
+ */
245
+ export function listWorktrees(opts?: WorktreeOptions): WorktreeEntry[] {
246
+ const baseDir = resolveBaseDir(opts?.baseDir);
247
+ const entries: WorktreeEntry[] = [];
248
+
249
+ if (!existsSync(baseDir)) return [];
250
+
251
+ let dirs: string[];
252
+ try {
253
+ dirs = readdirSync(baseDir).map((d) => path.join(baseDir, d));
254
+ } catch {
255
+ return [];
256
+ }
257
+
258
+ for (const dir of dirs) {
259
+ if (!existsSync(dir)) continue;
260
+ try {
261
+ const stat = statSync(dir);
262
+ if (!stat.isDirectory()) continue;
263
+
264
+ // Verify it's a git worktree
265
+ try {
266
+ git(["rev-parse", "--git-dir"], dir);
267
+ } catch {
268
+ continue; // Not a git worktree
269
+ }
270
+
271
+ let branch = "unknown";
272
+ try {
273
+ branch = git(["rev-parse", "--abbrev-ref", "HEAD"], dir);
274
+ } catch {}
275
+
276
+ let hasChanges = false;
277
+ try {
278
+ const status = getWorktreeStatus(dir);
279
+ hasChanges = status.hasUncommitted;
280
+ } catch {}
281
+
282
+ entries.push({
283
+ path: dir,
284
+ branch,
285
+ issueIdentifier: path.basename(dir),
286
+ ageMs: Date.now() - stat.mtimeMs,
287
+ hasChanges,
288
+ });
289
+ } catch {
290
+ // Skip unreadable dirs
291
+ }
292
+ }
293
+
294
+ return entries.sort((a, b) => b.ageMs - a.ageMs);
295
+ }
296
+
297
+ export interface PrepareResult {
298
+ pulled: boolean;
299
+ pullOutput?: string;
300
+ submodulesInitialized: boolean;
301
+ submoduleOutput?: string;
302
+ errors: string[];
303
+ }
304
+
305
+ /**
306
+ * Prepare a worktree for a code run:
307
+ * 1. Pull latest from origin for the issue branch (fast-forward only)
308
+ * 2. Initialize and update all git submodules recursively
309
+ *
310
+ * Safe to call on every run — idempotent. Failures are non-fatal;
311
+ * the code run proceeds even if pull or submodule init fails.
312
+ */
313
+ export function prepareWorkspace(worktreePath: string, branch: string): PrepareResult {
314
+ const errors: string[] = [];
315
+ let pulled = false;
316
+ let pullOutput: string | undefined;
317
+ let submodulesInitialized = false;
318
+ let submoduleOutput: string | undefined;
319
+
320
+ // 1. Pull latest from origin (ff-only to avoid merge conflicts)
321
+ try {
322
+ // Check if remote branch exists before pulling
323
+ const remoteBranch = `origin/${branch}`;
324
+ try {
325
+ git(["rev-parse", "--verify", remoteBranch], worktreePath);
326
+ // Remote branch exists — pull latest
327
+ pullOutput = git(["pull", "--ff-only", "origin", branch], worktreePath);
328
+ pulled = true;
329
+ } catch {
330
+ // Remote branch doesn't exist yet (fresh issue branch) — nothing to pull
331
+ pullOutput = "remote branch not found, skipping pull";
332
+ }
333
+ } catch (err) {
334
+ const msg = `pull failed: ${err}`;
335
+ errors.push(msg);
336
+ pullOutput = msg;
337
+ }
338
+
339
+ // 2. Initialize and update all submodules recursively
340
+ try {
341
+ submoduleOutput = gitLong(
342
+ ["submodule", "update", "--init", "--recursive"],
343
+ worktreePath,
344
+ 120_000, // submodule clone can take a while
345
+ );
346
+ submodulesInitialized = true;
347
+ } catch (err) {
348
+ const msg = `submodule init failed: ${err}`;
349
+ errors.push(msg);
350
+ submoduleOutput = msg;
351
+ }
352
+
353
+ return { pulled, pullOutput, submodulesInitialized, submoduleOutput, errors };
354
+ }
355
+
356
+ /**
357
+ * Remove worktrees older than maxAgeMs.
358
+ * Returns list of removed paths.
359
+ */
360
+ export function pruneStaleWorktrees(
361
+ maxAgeMs: number = 24 * 60 * 60_000,
362
+ opts?: WorktreeOptions & { dryRun?: boolean },
363
+ ): { removed: string[]; skipped: string[]; errors: string[] } {
364
+ const worktrees = listWorktrees(opts);
365
+ const repo = opts?.baseRepo ?? DEFAULT_BASE_REPO;
366
+ const removed: string[] = [];
367
+ const skipped: string[] = [];
368
+ const errors: string[] = [];
369
+
370
+ for (const wt of worktrees) {
371
+ if (wt.ageMs < maxAgeMs) {
372
+ skipped.push(wt.path);
373
+ continue;
374
+ }
375
+
376
+ if (opts?.dryRun) {
377
+ removed.push(wt.path);
378
+ continue;
379
+ }
380
+
381
+ try {
382
+ removeWorktree(wt.path, { deleteBranch: true, baseRepo: repo });
383
+ removed.push(wt.path);
384
+ } catch (err) {
385
+ errors.push(`${wt.path}: ${err}`);
386
+ }
387
+ }
388
+
389
+ return { removed, skipped, errors };
390
+ }