@calltelemetry/openclaw-linear 0.9.16 → 0.9.17

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/src/infra/tmux.ts CHANGED
@@ -1,11 +1,11 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execSync } from "node:child_process";
2
2
 
3
3
  /**
4
4
  * Check if tmux is available on the system.
5
5
  */
6
6
  export function isTmuxAvailable(): boolean {
7
7
  try {
8
- execFileSync("tmux", ["-V"], { encoding: "utf8", timeout: 5000 });
8
+ execSync("tmux -V", { stdio: "ignore" });
9
9
  return true;
10
10
  } catch {
11
11
  return false;
@@ -13,146 +13,32 @@ export function isTmuxAvailable(): boolean {
13
13
  }
14
14
 
15
15
  /**
16
- * Create a new tmux session with given name.
17
- * Uses 200x50 terminal size for consistent capture-pane output.
16
+ * Build a deterministic tmux session name from issue identifier, backend, and index.
18
17
  */
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 });
18
+ export function buildSessionName(identifier: string, backend: string, index: number): string {
19
+ // tmux session names can't contain dots or colons — sanitize
20
+ const safe = `${identifier}-${backend}-${index}`.replace(/[^a-zA-Z0-9_-]/g, "_");
21
+ return `claw-${safe}`;
23
22
  }
24
23
 
25
24
  /**
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).
25
+ * Escape a string for safe shell interpolation (single-quote wrapping).
29
26
  */
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 });
27
+ export function shellEscape(value: string): string {
28
+ // Wrap in single quotes, escaping any embedded single quotes
29
+ return `'${value.replace(/'/g, "'\\''")}'`;
35
30
  }
36
31
 
37
32
  /**
38
- * Send text to the tmux session's active pane (injects into stdin).
39
- * Appends Enter key to execute the command.
33
+ * Capture the last N lines from a tmux session pane.
40
34
  */
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[] {
35
+ export function capturePane(sessionName: string, lines: number): string {
101
36
  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;
37
+ return execSync(
38
+ `tmux capture-pane -t ${shellEscape(sessionName)} -p -S -${lines}`,
39
+ { encoding: "utf8", timeout: 5_000 },
40
+ ).trimEnd();
108
41
  } catch {
109
- return []; // tmux server not running
42
+ return "";
110
43
  }
111
44
  }
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
- }
@@ -408,6 +408,7 @@ export function createPlannerTools(): AnyAgentTool[] {
408
408
  priority: { type: "number", description: "New priority: 1=Urgent, 2=High, 3=Medium, 4=Low" },
409
409
  labelIds: {
410
410
  type: "array",
411
+ items: { type: "string" },
411
412
  description: "Label IDs to set",
412
413
  },
413
414
  },
@@ -1,176 +1,141 @@
1
+ import { execSync } from "node:child_process";
1
2
  import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
2
3
  import { jsonResult } from "openclaw/plugin-sdk";
3
- import { getActiveTmuxSession, unregisterTmuxSession } from "../infra/tmux-runner.js";
4
- import { sendKeys, capturePane, killSession } from "../infra/tmux.js";
4
+ import { getActiveTmuxSession } from "../infra/tmux-runner.js";
5
+ import { capturePane, shellEscape } from "../infra/tmux.js";
5
6
 
7
+ /**
8
+ * Create steering tools for interacting with active tmux agent sessions.
9
+ *
10
+ * - steer_agent: Send input to an active agent session via tmux send-keys
11
+ * - capture_agent_output: Capture recent output from an agent's tmux pane
12
+ * - abort_agent: Kill an active agent's tmux session
13
+ */
6
14
  export function createSteeringTools(
7
15
  api: OpenClawPluginApi,
8
16
  _ctx: Record<string, unknown>,
9
17
  ): AnyAgentTool[] {
10
18
  return [
11
- // Tool 1: steer_agent
12
19
  {
13
20
  name: "steer_agent",
14
21
  label: "Steer Agent",
15
22
  description:
16
- "Send a message to the running coding agent. Only works for Claude/Gemini " +
17
- "(stdin-pipe mode). Codex is one-shot and cannot be steered mid-run. " +
18
- "Use this to inject precise, actionable instructions — do NOT forward raw user text.",
23
+ "Send a message to an active coding agent running in a tmux session. " +
24
+ "Use this to provide information, answer questions, or redirect the agent's approach.",
19
25
  parameters: {
20
26
  type: "object",
21
27
  properties: {
22
- text: {
28
+ issueId: {
23
29
  type: "string",
24
- description: "The text to inject into the agent's stdin. Should be a precise, actionable instruction.",
30
+ description: "The Linear issue ID of the active agent session.",
25
31
  },
26
- issueId: {
32
+ message: {
27
33
  type: "string",
28
- description: "Linear issue UUID of the running agent session.",
34
+ description: "The message to send to the agent.",
29
35
  },
30
36
  },
31
- required: ["text", "issueId"],
37
+ required: ["issueId", "message"],
32
38
  },
33
- execute: async (_toolCallId: string, params: { text: string; issueId: string }) => {
34
- const { text, issueId } = params;
35
- const session = getActiveTmuxSession(issueId);
39
+ async execute(params: { issueId: string; message: string }) {
40
+ const session = getActiveTmuxSession(params.issueId);
36
41
  if (!session) {
37
- return jsonResult({
38
- success: false,
39
- error: "no_active_session",
40
- message: `No active tmux session found for issue ${issueId}. The agent may have already completed.`,
41
- });
42
+ return jsonResult({ error: `No active tmux session for issue ${params.issueId}` });
42
43
  }
44
+
43
45
  if (session.steeringMode === "one-shot") {
44
46
  return jsonResult({
45
- success: false,
46
- error: "one_shot_mode",
47
- message: `Cannot steer ${session.backend} — it runs in one-shot mode (Codex exec). ` +
48
- `You can abort and re-dispatch with updated instructions, or wait for it to complete.`,
49
- backend: session.backend,
47
+ error: `Agent ${session.backend} is in one-shot mode — steering via stdin is not supported.`,
50
48
  });
51
49
  }
50
+
52
51
  try {
53
- sendKeys(session.sessionName, text);
54
- api.logger.info(`steer_agent: sent ${text.length} chars to ${session.sessionName}`);
52
+ execSync(
53
+ `tmux send-keys -t ${shellEscape(session.sessionName)} ${shellEscape(params.message)} Enter`,
54
+ { stdio: "ignore", timeout: 5_000 },
55
+ );
55
56
  return jsonResult({
56
57
  success: true,
57
- message: `Sent steering input to ${session.backend} agent (${session.issueIdentifier}).`,
58
- backend: session.backend,
59
58
  sessionName: session.sessionName,
59
+ backend: session.backend,
60
60
  });
61
61
  } catch (err) {
62
- api.logger.error(`steer_agent error: ${err}`);
63
- return jsonResult({
64
- success: false,
65
- error: "send_failed",
66
- message: `Failed to send keys to tmux session: ${err}`,
67
- });
62
+ return jsonResult({ error: `Failed to steer agent: ${err}` });
68
63
  }
69
64
  },
70
- } as unknown as AnyAgentTool,
71
-
72
- // Tool 2: capture_agent_output
65
+ },
73
66
  {
74
67
  name: "capture_agent_output",
75
68
  label: "Capture Agent Output",
76
69
  description:
77
- "Capture the last N lines of terminal output from the running coding agent. " +
78
- "Use this to check what the agent is doing before deciding whether to steer, respond, or abort.",
70
+ "Capture the last 50 lines of output from an active coding agent's tmux session. " +
71
+ "Use this to see what the agent is currently doing before deciding how to steer it.",
79
72
  parameters: {
80
73
  type: "object",
81
74
  properties: {
82
75
  issueId: {
83
76
  type: "string",
84
- description: "Linear issue UUID of the running agent session.",
77
+ description: "The Linear issue ID of the active agent session.",
85
78
  },
86
79
  lines: {
87
80
  type: "number",
88
- description: "Number of lines to capture (default: 50, max: 200).",
81
+ description: "Number of lines to capture (default: 50).",
89
82
  },
90
83
  },
91
84
  required: ["issueId"],
92
85
  },
93
- execute: async (_toolCallId: string, params: { issueId: string; lines?: number }) => {
94
- const { issueId, lines } = params;
95
- const session = getActiveTmuxSession(issueId);
86
+ async execute(params: { issueId: string; lines?: number }) {
87
+ const session = getActiveTmuxSession(params.issueId);
96
88
  if (!session) {
97
- return jsonResult({
98
- success: false,
99
- error: "no_active_session",
100
- message: `No active tmux session found for issue ${issueId}.`,
101
- });
89
+ return jsonResult({ error: `No active tmux session for issue ${params.issueId}` });
102
90
  }
91
+
103
92
  try {
104
- const lineCount = Math.min(lines ?? 50, 200);
105
- const output = capturePane(session.sessionName, lineCount);
93
+ const output = capturePane(session.sessionName, params.lines ?? 50);
106
94
  return jsonResult({
107
- success: true,
108
- backend: session.backend,
109
- issueIdentifier: session.issueIdentifier,
110
95
  sessionName: session.sessionName,
111
- output: output || "(no output captured)",
112
- linesCaptured: lineCount,
96
+ backend: session.backend,
97
+ output: output || "(empty pane)",
113
98
  });
114
99
  } catch (err) {
115
- api.logger.error(`capture_agent_output error: ${err}`);
116
- return jsonResult({
117
- success: false,
118
- error: "capture_failed",
119
- message: `Failed to capture pane output: ${err}`,
120
- });
100
+ return jsonResult({ error: `Failed to capture output: ${err}` });
121
101
  }
122
102
  },
123
- } as unknown as AnyAgentTool,
124
-
125
- // Tool 3: abort_agent
103
+ },
126
104
  {
127
105
  name: "abort_agent",
128
106
  label: "Abort Agent",
129
107
  description:
130
- "Kill the running coding agent session. Use when the user wants to stop, retry with different instructions, " +
131
- "or when the agent is stuck. Works for all backends (Claude, Codex, Gemini).",
108
+ "Kill an active coding agent's tmux session. Use this when the user wants to stop a running agent.",
132
109
  parameters: {
133
110
  type: "object",
134
111
  properties: {
135
112
  issueId: {
136
113
  type: "string",
137
- description: "Linear issue UUID of the running agent session.",
114
+ description: "The Linear issue ID of the active agent session.",
138
115
  },
139
116
  },
140
117
  required: ["issueId"],
141
118
  },
142
- execute: async (_toolCallId: string, params: { issueId: string }) => {
143
- const { issueId } = params;
144
- const session = getActiveTmuxSession(issueId);
119
+ async execute(params: { issueId: string }) {
120
+ const session = getActiveTmuxSession(params.issueId);
145
121
  if (!session) {
146
- return jsonResult({
147
- success: false,
148
- error: "no_active_session",
149
- message: `No active tmux session found for issue ${issueId}. It may have already completed.`,
150
- });
122
+ return jsonResult({ error: `No active tmux session for issue ${params.issueId}` });
151
123
  }
124
+
152
125
  try {
153
- killSession(session.sessionName);
154
- unregisterTmuxSession(issueId);
155
- api.logger.info(`abort_agent: killed ${session.sessionName} (${session.backend})`);
126
+ execSync(
127
+ `tmux kill-session -t ${shellEscape(session.sessionName)}`,
128
+ { stdio: "ignore", timeout: 5_000 },
129
+ );
156
130
  return jsonResult({
157
131
  success: true,
158
- message: `Killed ${session.backend} agent for ${session.issueIdentifier}. ` +
159
- `The session has been terminated. You can re-dispatch with updated instructions.`,
132
+ killed: session.sessionName,
160
133
  backend: session.backend,
161
- issueIdentifier: session.issueIdentifier,
162
134
  });
163
135
  } catch (err) {
164
- api.logger.error(`abort_agent error: ${err}`);
165
- // Still try to unregister even if kill fails
166
- unregisterTmuxSession(issueId);
167
- return jsonResult({
168
- success: false,
169
- error: "kill_failed",
170
- message: `Failed to kill tmux session: ${err}. Session unregistered from registry.`,
171
- });
136
+ return jsonResult({ error: `Failed to abort agent: ${err}` });
172
137
  }
173
138
  },
174
- } as unknown as AnyAgentTool,
139
+ },
175
140
  ];
176
141
  }
@@ -1,44 +0,0 @@
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
- }
@@ -1,40 +0,0 @@
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
- }