@calltelemetry/openclaw-linear 0.9.17 → 0.9.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.9.17",
3
+ "version": "0.9.19",
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",
@@ -1,6 +1,6 @@
1
1
  import { execSync, spawn } from "node:child_process";
2
2
  import { createInterface } from "node:readline";
3
- import { mkdirSync, createWriteStream } from "node:fs";
3
+ import { mkdirSync, writeFileSync, unlinkSync } from "node:fs";
4
4
  import { dirname } from "node:path";
5
5
  import type { ActivityContent, LinearAgentApi } from "../api/linear-api.js";
6
6
  import type { CliResult, OnProgressUpdate } from "../tools/cli-shared.js";
@@ -45,8 +45,12 @@ export function getActiveTmuxSession(issueId: string): TmuxSession | null {
45
45
  }
46
46
 
47
47
  /**
48
- * Run a command inside a tmux session with pipe-pane streaming to a JSONL log.
48
+ * Run a command inside a tmux session with output piped to a JSONL log.
49
49
  * Monitors the log file for events and streams them to Linear.
50
+ *
51
+ * The command + tee are wrapped in a shell script so that tee runs INSIDE
52
+ * the tmux session (not in the outer shell). This ensures JSONL output
53
+ * from the CLI subprocess is captured to the log file.
50
54
  */
51
55
  export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
52
56
  const {
@@ -70,6 +74,9 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
70
74
  // Ensure log directory exists
71
75
  mkdirSync(dirname(logPath), { recursive: true });
72
76
 
77
+ // Touch the log file so tail -f can start immediately
78
+ writeFileSync(logPath, "", { flag: "a" });
79
+
73
80
  // Register active session
74
81
  const session: TmuxSession = {
75
82
  sessionName,
@@ -83,14 +90,25 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
83
90
  const progress = createProgressEmitter({ header: progressHeader, onUpdate });
84
91
  progress.emitHeader();
85
92
 
93
+ // Write a shell wrapper script so the entire pipeline (command | tee)
94
+ // runs inside the tmux session. This avoids quoting hell and ensures
95
+ // tee captures the subprocess output, not tmux's own stdout.
96
+ const scriptPath = `${logPath}.run.sh`;
97
+
86
98
  try {
87
- // Create tmux session running the command, piping output to logPath
88
- const tmuxCmd = [
89
- `tmux new-session -d -s ${shellEscape(sessionName)} -c ${shellEscape(cwd)}`,
90
- `${shellEscape(command)} 2>&1 | tee ${shellEscape(logPath)}`,
91
- ].join(" ");
99
+ writeFileSync(scriptPath, [
100
+ "#!/bin/sh",
101
+ `exec ${command} 2>&1 | tee -a ${shellEscape(logPath)}`,
102
+ "",
103
+ ].join("\n"), { mode: 0o755 });
92
104
 
93
- execSync(tmuxCmd, { stdio: "ignore", timeout: 10_000 });
105
+ // Start tmux session running the wrapper script
106
+ execSync(
107
+ `tmux new-session -d -s ${shellEscape(sessionName)} -c ${shellEscape(cwd)} ${shellEscape(scriptPath)}`,
108
+ { stdio: "ignore", timeout: 10_000 },
109
+ );
110
+
111
+ logger.info(`tmux session started: ${sessionName} (log: ${logPath})`);
94
112
 
95
113
  // Tail the log file and process JSONL events
96
114
  return await new Promise<CliResult>((resolve) => {
@@ -100,6 +118,7 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
100
118
 
101
119
  let killed = false;
102
120
  let killedByWatchdog = false;
121
+ let resolved = false;
103
122
  const collectedMessages: string[] = [];
104
123
 
105
124
  const timer = setTimeout(() => {
@@ -120,6 +139,9 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
120
139
  watchdog.start();
121
140
 
122
141
  function cleanup(reason: string) {
142
+ if (resolved) return;
143
+ resolved = true;
144
+
123
145
  clearTimeout(timer);
124
146
  watchdog.stop();
125
147
  tail.kill();
@@ -132,6 +154,9 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
132
154
  });
133
155
  } catch { /* session may already be gone */ }
134
156
 
157
+ // Clean up wrapper script
158
+ try { unlinkSync(scriptPath); } catch { /* best effort */ }
159
+
135
160
  activeSessions.delete(issueId);
136
161
 
137
162
  const output = collectedMessages.join("\n\n") || "(no output)";
@@ -169,8 +194,9 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
169
194
  return;
170
195
  }
171
196
 
172
- // Collect text for output
197
+ // Collect text for output — handle both Claude and Codex event shapes
173
198
  if (event.type === "assistant") {
199
+ // Claude stream-json shape
174
200
  const content = event.message?.content;
175
201
  if (Array.isArray(content)) {
176
202
  for (const block of content) {
@@ -180,6 +206,11 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
180
206
  }
181
207
  }
182
208
  }
209
+ if (event.item?.type === "agent_message" || event.item?.type === "message") {
210
+ // Codex --json shape
211
+ const text = event.item.text ?? event.item.content ?? "";
212
+ if (text) collectedMessages.push(text);
213
+ }
183
214
 
184
215
  // Stream to Linear
185
216
  const activity = mapEvent(event);
@@ -192,16 +223,16 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
192
223
  progress.push(formatActivityLogLine(activity));
193
224
  }
194
225
 
195
- // Detect completion
196
- if (event.type === "result") {
226
+ // Detect completion — Claude uses "result", Codex uses "session.completed"
227
+ if (event.type === "result" || event.type === "session.completed") {
197
228
  cleanup("done");
198
229
  rl.close();
199
230
  }
200
231
  });
201
232
 
202
- // Handle tail process ending (tmux session completed)
233
+ // Handle tail process ending (tmux session exited)
203
234
  tail.on("close", () => {
204
- if (!killed) {
235
+ if (!resolved) {
205
236
  cleanup("done");
206
237
  }
207
238
  rl.close();
@@ -209,11 +240,16 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
209
240
 
210
241
  tail.on("error", (err) => {
211
242
  logger.error(`tmux tail error: ${err}`);
212
- cleanup("error");
243
+ if (!resolved) {
244
+ cleanup("error");
245
+ }
213
246
  rl.close();
214
247
  });
215
248
  });
216
249
  } catch (err) {
250
+ // Clean up wrapper script on failure
251
+ try { unlinkSync(scriptPath); } catch { /* best effort */ }
252
+
217
253
  activeSessions.delete(issueId);
218
254
  logger.error(`runInTmux failed: ${err}`);
219
255
  return {
@@ -0,0 +1,51 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { refreshTokenProactively } from "../api/linear-api.js";
3
+
4
+ const REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
5
+
6
+ let timer: ReturnType<typeof setInterval> | null = null;
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
+ // Run immediately
17
+ doRefresh(api, pluginConfig);
18
+
19
+ // Then schedule periodic refresh
20
+ timer = setInterval(() => doRefresh(api, pluginConfig), REFRESH_INTERVAL_MS);
21
+ // Don't keep the process alive just for this timer
22
+ if (timer && typeof timer === "object" && "unref" in timer) {
23
+ timer.unref();
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Stop the proactive token refresh timer.
29
+ */
30
+ export function stopTokenRefreshTimer(): void {
31
+ if (timer) {
32
+ clearInterval(timer);
33
+ timer = null;
34
+ }
35
+ }
36
+
37
+ async function doRefresh(
38
+ api: OpenClawPluginApi,
39
+ pluginConfig?: Record<string, unknown>,
40
+ ): Promise<void> {
41
+ try {
42
+ const result = await refreshTokenProactively(pluginConfig);
43
+ if (result.refreshed) {
44
+ api.logger.info(`Token refresh: ${result.reason}`);
45
+ } else {
46
+ api.logger.debug?.(`Token refresh skipped: ${result.reason}`);
47
+ }
48
+ } catch (err) {
49
+ api.logger.warn(`Token refresh failed: ${err}`);
50
+ }
51
+ }