@calltelemetry/openclaw-linear 0.9.15 → 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.
@@ -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
 
@@ -0,0 +1,176 @@
1
+ import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { jsonResult } from "openclaw/plugin-sdk";
3
+ import { getActiveTmuxSession, unregisterTmuxSession } from "../infra/tmux-runner.js";
4
+ import { sendKeys, capturePane, killSession } from "../infra/tmux.js";
5
+
6
+ export function createSteeringTools(
7
+ api: OpenClawPluginApi,
8
+ _ctx: Record<string, unknown>,
9
+ ): AnyAgentTool[] {
10
+ return [
11
+ // Tool 1: steer_agent
12
+ {
13
+ name: "steer_agent",
14
+ label: "Steer Agent",
15
+ 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.",
19
+ parameters: {
20
+ type: "object",
21
+ properties: {
22
+ text: {
23
+ type: "string",
24
+ description: "The text to inject into the agent's stdin. Should be a precise, actionable instruction.",
25
+ },
26
+ issueId: {
27
+ type: "string",
28
+ description: "Linear issue UUID of the running agent session.",
29
+ },
30
+ },
31
+ required: ["text", "issueId"],
32
+ },
33
+ execute: async (_toolCallId: string, params: { text: string; issueId: string }) => {
34
+ const { text, issueId } = params;
35
+ const session = getActiveTmuxSession(issueId);
36
+ 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
+ }
43
+ if (session.steeringMode === "one-shot") {
44
+ 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,
50
+ });
51
+ }
52
+ try {
53
+ sendKeys(session.sessionName, text);
54
+ api.logger.info(`steer_agent: sent ${text.length} chars to ${session.sessionName}`);
55
+ return jsonResult({
56
+ success: true,
57
+ message: `Sent steering input to ${session.backend} agent (${session.issueIdentifier}).`,
58
+ backend: session.backend,
59
+ sessionName: session.sessionName,
60
+ });
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
+ });
68
+ }
69
+ },
70
+ } as unknown as AnyAgentTool,
71
+
72
+ // Tool 2: capture_agent_output
73
+ {
74
+ name: "capture_agent_output",
75
+ label: "Capture Agent Output",
76
+ 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.",
79
+ parameters: {
80
+ type: "object",
81
+ properties: {
82
+ issueId: {
83
+ type: "string",
84
+ description: "Linear issue UUID of the running agent session.",
85
+ },
86
+ lines: {
87
+ type: "number",
88
+ description: "Number of lines to capture (default: 50, max: 200).",
89
+ },
90
+ },
91
+ required: ["issueId"],
92
+ },
93
+ execute: async (_toolCallId: string, params: { issueId: string; lines?: number }) => {
94
+ const { issueId, lines } = params;
95
+ const session = getActiveTmuxSession(issueId);
96
+ if (!session) {
97
+ return jsonResult({
98
+ success: false,
99
+ error: "no_active_session",
100
+ message: `No active tmux session found for issue ${issueId}.`,
101
+ });
102
+ }
103
+ try {
104
+ const lineCount = Math.min(lines ?? 50, 200);
105
+ const output = capturePane(session.sessionName, lineCount);
106
+ return jsonResult({
107
+ success: true,
108
+ backend: session.backend,
109
+ issueIdentifier: session.issueIdentifier,
110
+ sessionName: session.sessionName,
111
+ output: output || "(no output captured)",
112
+ linesCaptured: lineCount,
113
+ });
114
+ } 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
+ });
121
+ }
122
+ },
123
+ } as unknown as AnyAgentTool,
124
+
125
+ // Tool 3: abort_agent
126
+ {
127
+ name: "abort_agent",
128
+ label: "Abort Agent",
129
+ 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).",
132
+ parameters: {
133
+ type: "object",
134
+ properties: {
135
+ issueId: {
136
+ type: "string",
137
+ description: "Linear issue UUID of the running agent session.",
138
+ },
139
+ },
140
+ required: ["issueId"],
141
+ },
142
+ execute: async (_toolCallId: string, params: { issueId: string }) => {
143
+ const { issueId } = params;
144
+ const session = getActiveTmuxSession(issueId);
145
+ 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
+ });
151
+ }
152
+ try {
153
+ killSession(session.sessionName);
154
+ unregisterTmuxSession(issueId);
155
+ api.logger.info(`abort_agent: killed ${session.sessionName} (${session.backend})`);
156
+ return jsonResult({
157
+ success: true,
158
+ message: `Killed ${session.backend} agent for ${session.issueIdentifier}. ` +
159
+ `The session has been terminated. You can re-dispatch with updated instructions.`,
160
+ backend: session.backend,
161
+ issueIdentifier: session.issueIdentifier,
162
+ });
163
+ } 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
+ });
172
+ }
173
+ },
174
+ } as unknown as AnyAgentTool,
175
+ ];
176
+ }
@@ -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
  }