@calltelemetry/openclaw-linear 0.4.0 → 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.
package/index.ts CHANGED
@@ -6,6 +6,7 @@ import { createLinearTools } from "./src/tools.js";
6
6
  import { handleLinearWebhook } from "./src/webhook.js";
7
7
  import { handleOAuthCallback } from "./src/oauth-callback.js";
8
8
  import { resolveLinearToken } from "./src/linear-api.js";
9
+ import { createDispatchService } from "./src/dispatch-service.js";
9
10
 
10
11
  export default function register(api: OpenClawPluginApi) {
11
12
  const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
@@ -56,6 +57,9 @@ export default function register(api: OpenClawPluginApi) {
56
57
  },
57
58
  });
58
59
 
60
+ // Register dispatch monitor service (stale detection, session hydration, cleanup)
61
+ api.registerService(createDispatchService(api));
62
+
59
63
  // Narration Guard: catch short "Let me explore..." responses that narrate intent
60
64
  // without actually calling tools, and append a warning for the user.
61
65
  const NARRATION_PATTERNS = [
@@ -17,7 +17,9 @@
17
17
  "codexBaseRepo": { "type": "string", "description": "Path to git repo for Codex worktrees", "default": "/home/claw/ai-workspace" },
18
18
  "codexModel": { "type": "string", "description": "Default Codex model (optional — uses Codex default if omitted)" },
19
19
  "codexTimeoutMs": { "type": "number", "description": "Default Codex timeout in milliseconds", "default": 600000 },
20
- "enableOrchestration": { "type": "boolean", "description": "Allow agents to spawn sub-agents via spawn_agent/ask_agent tools", "default": true }
20
+ "enableOrchestration": { "type": "boolean", "description": "Allow agents to spawn sub-agents via spawn_agent/ask_agent tools", "default": true },
21
+ "worktreeBaseDir": { "type": "string", "description": "Base directory for persistent git worktrees (default: ~/.openclaw/worktrees)" },
22
+ "dispatchStatePath": { "type": "string", "description": "Path to dispatch state JSON file (default: ~/.openclaw/linear-dispatch-state.json)" }
21
23
  }
22
24
  }
23
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Linear Agent plugin for OpenClaw — webhook-driven AI pipeline with OAuth, multi-agent routing, and issue triage",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -7,8 +7,14 @@
7
7
  *
8
8
  * This runs in the gateway process. Tool execution also happens in the gateway,
9
9
  * so tools can read from this registry directly.
10
+ *
11
+ * The in-memory Map is the fast-path for tool lookups. On startup, the
12
+ * dispatch service calls hydrateFromDispatchState() to rebuild it from
13
+ * the persistent dispatch-state.json file.
10
14
  */
11
15
 
16
+ import { readDispatchState } from "./dispatch-state.js";
17
+
12
18
  export interface ActiveSession {
13
19
  agentSessionId: string;
14
20
  issueIdentifier: string;
@@ -64,3 +70,37 @@ export function getCurrentSession(): ActiveSession | null {
64
70
  }
65
71
  return null;
66
72
  }
73
+
74
+ /**
75
+ * Hydrate the in-memory session Map from dispatch-state.json.
76
+ * Called on startup by the dispatch service to restore sessions
77
+ * that were active before a gateway restart.
78
+ *
79
+ * Returns the number of sessions restored.
80
+ */
81
+ export async function hydrateFromDispatchState(configPath?: string): Promise<number> {
82
+ const state = await readDispatchState(configPath);
83
+ const active = state.dispatches.active;
84
+ let restored = 0;
85
+
86
+ for (const [, dispatch] of Object.entries(active)) {
87
+ if (dispatch.status === "dispatched" || dispatch.status === "running") {
88
+ sessions.set(dispatch.issueId, {
89
+ agentSessionId: dispatch.agentSessionId ?? "",
90
+ issueIdentifier: dispatch.issueIdentifier,
91
+ issueId: dispatch.issueId,
92
+ startedAt: new Date(dispatch.dispatchedAt).getTime(),
93
+ });
94
+ restored++;
95
+ }
96
+ }
97
+
98
+ return restored;
99
+ }
100
+
101
+ /**
102
+ * Get the count of currently tracked sessions.
103
+ */
104
+ export function getSessionCount(): number {
105
+ return sessions.size;
106
+ }
package/src/code-tool.ts CHANGED
@@ -30,7 +30,7 @@ interface BackendConfig {
30
30
  aliases?: string[];
31
31
  }
32
32
 
33
- interface CodingToolsConfig {
33
+ export interface CodingToolsConfig {
34
34
  codingTool?: string;
35
35
  agentCodingTools?: Record<string, string>;
36
36
  backends?: Record<string, BackendConfig>;
@@ -40,7 +40,7 @@ interface CodingToolsConfig {
40
40
  * Load coding tool config from the plugin's coding-tools.json file.
41
41
  * Falls back to empty config if the file doesn't exist or is invalid.
42
42
  */
43
- function loadCodingConfig(): CodingToolsConfig {
43
+ export function loadCodingConfig(): CodingToolsConfig {
44
44
  try {
45
45
  // Resolve relative to the plugin root (one level up from src/)
46
46
  const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
@@ -1,11 +1,16 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync, statSync, readdirSync } from "node:fs";
2
+ import { existsSync, statSync, readdirSync, mkdirSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
3
5
 
4
6
  const DEFAULT_BASE_REPO = "/home/claw/ai-workspace";
7
+ const DEFAULT_WORKTREE_BASE_DIR = path.join(homedir(), ".openclaw", "worktrees");
5
8
 
6
9
  export interface WorktreeInfo {
7
10
  path: string;
8
11
  branch: string;
12
+ /** True if the worktree already existed and was resumed, not freshly created. */
13
+ resumed: boolean;
9
14
  }
10
15
 
11
16
  export interface WorktreeStatus {
@@ -14,6 +19,19 @@ export interface WorktreeStatus {
14
19
  lastCommit: string | null;
15
20
  }
16
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
+
17
35
  function git(args: string[], cwd: string): string {
18
36
  return execFileSync("git", args, {
19
37
  cwd,
@@ -22,42 +40,89 @@ function git(args: string[], cwd: string): string {
22
40
  }).trim();
23
41
  }
24
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
+
25
51
  /**
26
- * Create a git worktree for isolated Codex work.
52
+ * Create a git worktree for isolated work on a Linear issue.
53
+ *
54
+ * Path: {baseDir}/{issueIdentifier}/ — deterministic, persistent.
27
55
  * Branch: codex/{issueIdentifier}
28
- * Path: /tmp/codex-{issueIdentifier}-{timestamp}
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).
29
60
  */
30
61
  export function createWorktree(
31
62
  issueIdentifier: string,
32
- baseRepo?: string,
63
+ opts?: WorktreeOptions,
33
64
  ): WorktreeInfo {
34
- const repo = baseRepo ?? DEFAULT_BASE_REPO;
65
+ const repo = opts?.baseRepo ?? DEFAULT_BASE_REPO;
66
+ const baseDir = resolveBaseDir(opts?.baseDir);
67
+
35
68
  if (!existsSync(repo)) {
36
69
  throw new Error(`Base repo not found: ${repo}`);
37
70
  }
38
71
 
72
+ // Ensure base directory exists
73
+ if (!existsSync(baseDir)) {
74
+ mkdirSync(baseDir, { recursive: true });
75
+ }
76
+
39
77
  const branch = `codex/${issueIdentifier}`;
40
- const ts = Date.now();
41
- const worktreePath = `/tmp/codex-${issueIdentifier}-${ts}`;
78
+ const worktreePath = path.join(baseDir, issueIdentifier);
42
79
 
43
- // Ensure we're on a clean basefetch latest
80
+ // Fetch latest from origin (best effort)do this early so both
81
+ // resume and fresh paths have up-to-date refs.
44
82
  try {
45
83
  git(["fetch", "origin"], repo);
46
84
  } catch {
47
85
  // Offline or no remote — continue with local state
48
86
  }
49
87
 
50
- // Delete stale branch if it exists (from a previous run)
51
- try {
52
- git(["branch", "-D", branch], repo);
53
- } catch {
54
- // Branch doesn't exist — fine
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
+ }
55
100
  }
56
101
 
57
- // Create worktree with new branch off HEAD
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
58
112
  git(["worktree", "add", "-b", branch, worktreePath], repo);
113
+ return { path: worktreePath, branch, resumed: false };
114
+ }
59
115
 
60
- return { path: worktreePath, branch };
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
+ }
61
126
  }
62
127
 
63
128
  /**
@@ -117,19 +182,13 @@ export function removeWorktree(
117
182
  }
118
183
 
119
184
  if (opts?.deleteBranch) {
120
- // Extract branch name from the worktree path convention
185
+ // Extract issue identifier from worktree path to find matching branch
186
+ const dirName = path.basename(worktreePath);
187
+ const branch = `codex/${dirName}`;
121
188
  try {
122
- const branches = git(["branch", "--list", "codex/*"], repo);
123
- // Only delete if it looks like a codex branch
124
- for (const b of branches.split("\n")) {
125
- const name = b.trim().replace(/^\* /, "");
126
- if (name && worktreePath.includes(name.replace("codex/", ""))) {
127
- git(["branch", "-D", name], repo);
128
- break;
129
- }
130
- }
189
+ git(["branch", "-D", branch], repo);
131
190
  } catch {
132
- // Best effort
191
+ // Branch doesn't exist or already deleted
133
192
  }
134
193
  }
135
194
  }
@@ -175,23 +234,23 @@ export function createPullRequest(
175
234
  export interface WorktreeEntry {
176
235
  path: string;
177
236
  branch: string;
237
+ issueIdentifier: string;
178
238
  ageMs: number;
179
239
  hasChanges: boolean;
180
240
  }
181
241
 
182
242
  /**
183
- * List all codex worktrees under /tmp.
243
+ * List all worktrees in the configured base directory.
184
244
  */
185
- export function listWorktrees(baseRepo?: string): WorktreeEntry[] {
186
- const repo = baseRepo ?? DEFAULT_BASE_REPO;
245
+ export function listWorktrees(opts?: WorktreeOptions): WorktreeEntry[] {
246
+ const baseDir = resolveBaseDir(opts?.baseDir);
187
247
  const entries: WorktreeEntry[] = [];
188
248
 
189
- // Find /tmp/codex-* directories
249
+ if (!existsSync(baseDir)) return [];
250
+
190
251
  let dirs: string[];
191
252
  try {
192
- dirs = readdirSync("/tmp")
193
- .filter((d) => d.startsWith("codex-"))
194
- .map((d) => `/tmp/${d}`);
253
+ dirs = readdirSync(baseDir).map((d) => path.join(baseDir, d));
195
254
  } catch {
196
255
  return [];
197
256
  }
@@ -202,6 +261,13 @@ export function listWorktrees(baseRepo?: string): WorktreeEntry[] {
202
261
  const stat = statSync(dir);
203
262
  if (!stat.isDirectory()) continue;
204
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
+
205
271
  let branch = "unknown";
206
272
  try {
207
273
  branch = git(["rev-parse", "--abbrev-ref", "HEAD"], dir);
@@ -216,6 +282,7 @@ export function listWorktrees(baseRepo?: string): WorktreeEntry[] {
216
282
  entries.push({
217
283
  path: dir,
218
284
  branch,
285
+ issueIdentifier: path.basename(dir),
219
286
  ageMs: Date.now() - stat.mtimeMs,
220
287
  hasChanges,
221
288
  });
@@ -227,16 +294,75 @@ export function listWorktrees(baseRepo?: string): WorktreeEntry[] {
227
294
  return entries.sort((a, b) => b.ageMs - a.ageMs);
228
295
  }
229
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
+
230
356
  /**
231
- * Remove codex worktrees older than maxAgeMs.
357
+ * Remove worktrees older than maxAgeMs.
232
358
  * Returns list of removed paths.
233
359
  */
234
360
  export function pruneStaleWorktrees(
235
361
  maxAgeMs: number = 24 * 60 * 60_000,
236
- opts?: { baseRepo?: string; dryRun?: boolean },
362
+ opts?: WorktreeOptions & { dryRun?: boolean },
237
363
  ): { removed: string[]; skipped: string[]; errors: string[] } {
364
+ const worktrees = listWorktrees(opts);
238
365
  const repo = opts?.baseRepo ?? DEFAULT_BASE_REPO;
239
- const worktrees = listWorktrees(repo);
240
366
  const removed: string[] = [];
241
367
  const skipped: string[] = [];
242
368
  const errors: string[] = [];
@@ -0,0 +1,113 @@
1
+ /**
2
+ * dispatch-service.ts — Background service for dispatch health monitoring.
3
+ *
4
+ * Registered via api.registerService(). Runs on a 5-minute interval.
5
+ * Zero LLM tokens — all logic is deterministic code.
6
+ *
7
+ * Responsibilities:
8
+ * - Hydrate active sessions from dispatch-state.json on startup
9
+ * - Detect stale dispatches (active >2h with no progress)
10
+ * - Verify worktree health for active dispatches
11
+ * - Prune completed dispatches older than 7 days
12
+ */
13
+ import { existsSync } from "node:fs";
14
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
15
+ import { hydrateFromDispatchState } from "./active-session.js";
16
+ import {
17
+ readDispatchState,
18
+ listStaleDispatches,
19
+ removeActiveDispatch,
20
+ pruneCompleted,
21
+ } from "./dispatch-state.js";
22
+ import { getWorktreeStatus } from "./codex-worktree.js";
23
+
24
+ const INTERVAL_MS = 5 * 60_000; // 5 minutes
25
+ const STALE_THRESHOLD_MS = 2 * 60 * 60_000; // 2 hours
26
+ const COMPLETED_MAX_AGE_MS = 7 * 24 * 60 * 60_000; // 7 days
27
+
28
+ type ServiceContext = {
29
+ logger: { info(msg: string): void; warn(msg: string): void; error(msg: string): void };
30
+ };
31
+
32
+ export function createDispatchService(api: OpenClawPluginApi) {
33
+ let intervalId: ReturnType<typeof setInterval> | null = null;
34
+
35
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
36
+ const statePath = pluginConfig?.dispatchStatePath as string | undefined;
37
+
38
+ return {
39
+ id: "linear-dispatch-monitor",
40
+
41
+ start: async (ctx: ServiceContext) => {
42
+ // Hydrate active sessions on startup
43
+ try {
44
+ const restored = await hydrateFromDispatchState(statePath);
45
+ if (restored > 0) {
46
+ ctx.logger.info(`linear-dispatch: hydrated ${restored} active session(s) from dispatch state`);
47
+ }
48
+ } catch (err) {
49
+ ctx.logger.warn(`linear-dispatch: hydration failed: ${err}`);
50
+ }
51
+
52
+ ctx.logger.info(`linear-dispatch: service started (interval: ${INTERVAL_MS / 1000}s)`);
53
+
54
+ intervalId = setInterval(() => runTick(ctx), INTERVAL_MS);
55
+ },
56
+
57
+ stop: async (ctx: ServiceContext) => {
58
+ if (intervalId) {
59
+ clearInterval(intervalId);
60
+ intervalId = null;
61
+ ctx.logger.info("linear-dispatch: service stopped");
62
+ }
63
+ },
64
+ };
65
+
66
+ async function runTick(ctx: ServiceContext): Promise<void> {
67
+ try {
68
+ const state = await readDispatchState(statePath);
69
+ const activeCount = Object.keys(state.dispatches.active).length;
70
+
71
+ // Skip tick if nothing to do
72
+ if (activeCount === 0 && Object.keys(state.dispatches.completed).length === 0) return;
73
+
74
+ // 1. Stale dispatch detection
75
+ const stale = listStaleDispatches(state, STALE_THRESHOLD_MS);
76
+ for (const dispatch of stale) {
77
+ // Check if worktree still exists and has progress
78
+ if (existsSync(dispatch.worktreePath)) {
79
+ const status = getWorktreeStatus(dispatch.worktreePath);
80
+ if (status.hasUncommitted || status.lastCommit) {
81
+ // Worktree has activity — not truly stale, just slow
82
+ continue;
83
+ }
84
+ }
85
+ ctx.logger.warn(
86
+ `linear-dispatch: stale dispatch ${dispatch.issueIdentifier} ` +
87
+ `(dispatched ${dispatch.dispatchedAt}, status: ${dispatch.status})`
88
+ );
89
+ }
90
+
91
+ // 2. Worktree health — verify active dispatches have valid worktrees
92
+ for (const [id, dispatch] of Object.entries(state.dispatches.active)) {
93
+ if (!existsSync(dispatch.worktreePath)) {
94
+ ctx.logger.warn(
95
+ `linear-dispatch: worktree missing for ${id} at ${dispatch.worktreePath}`
96
+ );
97
+ }
98
+ }
99
+
100
+ // 3. Prune old completed entries
101
+ const pruned = await pruneCompleted(COMPLETED_MAX_AGE_MS, statePath);
102
+ if (pruned > 0) {
103
+ ctx.logger.info(`linear-dispatch: pruned ${pruned} old completed dispatch(es)`);
104
+ }
105
+
106
+ if (activeCount > 0) {
107
+ ctx.logger.info(`linear-dispatch: tick — ${activeCount} active, ${stale.length} stale`);
108
+ }
109
+ } catch (err) {
110
+ ctx.logger.error(`linear-dispatch: tick failed: ${err}`);
111
+ }
112
+ }
113
+ }