@calltelemetry/openclaw-linear 0.9.14 → 0.9.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,158 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ /**
4
+ * Check if tmux is available on the system.
5
+ */
6
+ export function isTmuxAvailable(): boolean {
7
+ try {
8
+ execFileSync("tmux", ["-V"], { encoding: "utf8", timeout: 5000 });
9
+ return true;
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Create a new tmux session with given name.
17
+ * Uses 200x50 terminal size for consistent capture-pane output.
18
+ */
19
+ export function createSession(name: string, cwd: string): void {
20
+ execFileSync("tmux", [
21
+ "new-session", "-d", "-s", name, "-x", "200", "-y", "50",
22
+ ], { cwd, encoding: "utf8", timeout: 10_000 });
23
+ }
24
+
25
+ /**
26
+ * Set up pipe-pane to stream terminal output to a file.
27
+ * Filters for JSON lines only (lines starting with "{") to extract JSONL
28
+ * from raw PTY output (which includes ANSI sequences, prompts, etc).
29
+ */
30
+ export function setupPipePane(name: string, logPath: string): void {
31
+ execFileSync("tmux", [
32
+ "pipe-pane", "-t", name, "-O",
33
+ `grep --line-buffered "^{" >> ${shellEscapeForTmux(logPath)}`,
34
+ ], { encoding: "utf8", timeout: 5000 });
35
+ }
36
+
37
+ /**
38
+ * Send text to the tmux session's active pane (injects into stdin).
39
+ * Appends Enter key to execute the command.
40
+ */
41
+ export function sendKeys(name: string, text: string): void {
42
+ execFileSync("tmux", [
43
+ "send-keys", "-t", name, text, "Enter",
44
+ ], { encoding: "utf8", timeout: 5000 });
45
+ }
46
+
47
+ /**
48
+ * Send raw text without appending Enter (for steering prompts
49
+ * where the Enter should be part of the text itself).
50
+ */
51
+ export function sendKeysRaw(name: string, text: string): void {
52
+ execFileSync("tmux", [
53
+ "send-keys", "-t", name, "-l", text,
54
+ ], { encoding: "utf8", timeout: 5000 });
55
+ }
56
+
57
+ /**
58
+ * Capture the visible pane content (ANSI-stripped).
59
+ * Returns the last `lines` lines of the terminal.
60
+ */
61
+ export function capturePane(name: string, lines = 50): string {
62
+ return execFileSync("tmux", [
63
+ "capture-pane", "-t", name, "-p", "-S", `-${lines}`,
64
+ ], { encoding: "utf8", timeout: 5000 }).trimEnd();
65
+ }
66
+
67
+ /**
68
+ * Check if a tmux session exists.
69
+ */
70
+ export function sessionExists(name: string): boolean {
71
+ try {
72
+ execFileSync("tmux", ["has-session", "-t", name], {
73
+ encoding: "utf8",
74
+ timeout: 5000,
75
+ });
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Kill a tmux session.
84
+ */
85
+ export function killSession(name: string): void {
86
+ try {
87
+ execFileSync("tmux", ["kill-session", "-t", name], {
88
+ encoding: "utf8",
89
+ timeout: 10_000,
90
+ });
91
+ } catch {
92
+ // Session may already be dead
93
+ }
94
+ }
95
+
96
+ /**
97
+ * List all tmux sessions matching a prefix.
98
+ * Returns session names.
99
+ */
100
+ export function listSessions(prefix?: string): string[] {
101
+ try {
102
+ const output = execFileSync("tmux", [
103
+ "list-sessions", "-F", "#{session_name}",
104
+ ], { encoding: "utf8", timeout: 5000 });
105
+ const sessions = output.trim().split("\n").filter(Boolean);
106
+ if (prefix) return sessions.filter(s => s.startsWith(prefix));
107
+ return sessions;
108
+ } catch {
109
+ return []; // tmux server not running
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Wait for a tmux session to exit (poll-based).
115
+ * Resolves when the session no longer exists or timeout is reached.
116
+ */
117
+ export function waitForExit(name: string, timeoutMs: number): Promise<void> {
118
+ return new Promise((resolve) => {
119
+ const start = Date.now();
120
+ const check = () => {
121
+ if (!sessionExists(name) || Date.now() - start > timeoutMs) {
122
+ resolve();
123
+ return;
124
+ }
125
+ setTimeout(check, 1000);
126
+ };
127
+ check();
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Build a tmux session name from dispatch context.
133
+ * Format: lnr-{issueIdentifier}-{backend}-{attempt}
134
+ */
135
+ export function buildSessionName(
136
+ issueIdentifier: string,
137
+ backend: string,
138
+ attempt: number,
139
+ ): string {
140
+ // Sanitize identifier for tmux (replace dots/spaces with dashes)
141
+ const safe = issueIdentifier.replace(/[^a-zA-Z0-9-]/g, "-");
142
+ return `lnr-${safe}-${backend}-${attempt}`;
143
+ }
144
+
145
+ /**
146
+ * Escape a string for safe use in tmux pipe-pane shell commands.
147
+ */
148
+ function shellEscapeForTmux(s: string): string {
149
+ return `'${s.replace(/'/g, "'\\''")}'`;
150
+ }
151
+
152
+ /**
153
+ * Escape a string for safe use as a shell argument in sendKeys.
154
+ * Wraps in single quotes and escapes internal single quotes.
155
+ */
156
+ export function shellEscape(s: string): string {
157
+ return `'${s.replace(/'/g, "'\\''")}'`;
158
+ }
@@ -0,0 +1,44 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { refreshTokenProactively } from "../api/linear-api.js";
3
+
4
+ let refreshInterval: ReturnType<typeof setInterval> | null = null;
5
+
6
+ const REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
7
+
8
+ /**
9
+ * Start the proactive token refresh timer.
10
+ * Runs immediately on start, then every 6 hours.
11
+ */
12
+ export function startTokenRefreshTimer(
13
+ api: OpenClawPluginApi,
14
+ pluginConfig?: Record<string, unknown>,
15
+ ): void {
16
+ if (refreshInterval) return; // Already running
17
+
18
+ const doRefresh = async () => {
19
+ try {
20
+ const result = await refreshTokenProactively(pluginConfig);
21
+ if (result.refreshed) {
22
+ api.logger.info(`Linear token refresh: ${result.reason}`);
23
+ } else {
24
+ api.logger.debug(`Linear token refresh skipped: ${result.reason}`);
25
+ }
26
+ } catch (err) {
27
+ api.logger.warn(`Linear token refresh failed: ${err}`);
28
+ }
29
+ };
30
+
31
+ // Run immediately
32
+ void doRefresh();
33
+
34
+ // Then every 6 hours
35
+ refreshInterval = setInterval(doRefresh, REFRESH_INTERVAL_MS);
36
+ api.logger.info(`Linear token refresh timer started (every ${REFRESH_INTERVAL_MS / 3600000}h)`);
37
+ }
38
+
39
+ export function stopTokenRefreshTimer(): void {
40
+ if (refreshInterval) {
41
+ clearInterval(refreshInterval);
42
+ refreshInterval = null;
43
+ }
44
+ }
@@ -2,7 +2,7 @@
2
2
  * active-session.ts — Idempotent registry of active Linear agent sessions.
3
3
  *
4
4
  * When the pipeline starts work on an issue, it registers the session here.
5
- * Any tool (code_run, etc.) can look up the active session for the current
5
+ * Any tool (cli_codex, cli_claude, etc.) can look up the active session for the current
6
6
  * issue to stream activities without relying on the LLM agent to pass params.
7
7
  *
8
8
  * This runs in the gateway process. Tool execution also happens in the gateway,
@@ -96,6 +96,24 @@ export function getCurrentSession(): ActiveSession | null {
96
96
  return null;
97
97
  }
98
98
 
99
+ /**
100
+ * Look up the most recent active session for a given agent ID.
101
+ * When multiple sessions exist for the same agent, returns the most
102
+ * recently started one. This is the primary lookup for tool execution
103
+ * contexts where the agent ID is known but the issue isn't.
104
+ */
105
+ export function getActiveSessionByAgentId(agentId: string): ActiveSession | null {
106
+ let best: ActiveSession | null = null;
107
+ for (const session of sessions.values()) {
108
+ if (session.agentId === agentId) {
109
+ if (!best || session.startedAt > best.startedAt) {
110
+ best = session;
111
+ }
112
+ }
113
+ }
114
+ return best;
115
+ }
116
+
99
117
  /**
100
118
  * Hydrate the in-memory session Map from dispatch-state.json.
101
119
  * Called on startup by the dispatch service to restore sessions
@@ -307,3 +307,45 @@ export function resolveOrchestratorWorkspace(
307
307
  return join(home, ".openclaw", "workspace");
308
308
  }
309
309
  }
310
+
311
+ /**
312
+ * Read worker output artifacts for all attempts.
313
+ */
314
+ export function readWorkerOutputs(worktreePath: string, maxAttempt: number): string[] {
315
+ const outputs: string[] = [];
316
+ for (let i = 0; i <= maxAttempt; i++) {
317
+ try {
318
+ outputs.push(readFileSync(join(clawDir(worktreePath), `worker-${i}.md`), "utf-8"));
319
+ } catch {
320
+ outputs.push("(not found)");
321
+ }
322
+ }
323
+ return outputs;
324
+ }
325
+
326
+ /**
327
+ * Read audit verdict artifacts for all attempts.
328
+ */
329
+ export function readAuditVerdicts(worktreePath: string, maxAttempt: number): string[] {
330
+ const verdicts: string[] = [];
331
+ for (let i = 0; i <= maxAttempt; i++) {
332
+ try {
333
+ verdicts.push(readFileSync(join(clawDir(worktreePath), `audit-${i}.json`), "utf-8"));
334
+ } catch {
335
+ verdicts.push("(not found)");
336
+ }
337
+ }
338
+ return verdicts;
339
+ }
340
+
341
+ /**
342
+ * Read the interaction log entries.
343
+ */
344
+ export function readLog(worktreePath: string): string[] {
345
+ try {
346
+ const raw = readFileSync(join(clawDir(worktreePath), "log.jsonl"), "utf-8");
347
+ return raw.trim().split("\n").filter(Boolean);
348
+ } catch {
349
+ return [];
350
+ }
351
+ }
@@ -69,6 +69,8 @@ export interface CompletedDispatch {
69
69
  prUrl?: string;
70
70
  project?: string;
71
71
  totalAttempts?: number;
72
+ worktreePath?: string;
73
+ cleanedUp?: boolean;
72
74
  }
73
75
 
74
76
  /** Maps session keys to dispatch context for agent_end hook lookup */
@@ -370,6 +372,7 @@ export async function completeDispatch(
370
372
  prUrl: result.prUrl,
371
373
  project: active?.project ?? result.project,
372
374
  totalAttempts: active?.attempt ?? 0,
375
+ worktreePath: active?.worktreePath,
373
376
  };
374
377
  await writeDispatchState(filePath, data);
375
378
  } finally {
@@ -6,6 +6,7 @@ import {
6
6
  getCachedGuidanceForTeam,
7
7
  formatGuidanceAppendix,
8
8
  isGuidanceEnabled,
9
+ resolveGuidance,
9
10
  _resetGuidanceCacheForTesting,
10
11
  } from "./guidance.js";
11
12
 
@@ -220,3 +221,55 @@ describe("isGuidanceEnabled", () => {
220
221
  expect(isGuidanceEnabled({ enableGuidance: true }, undefined)).toBe(true);
221
222
  });
222
223
  });
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // resolveGuidance
227
+ // ---------------------------------------------------------------------------
228
+
229
+ describe("resolveGuidance", () => {
230
+ it("extracts guidance from webhook payload and caches it", () => {
231
+ const result = resolveGuidance("team-1", { guidance: "Use TypeScript." });
232
+ expect(result).toBe("Use TypeScript.");
233
+ // Should also be cached now
234
+ expect(getCachedGuidanceForTeam("team-1")).toBe("Use TypeScript.");
235
+ });
236
+
237
+ it("falls back to cache when payload has no guidance", () => {
238
+ cacheGuidanceForTeam("team-1", "Cached guidance");
239
+ const result = resolveGuidance("team-1", {});
240
+ expect(result).toBe("Cached guidance");
241
+ });
242
+
243
+ it("returns null when no guidance anywhere", () => {
244
+ const result = resolveGuidance("team-1", {});
245
+ expect(result).toBeNull();
246
+ });
247
+
248
+ it("returns null when guidance is disabled for team", () => {
249
+ cacheGuidanceForTeam("team-1", "Should be ignored");
250
+ const config = { teamGuidanceOverrides: { "team-1": false } };
251
+ const result = resolveGuidance("team-1", { guidance: "Direct guidance" }, config);
252
+ expect(result).toBeNull();
253
+ });
254
+
255
+ it("returns null when teamId is undefined and no payload guidance", () => {
256
+ const result = resolveGuidance(undefined, {});
257
+ expect(result).toBeNull();
258
+ });
259
+
260
+ it("extracts from payload even with undefined teamId", () => {
261
+ const result = resolveGuidance(undefined, { guidance: "Global guidance" });
262
+ expect(result).toBe("Global guidance");
263
+ });
264
+
265
+ it("returns null when payload is null and cache is empty", () => {
266
+ const result = resolveGuidance("team-1", null);
267
+ expect(result).toBeNull();
268
+ });
269
+
270
+ it("uses cached guidance when payload is null", () => {
271
+ cacheGuidanceForTeam("team-1", "Cached");
272
+ const result = resolveGuidance("team-1", null);
273
+ expect(result).toBe("Cached");
274
+ });
275
+ });
@@ -127,6 +127,44 @@ export function formatGuidanceAppendix(guidance: string | null): string {
127
127
  ].join("\n");
128
128
  }
129
129
 
130
+ // ---------------------------------------------------------------------------
131
+ // Proactive resolution (webhook → cache → null)
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /**
135
+ * Resolve guidance for a team through all available sources.
136
+ * Chain: webhook payload → cache → null
137
+ *
138
+ * This replaces ad-hoc cache lookups with a single resolution function.
139
+ * When the cache expires and no webhook guidance is available, returns null.
140
+ */
141
+ export function resolveGuidance(
142
+ teamId: string | undefined,
143
+ payload: Record<string, unknown> | null,
144
+ pluginConfig?: Record<string, unknown>,
145
+ ): string | null {
146
+ // Check if guidance is enabled for this team
147
+ if (!isGuidanceEnabled(pluginConfig, teamId)) return null;
148
+
149
+ // 1. Try extracting from webhook payload (freshest source)
150
+ if (payload) {
151
+ const extracted = extractGuidance(payload);
152
+ if (extracted.guidance) {
153
+ // Cache for future Comment webhook paths
154
+ if (teamId) cacheGuidanceForTeam(teamId, extracted.guidance);
155
+ return extracted.guidance;
156
+ }
157
+ }
158
+
159
+ // 2. Try cache
160
+ if (teamId) {
161
+ const cached = getCachedGuidanceForTeam(teamId);
162
+ if (cached) return cached;
163
+ }
164
+
165
+ return null;
166
+ }
167
+
130
168
  // ---------------------------------------------------------------------------
131
169
  // Config toggle
132
170
  // ---------------------------------------------------------------------------
@@ -0,0 +1,40 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { runAgent } from "../agent/agent.js";
3
+
4
+ /**
5
+ * Search agent memory by running a short read-only agent session.
6
+ *
7
+ * The agent has access to memory_search, read, glob, grep tools
8
+ * (readOnly mode allows these). It returns search results as text.
9
+ *
10
+ * @param api - OpenClaw plugin API
11
+ * @param agentId - Agent ID to use for the session
12
+ * @param query - Search query string
13
+ * @param timeoutMs - Max time for the search session (default 15s)
14
+ * @returns Text output from the memory search, or empty string on failure
15
+ */
16
+ export async function searchMemoryViaAgent(
17
+ api: OpenClawPluginApi,
18
+ agentId: string,
19
+ query: string,
20
+ timeoutMs = 15_000,
21
+ ): Promise<string> {
22
+ try {
23
+ const result = await runAgent({
24
+ api,
25
+ agentId,
26
+ sessionId: `memory-search-${Date.now()}`,
27
+ message: [
28
+ `Search your memory for information relevant to: "${query}"`,
29
+ `Return ONLY the search results as a bulleted list, one result per line.`,
30
+ `Include the most relevant content snippets. No commentary or explanation.`,
31
+ `If no results found, return exactly: "No relevant memories found."`,
32
+ ].join("\n"),
33
+ timeoutMs,
34
+ readOnly: true,
35
+ });
36
+ return result.success ? result.output : "";
37
+ } catch {
38
+ return "";
39
+ }
40
+ }