@calltelemetry/openclaw-linear 0.9.15 → 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.
@@ -1,17 +1,22 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createInterface } from "node:readline";
3
+ import path from "node:path";
3
4
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
4
5
  import type { ActivityContent } from "../api/linear-api.js";
5
6
  import {
6
7
  buildLinearApi,
7
8
  resolveSession,
8
9
  extractPrompt,
9
- DEFAULT_TIMEOUT_MS,
10
10
  DEFAULT_BASE_REPO,
11
+ formatActivityLogLine,
12
+ createProgressEmitter,
11
13
  type CliToolParams,
12
14
  type CliResult,
15
+ type OnProgressUpdate,
13
16
  } from "./cli-shared.js";
14
17
  import { InactivityWatchdog, resolveWatchdogConfig } from "../agent/watchdog.js";
18
+ import { isTmuxAvailable, buildSessionName, shellEscape } from "../infra/tmux.js";
19
+ import { runInTmux } from "../infra/tmux-runner.js";
15
20
 
16
21
  const GEMINI_BIN = "gemini";
17
22
 
@@ -43,7 +48,7 @@ function mapGeminiEventToActivity(event: any): ActivityContent | null {
43
48
  } else if (params.description) {
44
49
  paramSummary = String(params.description).slice(0, 200);
45
50
  } else {
46
- paramSummary = JSON.stringify(params).slice(0, 200);
51
+ paramSummary = JSON.stringify(params).slice(0, 500);
47
52
  }
48
53
  return { type: "action", action: `Running ${toolName}`, parameter: paramSummary };
49
54
  }
@@ -52,7 +57,7 @@ function mapGeminiEventToActivity(event: any): ActivityContent | null {
52
57
  if (type === "tool_result") {
53
58
  const status = event.status ?? "unknown";
54
59
  const output = event.output ?? "";
55
- const truncated = output.length > 300 ? output.slice(0, 300) + "..." : output;
60
+ const truncated = output.length > 1000 ? output.slice(0, 1000) + "..." : output;
56
61
  return {
57
62
  type: "action",
58
63
  action: `Tool ${status}`,
@@ -82,6 +87,7 @@ export async function runGemini(
82
87
  api: OpenClawPluginApi,
83
88
  params: CliToolParams,
84
89
  pluginConfig?: Record<string, unknown>,
90
+ onUpdate?: OnProgressUpdate,
85
91
  ): Promise<CliResult> {
86
92
  api.logger.info(`gemini_run params: ${JSON.stringify(params).slice(0, 500)}`);
87
93
 
@@ -95,7 +101,7 @@ export async function runGemini(
95
101
  }
96
102
 
97
103
  const { model, timeoutMs } = params;
98
- const { agentSessionId, issueIdentifier } = resolveSession(params);
104
+ const { agentSessionId, issueId, issueIdentifier } = resolveSession(params);
99
105
 
100
106
  api.logger.info(`gemini_run: session=${agentSessionId ?? "none"}, issue=${issueIdentifier ?? "none"}`);
101
107
 
@@ -123,8 +129,47 @@ export async function runGemini(
123
129
  args.push("-m", (model ?? pluginConfig?.geminiModel) as string);
124
130
  }
125
131
 
126
- api.logger.info(`Gemini exec: ${GEMINI_BIN} ${args.join(" ").slice(0, 200)}...`);
132
+ const fullCommand = `${GEMINI_BIN} ${args.join(" ")}`;
133
+ api.logger.info(`Gemini exec: ${fullCommand.slice(0, 200)}...`);
134
+
135
+ const progressHeader = `[gemini] ${workingDir}\n$ ${fullCommand.slice(0, 500)}\n\nPrompt: ${prompt}`;
136
+
137
+ // --- tmux path: run inside a tmux session with pipe-pane streaming ---
138
+ const tmuxEnabled = pluginConfig?.enableTmux !== false;
139
+ if (tmuxEnabled && isTmuxAvailable()) {
140
+ const sessionName = buildSessionName(issueIdentifier ?? "unknown", "gemini", 0);
141
+ const tmuxIssueId = issueId ?? sessionName;
142
+ const modelArgs = (model ?? pluginConfig?.geminiModel)
143
+ ? `-m ${shellEscape((model ?? pluginConfig?.geminiModel) as string)}`
144
+ : "";
145
+ const cmdStr = [
146
+ GEMINI_BIN,
147
+ "-p", shellEscape(prompt),
148
+ "-o", "stream-json",
149
+ "--yolo",
150
+ modelArgs,
151
+ ].filter(Boolean).join(" ");
152
+
153
+ return runInTmux({
154
+ issueId: tmuxIssueId,
155
+ issueIdentifier: issueIdentifier ?? "unknown",
156
+ sessionName,
157
+ command: cmdStr,
158
+ cwd: workingDir,
159
+ timeoutMs: timeout,
160
+ watchdogMs: wdConfig.inactivityMs,
161
+ logPath: path.join(workingDir, ".claw", `tmux-${sessionName}.jsonl`),
162
+ mapEvent: mapGeminiEventToActivity,
163
+ linearApi: linearApi ?? undefined,
164
+ agentSessionId: agentSessionId ?? undefined,
165
+ steeringMode: "stdin-pipe",
166
+ logger: api.logger,
167
+ onUpdate,
168
+ progressHeader,
169
+ });
170
+ }
127
171
 
172
+ // --- fallback: direct spawn ---
128
173
  return new Promise<CliResult>((resolve) => {
129
174
  const child = spawn(GEMINI_BIN, args, {
130
175
  stdio: ["ignore", "pipe", "pipe"],
@@ -159,6 +204,9 @@ export async function runGemini(
159
204
  const collectedCommands: string[] = [];
160
205
  let stderrOutput = "";
161
206
 
207
+ const progress = createProgressEmitter({ header: progressHeader, onUpdate });
208
+ progress.emitHeader();
209
+
162
210
  const rl = createInterface({ input: child.stdout! });
163
211
  rl.on("line", (line) => {
164
212
  if (!line.trim()) return;
@@ -196,12 +244,15 @@ export async function runGemini(
196
244
  }
197
245
  }
198
246
 
199
- // Stream activity to Linear
247
+ // Stream activity to Linear + session progress
200
248
  const activity = mapGeminiEventToActivity(event);
201
- if (activity && linearApi && agentSessionId) {
202
- linearApi.emitActivity(agentSessionId, activity).catch((err) => {
203
- api.logger.warn(`Failed to emit Gemini activity: ${err}`);
204
- });
249
+ if (activity) {
250
+ if (linearApi && agentSessionId) {
251
+ linearApi.emitActivity(agentSessionId, activity).catch((err) => {
252
+ api.logger.warn(`Failed to emit Gemini activity: ${err}`);
253
+ });
254
+ }
255
+ progress.push(formatActivityLogLine(activity));
205
256
  }
206
257
  });
207
258
 
@@ -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
  },
@@ -0,0 +1,141 @@
1
+ import { execSync } from "node:child_process";
2
+ import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
3
+ import { jsonResult } from "openclaw/plugin-sdk";
4
+ import { getActiveTmuxSession } from "../infra/tmux-runner.js";
5
+ import { capturePane, shellEscape } from "../infra/tmux.js";
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
+ */
14
+ export function createSteeringTools(
15
+ api: OpenClawPluginApi,
16
+ _ctx: Record<string, unknown>,
17
+ ): AnyAgentTool[] {
18
+ return [
19
+ {
20
+ name: "steer_agent",
21
+ label: "Steer Agent",
22
+ description:
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.",
25
+ parameters: {
26
+ type: "object",
27
+ properties: {
28
+ issueId: {
29
+ type: "string",
30
+ description: "The Linear issue ID of the active agent session.",
31
+ },
32
+ message: {
33
+ type: "string",
34
+ description: "The message to send to the agent.",
35
+ },
36
+ },
37
+ required: ["issueId", "message"],
38
+ },
39
+ async execute(params: { issueId: string; message: string }) {
40
+ const session = getActiveTmuxSession(params.issueId);
41
+ if (!session) {
42
+ return jsonResult({ error: `No active tmux session for issue ${params.issueId}` });
43
+ }
44
+
45
+ if (session.steeringMode === "one-shot") {
46
+ return jsonResult({
47
+ error: `Agent ${session.backend} is in one-shot mode — steering via stdin is not supported.`,
48
+ });
49
+ }
50
+
51
+ try {
52
+ execSync(
53
+ `tmux send-keys -t ${shellEscape(session.sessionName)} ${shellEscape(params.message)} Enter`,
54
+ { stdio: "ignore", timeout: 5_000 },
55
+ );
56
+ return jsonResult({
57
+ success: true,
58
+ sessionName: session.sessionName,
59
+ backend: session.backend,
60
+ });
61
+ } catch (err) {
62
+ return jsonResult({ error: `Failed to steer agent: ${err}` });
63
+ }
64
+ },
65
+ },
66
+ {
67
+ name: "capture_agent_output",
68
+ label: "Capture Agent Output",
69
+ description:
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.",
72
+ parameters: {
73
+ type: "object",
74
+ properties: {
75
+ issueId: {
76
+ type: "string",
77
+ description: "The Linear issue ID of the active agent session.",
78
+ },
79
+ lines: {
80
+ type: "number",
81
+ description: "Number of lines to capture (default: 50).",
82
+ },
83
+ },
84
+ required: ["issueId"],
85
+ },
86
+ async execute(params: { issueId: string; lines?: number }) {
87
+ const session = getActiveTmuxSession(params.issueId);
88
+ if (!session) {
89
+ return jsonResult({ error: `No active tmux session for issue ${params.issueId}` });
90
+ }
91
+
92
+ try {
93
+ const output = capturePane(session.sessionName, params.lines ?? 50);
94
+ return jsonResult({
95
+ sessionName: session.sessionName,
96
+ backend: session.backend,
97
+ output: output || "(empty pane)",
98
+ });
99
+ } catch (err) {
100
+ return jsonResult({ error: `Failed to capture output: ${err}` });
101
+ }
102
+ },
103
+ },
104
+ {
105
+ name: "abort_agent",
106
+ label: "Abort Agent",
107
+ description:
108
+ "Kill an active coding agent's tmux session. Use this when the user wants to stop a running agent.",
109
+ parameters: {
110
+ type: "object",
111
+ properties: {
112
+ issueId: {
113
+ type: "string",
114
+ description: "The Linear issue ID of the active agent session.",
115
+ },
116
+ },
117
+ required: ["issueId"],
118
+ },
119
+ async execute(params: { issueId: string }) {
120
+ const session = getActiveTmuxSession(params.issueId);
121
+ if (!session) {
122
+ return jsonResult({ error: `No active tmux session for issue ${params.issueId}` });
123
+ }
124
+
125
+ try {
126
+ execSync(
127
+ `tmux kill-session -t ${shellEscape(session.sessionName)}`,
128
+ { stdio: "ignore", timeout: 5_000 },
129
+ );
130
+ return jsonResult({
131
+ success: true,
132
+ killed: session.sessionName,
133
+ backend: session.backend,
134
+ });
135
+ } catch (err) {
136
+ return jsonResult({ error: `Failed to abort agent: ${err}` });
137
+ }
138
+ },
139
+ },
140
+ ];
141
+ }
@@ -7,7 +7,16 @@
7
7
  import { describe, expect, it, vi } from "vitest";
8
8
 
9
9
  vi.mock("./code-tool.js", () => ({
10
- createCodeTool: vi.fn(() => ({ name: "code_run", execute: vi.fn() })),
10
+ createCodeTools: vi.fn(() => [
11
+ { name: "cli_codex", execute: vi.fn() },
12
+ { name: "cli_claude", execute: vi.fn() },
13
+ { name: "cli_gemini", execute: vi.fn() },
14
+ ]),
15
+ createCodeTool: vi.fn(() => [
16
+ { name: "cli_codex", execute: vi.fn() },
17
+ { name: "cli_claude", execute: vi.fn() },
18
+ { name: "cli_gemini", execute: vi.fn() },
19
+ ]),
11
20
  }));
12
21
 
13
22
  vi.mock("./orchestration-tools.js", () => ({
@@ -21,8 +30,16 @@ vi.mock("./linear-issues-tool.js", () => ({
21
30
  createLinearIssuesTool: vi.fn(() => ({ name: "linear_issues", execute: vi.fn() })),
22
31
  }));
23
32
 
33
+ vi.mock("./steering-tools.js", () => ({
34
+ createSteeringTools: vi.fn(() => [
35
+ { name: "steer_agent", execute: vi.fn() },
36
+ { name: "capture_agent_output", execute: vi.fn() },
37
+ { name: "abort_agent", execute: vi.fn() },
38
+ ]),
39
+ }));
40
+
24
41
  import { createLinearTools } from "./tools.js";
25
- import { createCodeTool } from "./code-tool.js";
42
+ import { createCodeTools } from "./code-tool.js";
26
43
  import { createOrchestrationTools } from "./orchestration-tools.js";
27
44
  import { createLinearIssuesTool } from "./linear-issues-tool.js";
28
45
 
@@ -43,16 +60,21 @@ function makeApi(pluginConfig?: Record<string, unknown>) {
43
60
  // ── Tests ──────────────────────────────────────────────────────────
44
61
 
45
62
  describe("createLinearTools", () => {
46
- it("returns code_run, spawn_agent, ask_agent, and linear_issues tools", () => {
63
+ it("returns cli_codex, cli_claude, cli_gemini, orchestration, linear_issues, and steering tools", () => {
47
64
  const api = makeApi();
48
65
  const tools = createLinearTools(api, {});
49
66
 
50
- expect(tools).toHaveLength(4);
67
+ expect(tools).toHaveLength(9);
51
68
  const names = tools.map((t: any) => t.name);
52
- expect(names).toContain("code_run");
69
+ expect(names).toContain("cli_codex");
70
+ expect(names).toContain("cli_claude");
71
+ expect(names).toContain("cli_gemini");
53
72
  expect(names).toContain("spawn_agent");
54
73
  expect(names).toContain("ask_agent");
55
74
  expect(names).toContain("linear_issues");
75
+ expect(names).toContain("steer_agent");
76
+ expect(names).toContain("capture_agent_output");
77
+ expect(names).toContain("abort_agent");
56
78
  });
57
79
 
58
80
  it("includes orchestration tools by default", () => {
@@ -67,28 +89,32 @@ describe("createLinearTools", () => {
67
89
  const api = makeApi({ enableOrchestration: false });
68
90
  const tools = createLinearTools(api, {});
69
91
 
70
- expect(tools).toHaveLength(2);
92
+ expect(tools).toHaveLength(7);
71
93
  const names = tools.map((t: any) => t.name);
72
- expect(names).toContain("code_run");
94
+ expect(names).toContain("cli_codex");
95
+ expect(names).toContain("cli_claude");
96
+ expect(names).toContain("cli_gemini");
73
97
  expect(names).toContain("linear_issues");
98
+ expect(names).toContain("steer_agent");
74
99
  expect(createOrchestrationTools).not.toHaveBeenCalled();
75
100
  });
76
101
 
77
- it("handles code_run creation failure gracefully", () => {
78
- vi.mocked(createCodeTool).mockImplementationOnce(() => {
102
+ it("handles CLI tools creation failure gracefully", () => {
103
+ vi.mocked(createCodeTools).mockImplementationOnce(() => {
79
104
  throw new Error("CLI not found");
80
105
  });
81
106
 
82
107
  const api = makeApi();
83
108
  const tools = createLinearTools(api, {});
84
109
 
85
- expect(tools).toHaveLength(3);
110
+ expect(tools).toHaveLength(6);
86
111
  const names = tools.map((t: any) => t.name);
87
112
  expect(names).toContain("spawn_agent");
88
113
  expect(names).toContain("ask_agent");
89
114
  expect(names).toContain("linear_issues");
115
+ expect(names).toContain("steer_agent");
90
116
  expect(api.logger.warn).toHaveBeenCalledWith(
91
- expect.stringContaining("code_run tool not available"),
117
+ expect.stringContaining("CLI coding tools not available"),
92
118
  );
93
119
  });
94
120
 
@@ -100,10 +126,13 @@ describe("createLinearTools", () => {
100
126
  const api = makeApi();
101
127
  const tools = createLinearTools(api, {});
102
128
 
103
- expect(tools).toHaveLength(2);
129
+ expect(tools).toHaveLength(7);
104
130
  const names = tools.map((t: any) => t.name);
105
- expect(names).toContain("code_run");
131
+ expect(names).toContain("cli_codex");
132
+ expect(names).toContain("cli_claude");
133
+ expect(names).toContain("cli_gemini");
106
134
  expect(names).toContain("linear_issues");
135
+ expect(names).toContain("steer_agent");
107
136
  expect(api.logger.warn).toHaveBeenCalledWith(
108
137
  expect.stringContaining("Orchestration tools not available"),
109
138
  );
@@ -117,11 +146,14 @@ describe("createLinearTools", () => {
117
146
  const api = makeApi();
118
147
  const tools = createLinearTools(api, {});
119
148
 
120
- expect(tools).toHaveLength(3);
149
+ expect(tools).toHaveLength(8);
121
150
  const names = tools.map((t: any) => t.name);
122
- expect(names).toContain("code_run");
151
+ expect(names).toContain("cli_codex");
152
+ expect(names).toContain("cli_claude");
153
+ expect(names).toContain("cli_gemini");
123
154
  expect(names).toContain("spawn_agent");
124
155
  expect(names).toContain("ask_agent");
156
+ expect(names).toContain("steer_agent");
125
157
  expect(api.logger.warn).toHaveBeenCalledWith(
126
158
  expect.stringContaining("linear_issues tool not available"),
127
159
  );
@@ -1,17 +1,18 @@
1
1
  import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import { createCodeTool } from "./code-tool.js";
2
+ import { createCodeTools } from "./code-tool.js";
3
3
  import { createOrchestrationTools } from "./orchestration-tools.js";
4
4
  import { createLinearIssuesTool } from "./linear-issues-tool.js";
5
+ import { createSteeringTools } from "./steering-tools.js";
5
6
 
6
7
  export function createLinearTools(api: OpenClawPluginApi, ctx: Record<string, unknown>): any[] {
7
8
  const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
8
9
 
9
- // Unified code_run tool dispatches to configured backend (claude/codex/gemini)
10
+ // Per-backend coding CLI tools: cli_codex, cli_claude, cli_gemini
10
11
  const codeTools: AnyAgentTool[] = [];
11
12
  try {
12
- codeTools.push(createCodeTool(api, ctx));
13
+ codeTools.push(...createCodeTools(api, ctx));
13
14
  } catch (err) {
14
- api.logger.warn(`code_run tool not available: ${err}`);
15
+ api.logger.warn(`CLI coding tools not available: ${err}`);
15
16
  }
16
17
 
17
18
  // Orchestration tools (conditional on config — defaults to enabled)
@@ -33,9 +34,21 @@ export function createLinearTools(api: OpenClawPluginApi, ctx: Record<string, un
33
34
  api.logger.warn(`linear_issues tool not available: ${err}`);
34
35
  }
35
36
 
37
+ // Steering tools (steer/capture/abort active tmux agent sessions)
38
+ const steeringTools: AnyAgentTool[] = [];
39
+ const enableTmux = pluginConfig?.enableTmux !== false;
40
+ if (enableTmux) {
41
+ try {
42
+ steeringTools.push(...createSteeringTools(api, ctx));
43
+ } catch (err) {
44
+ api.logger.warn(`Steering tools not available: ${err}`);
45
+ }
46
+ }
47
+
36
48
  return [
37
49
  ...codeTools,
38
50
  ...orchestrationTools,
39
51
  ...linearIssuesTools,
52
+ ...steeringTools,
40
53
  ];
41
54
  }