@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.
@@ -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 CLAUDE_BIN = "claude";
17
22
 
@@ -47,7 +52,7 @@ function mapClaudeEventToActivity(event: any): ActivityContent | null {
47
52
  } else if (input.query) {
48
53
  paramSummary = String(input.query).slice(0, 200);
49
54
  } else {
50
- paramSummary = JSON.stringify(input).slice(0, 200);
55
+ paramSummary = JSON.stringify(input).slice(0, 500);
51
56
  }
52
57
  return { type: "action", action: `Running ${toolName}`, parameter: paramSummary };
53
58
  }
@@ -63,7 +68,7 @@ function mapClaudeEventToActivity(event: any): ActivityContent | null {
63
68
  for (const block of content) {
64
69
  if (block.type === "tool_result") {
65
70
  const output = typeof block.content === "string" ? block.content : "";
66
- const truncated = output.length > 300 ? output.slice(0, 300) + "..." : output;
71
+ const truncated = output.length > 1000 ? output.slice(0, 1000) + "..." : output;
67
72
  const isError = block.is_error === true;
68
73
  return {
69
74
  type: "action",
@@ -100,6 +105,7 @@ export async function runClaude(
100
105
  api: OpenClawPluginApi,
101
106
  params: CliToolParams,
102
107
  pluginConfig?: Record<string, unknown>,
108
+ onUpdate?: OnProgressUpdate,
103
109
  ): Promise<CliResult> {
104
110
  api.logger.info(`claude_run params: ${JSON.stringify(params).slice(0, 500)}`);
105
111
 
@@ -113,7 +119,7 @@ export async function runClaude(
113
119
  }
114
120
 
115
121
  const { model, timeoutMs } = params;
116
- const { agentSessionId, issueIdentifier } = resolveSession(params);
122
+ const { agentSessionId, issueId, issueIdentifier } = resolveSession(params);
117
123
 
118
124
  api.logger.info(`claude_run: session=${agentSessionId ?? "none"}, issue=${issueIdentifier ?? "none"}`);
119
125
 
@@ -143,8 +149,54 @@ export async function runClaude(
143
149
  }
144
150
  args.push("-p", prompt);
145
151
 
146
- api.logger.info(`Claude exec: ${CLAUDE_BIN} ${args.join(" ").slice(0, 200)}...`);
152
+ const fullCommand = `${CLAUDE_BIN} ${args.join(" ")}`;
153
+ api.logger.info(`Claude exec: ${fullCommand.slice(0, 200)}...`);
154
+
155
+ const progressHeader = `[claude] ${workingDir}\n$ ${fullCommand.slice(0, 500)}\n\nPrompt: ${prompt}`;
156
+
157
+ // --- tmux path: run inside a tmux session with pipe-pane streaming ---
158
+ const tmuxEnabled = pluginConfig?.enableTmux !== false;
159
+ if (tmuxEnabled && isTmuxAvailable()) {
160
+ const sessionName = buildSessionName(issueIdentifier ?? "unknown", "claude", 0);
161
+ const tmuxIssueId = issueId ?? sessionName;
162
+ const modelArgs = (model ?? pluginConfig?.claudeModel)
163
+ ? `--model ${shellEscape((model ?? pluginConfig?.claudeModel) as string)}`
164
+ : "";
165
+ // Build env prefix: unset CLAUDECODE (avoids "nested session" error)
166
+ // and inject API key from plugin config if configured
167
+ const envParts: string[] = ["unset CLAUDECODE CLAUDE_CODE_ENTRYPOINT;"];
168
+ const claudeApiKey = pluginConfig?.claudeApiKey as string | undefined;
169
+ if (claudeApiKey) {
170
+ envParts.push(`export ANTHROPIC_API_KEY=${shellEscape(claudeApiKey)};`);
171
+ }
172
+ const cmdStr = [
173
+ ...envParts,
174
+ CLAUDE_BIN,
175
+ "--print", "--output-format", "stream-json", "--verbose", "--dangerously-skip-permissions",
176
+ modelArgs,
177
+ "-p", shellEscape(prompt),
178
+ ].filter(Boolean).join(" ");
179
+
180
+ return runInTmux({
181
+ issueId: tmuxIssueId,
182
+ issueIdentifier: issueIdentifier ?? "unknown",
183
+ sessionName,
184
+ command: cmdStr,
185
+ cwd: workingDir,
186
+ timeoutMs: timeout,
187
+ watchdogMs: wdConfig.inactivityMs,
188
+ logPath: path.join(workingDir, ".claw", `tmux-${sessionName}.jsonl`),
189
+ mapEvent: mapClaudeEventToActivity,
190
+ linearApi: linearApi ?? undefined,
191
+ agentSessionId: agentSessionId ?? undefined,
192
+ steeringMode: "stdin-pipe",
193
+ logger: api.logger,
194
+ onUpdate,
195
+ progressHeader,
196
+ });
197
+ }
147
198
 
199
+ // --- fallback: direct spawn ---
148
200
  return new Promise<CliResult>((resolve) => {
149
201
  // Must unset CLAUDECODE to avoid "nested session" error
150
202
  const env = { ...process.env };
@@ -190,6 +242,9 @@ export async function runClaude(
190
242
  let stderrOutput = "";
191
243
  let lastToolName = "";
192
244
 
245
+ const progress = createProgressEmitter({ header: progressHeader, onUpdate });
246
+ progress.emitHeader();
247
+
193
248
  const rl = createInterface({ input: child.stdout! });
194
249
  rl.on("line", (line) => {
195
250
  if (!line.trim()) return;
@@ -244,12 +299,15 @@ export async function runClaude(
244
299
  // (it duplicates the last assistant text message)
245
300
  }
246
301
 
247
- // Stream activity to Linear
302
+ // Stream activity to Linear + session progress
248
303
  const activity = mapClaudeEventToActivity(event);
249
- if (activity && linearApi && agentSessionId) {
250
- linearApi.emitActivity(agentSessionId, activity).catch((err) => {
251
- api.logger.warn(`Failed to emit Claude activity: ${err}`);
252
- });
304
+ if (activity) {
305
+ if (linearApi && agentSessionId) {
306
+ linearApi.emitActivity(agentSessionId, activity).catch((err) => {
307
+ api.logger.warn(`Failed to emit Claude activity: ${err}`);
308
+ });
309
+ }
310
+ progress.push(formatActivityLogLine(activity));
253
311
  }
254
312
  });
255
313
 
@@ -14,6 +14,7 @@ export interface CliToolParams {
14
14
  workingDir?: string;
15
15
  model?: string;
16
16
  timeoutMs?: number;
17
+ issueId?: string;
17
18
  issueIdentifier?: string;
18
19
  agentSessionId?: string;
19
20
  }
@@ -24,6 +25,51 @@ export interface CliResult {
24
25
  error?: string;
25
26
  }
26
27
 
28
+ export type OnProgressUpdate = (update: Record<string, unknown>) => void;
29
+
30
+ /**
31
+ * Format a Linear activity as a single streaming log line for session progress.
32
+ */
33
+ export function formatActivityLogLine(activity: { type: string; body?: string; action?: string; parameter?: string; result?: string }): string {
34
+ if (activity.type === "thought") {
35
+ return `▸ ${(activity.body ?? "").slice(0, 300)}`;
36
+ }
37
+ if (activity.type === "action") {
38
+ const result = activity.result ? `\n → ${activity.result.slice(0, 200)}` : "";
39
+ return `▸ ${activity.action ?? ""}: ${(activity.parameter ?? "").slice(0, 300)}${result}`;
40
+ }
41
+ return `▸ ${JSON.stringify(activity).slice(0, 300)}`;
42
+ }
43
+
44
+ /**
45
+ * Create a progress emitter that maintains a rolling log of streaming events.
46
+ * Calls onUpdate with the full accumulated log on each new event.
47
+ */
48
+ export function createProgressEmitter(opts: {
49
+ header: string;
50
+ onUpdate?: OnProgressUpdate;
51
+ maxLines?: number;
52
+ }): { push: (line: string) => void; emitHeader: () => void } {
53
+ const lines: string[] = [];
54
+ const maxLines = opts.maxLines ?? 40;
55
+ const { header, onUpdate } = opts;
56
+
57
+ function emit() {
58
+ if (!onUpdate) return;
59
+ const log = lines.length > 0 ? "\n---\n" + lines.join("\n") : "";
60
+ try { onUpdate({ status: "running", summary: header + log }); } catch {}
61
+ }
62
+
63
+ return {
64
+ emitHeader() { emit(); },
65
+ push(line: string) {
66
+ lines.push(line);
67
+ if (lines.length > maxLines) lines.splice(0, lines.length - maxLines);
68
+ emit();
69
+ },
70
+ };
71
+ }
72
+
27
73
  /**
28
74
  * Build a LinearAgentApi instance for streaming activities to Linear.
29
75
  */
@@ -53,9 +99,10 @@ export function buildLinearApi(
53
99
  */
54
100
  export function resolveSession(params: CliToolParams): {
55
101
  agentSessionId: string | undefined;
102
+ issueId: string | undefined;
56
103
  issueIdentifier: string | undefined;
57
104
  } {
58
- let { issueIdentifier, agentSessionId } = params;
105
+ let { issueId, issueIdentifier, agentSessionId } = params;
59
106
 
60
107
  if (!agentSessionId || !issueIdentifier) {
61
108
  const active = issueIdentifier
@@ -63,11 +110,12 @@ export function resolveSession(params: CliToolParams): {
63
110
  : getCurrentSession();
64
111
  if (active) {
65
112
  agentSessionId = agentSessionId ?? active.agentSessionId;
113
+ issueId = issueId ?? active.issueId;
66
114
  issueIdentifier = issueIdentifier ?? active.issueIdentifier;
67
115
  }
68
116
  }
69
117
 
70
- return { agentSessionId, issueIdentifier };
118
+ return { agentSessionId, issueId, issueIdentifier };
71
119
  }
72
120
 
73
121
  /**
@@ -1,39 +1,64 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join, dirname } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
4
+ import type { AnyAgentTool, OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
5
5
  import { jsonResult } from "openclaw/plugin-sdk";
6
- import { getCurrentSession } from "../pipeline/active-session.js";
6
+ import { getCurrentSession, getActiveSessionByAgentId } from "../pipeline/active-session.js";
7
7
  import { runCodex } from "./codex-tool.js";
8
8
  import { runClaude } from "./claude-tool.js";
9
9
  import { runGemini } from "./gemini-tool.js";
10
- import type { CliToolParams, CliResult } from "./cli-shared.js";
10
+ import type { CliToolParams, CliResult, OnProgressUpdate } from "./cli-shared.js";
11
+ import { DEFAULT_BASE_REPO } from "./cli-shared.js";
11
12
 
12
13
  export type CodingBackend = "claude" | "codex" | "gemini";
13
14
 
14
- const BACKEND_LABELS: Record<CodingBackend, string> = {
15
- claude: "Claude Code (Anthropic)",
16
- codex: "Codex (OpenAI)",
17
- gemini: "Gemini CLI (Google)",
18
- };
19
-
20
- const BACKEND_RUNNERS: Record<
21
- CodingBackend,
22
- (api: OpenClawPluginApi, params: CliToolParams, pluginConfig?: Record<string, unknown>) => Promise<CliResult>
23
- > = {
24
- claude: runClaude,
25
- codex: runCodex,
26
- gemini: runGemini,
15
+ const BACKENDS: Record<CodingBackend, {
16
+ label: string;
17
+ toolName: string;
18
+ runner: (api: OpenClawPluginApi, params: CliToolParams, pluginConfig?: Record<string, unknown>, onUpdate?: OnProgressUpdate) => Promise<CliResult>;
19
+ description: string;
20
+ configKeyTimeout: string;
21
+ configKeyBaseRepo: string;
22
+ }> = {
23
+ codex: {
24
+ label: "Codex CLI (OpenAI)",
25
+ toolName: "cli_codex",
26
+ runner: runCodex,
27
+ description:
28
+ "Run OpenAI Codex CLI to perform a coding task. " +
29
+ "Can read/write files, run commands, search code, run tests. " +
30
+ "Streams progress to Linear in real-time.",
31
+ configKeyTimeout: "codexTimeoutMs",
32
+ configKeyBaseRepo: "codexBaseRepo",
33
+ },
34
+ claude: {
35
+ label: "Claude Code (Anthropic)",
36
+ toolName: "cli_claude",
37
+ runner: runClaude,
38
+ description:
39
+ "Run Anthropic Claude Code CLI to perform a coding task. " +
40
+ "Can read/write files, run commands, search code, run tests. " +
41
+ "Streams progress to Linear in real-time.",
42
+ configKeyTimeout: "claudeTimeoutMs",
43
+ configKeyBaseRepo: "claudeBaseRepo",
44
+ },
45
+ gemini: {
46
+ label: "Gemini CLI (Google)",
47
+ toolName: "cli_gemini",
48
+ runner: runGemini,
49
+ description:
50
+ "Run Google Gemini CLI to perform a coding task. " +
51
+ "Can read/write files, run commands, search code, run tests. " +
52
+ "Streams progress to Linear in real-time.",
53
+ configKeyTimeout: "geminiTimeoutMs",
54
+ configKeyBaseRepo: "geminiBaseRepo",
55
+ },
27
56
  };
28
57
 
29
- interface BackendConfig {
30
- aliases?: string[];
31
- }
32
-
33
58
  export interface CodingToolsConfig {
34
59
  codingTool?: string;
35
60
  agentCodingTools?: Record<string, string>;
36
- backends?: Record<string, BackendConfig>;
61
+ backends?: Record<string, { aliases?: string[] }>;
37
62
  }
38
63
 
39
64
  /**
@@ -42,7 +67,6 @@ export interface CodingToolsConfig {
42
67
  */
43
68
  export function loadCodingConfig(): CodingToolsConfig {
44
69
  try {
45
- // Resolve relative to the plugin root (one level up from src/)
46
70
  const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
47
71
  const raw = readFileSync(join(pluginRoot, "coding-tools.json"), "utf8");
48
72
  return JSON.parse(raw) as CodingToolsConfig;
@@ -51,37 +75,6 @@ export function loadCodingConfig(): CodingToolsConfig {
51
75
  }
52
76
  }
53
77
 
54
- /**
55
- * Build a reverse lookup map: alias (lowercase) → backend ID.
56
- * Backend IDs themselves are always valid aliases.
57
- */
58
- function buildAliasMap(config: CodingToolsConfig): Map<string, CodingBackend> {
59
- const map = new Map<string, CodingBackend>();
60
-
61
- for (const backendId of Object.keys(BACKEND_RUNNERS) as CodingBackend[]) {
62
- // The backend ID itself is always an alias
63
- map.set(backendId, backendId);
64
-
65
- // Add configured aliases
66
- const aliases = config.backends?.[backendId]?.aliases;
67
- if (aliases) {
68
- for (const alias of aliases) {
69
- map.set(alias.toLowerCase(), backendId);
70
- }
71
- }
72
- }
73
-
74
- return map;
75
- }
76
-
77
- /**
78
- * Resolve a user-provided alias string to a backend ID.
79
- * Returns undefined if no match.
80
- */
81
- function resolveAlias(aliasMap: Map<string, CodingBackend>, input: string): CodingBackend | undefined {
82
- return aliasMap.get(input.toLowerCase());
83
- }
84
-
85
78
  /**
86
79
  * Resolve which coding backend to use for a given agent.
87
80
  *
@@ -94,121 +87,208 @@ export function resolveCodingBackend(
94
87
  config: CodingToolsConfig,
95
88
  agentId?: string,
96
89
  ): CodingBackend {
97
- // Per-agent override
98
90
  if (agentId) {
99
91
  const override = config.agentCodingTools?.[agentId];
100
- if (override && override in BACKEND_RUNNERS) return override as CodingBackend;
92
+ if (override && override in BACKENDS) return override as CodingBackend;
101
93
  }
102
-
103
- // Global default
104
94
  const global = config.codingTool;
105
- if (global && global in BACKEND_RUNNERS) return global as CodingBackend;
106
-
95
+ if (global && global in BACKENDS) return global as CodingBackend;
107
96
  return "codex";
108
97
  }
109
98
 
110
99
  /**
111
- * Create the unified `code_run` tool.
100
+ * Resolve the tool name (cli_codex, cli_claude, cli_gemini) for a given agent.
101
+ */
102
+ export function resolveToolName(config: CodingToolsConfig, agentId?: string): string {
103
+ return BACKENDS[resolveCodingBackend(config, agentId)].toolName;
104
+ }
105
+
106
+ /**
107
+ * Parse a session key to extract channel routing info for progress messages.
108
+ */
109
+ function parseChannelTarget(sessionKey?: string): {
110
+ provider: string;
111
+ peerId: string;
112
+ } | null {
113
+ if (!sessionKey) return null;
114
+ const parts = sessionKey.split(":");
115
+ if (parts.length < 5 || parts[0] !== "agent") return null;
116
+ const provider = parts[2];
117
+ const kind = parts[3];
118
+ if (!provider || !kind) return null;
119
+ const peerId = parts[4];
120
+ if (!peerId) return null;
121
+ return { provider, peerId };
122
+ }
123
+
124
+ /**
125
+ * Create a channel sender that can send messages to the session's channel.
126
+ */
127
+ function createChannelSender(
128
+ api: OpenClawPluginApi,
129
+ sessionKey?: string,
130
+ ): ((text: string) => Promise<void>) | null {
131
+ const target = parseChannelTarget(sessionKey);
132
+ if (!target) return null;
133
+ const { provider, peerId } = target;
134
+
135
+ if (provider === "discord") {
136
+ return async (text: string) => {
137
+ try {
138
+ await api.runtime.channel.discord.sendMessageDiscord(peerId, text, { silent: true });
139
+ } catch (err) {
140
+ api.logger.warn(`cli channel send (discord) failed: ${err}`);
141
+ }
142
+ };
143
+ }
144
+ if (provider === "telegram") {
145
+ return async (text: string) => {
146
+ try {
147
+ await api.runtime.channel.telegram.sendMessageTelegram(peerId, text, { silent: true });
148
+ } catch (err) {
149
+ api.logger.warn(`cli channel send (telegram) failed: ${err}`);
150
+ }
151
+ };
152
+ }
153
+ return null;
154
+ }
155
+
156
+ /**
157
+ * Inject Linear session info into tool params so backend runners can emit
158
+ * activities to the correct Linear agent session.
159
+ */
160
+ function injectSessionInfo(
161
+ params: CliToolParams,
162
+ ctx: OpenClawPluginToolContext,
163
+ ): void {
164
+ const ctxAgentId = ctx.agentId;
165
+ const activeSession = getCurrentSession()
166
+ ?? (ctxAgentId ? getActiveSessionByAgentId(ctxAgentId) : null);
167
+
168
+ if (activeSession) {
169
+ if (!params.agentSessionId) (params as any).agentSessionId = activeSession.agentSessionId;
170
+ if (!params.issueId) (params as any).issueId = activeSession.issueId;
171
+ if (!params.issueIdentifier) (params as any).issueIdentifier = activeSession.issueIdentifier;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Create the three coding CLI tools: cli_codex, cli_claude, cli_gemini.
112
177
  *
113
- * The tool dispatches to the backend configured in coding-tools.json
114
- * (codingTool / agentCodingTools). The agent always calls `code_run` —
115
- * it doesn't need to know which CLI is being used.
178
+ * Each tool directly invokes its backend CLI. The tool name shown in Linear
179
+ * reflects which CLI is running (e.g. "Running cli_codex").
116
180
  */
117
- export function createCodeTool(
181
+ export function createCodeTools(
118
182
  api: OpenClawPluginApi,
119
- _ctx: Record<string, unknown>,
120
- ): AnyAgentTool {
183
+ rawCtx: Record<string, unknown>,
184
+ ): AnyAgentTool[] {
185
+ const ctx = rawCtx as OpenClawPluginToolContext;
121
186
  const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
122
187
  const codingConfig = loadCodingConfig();
123
- const aliasMap = buildAliasMap(codingConfig);
124
188
 
125
- // Resolve the default backend for the tool description (may be overridden at runtime per-agent)
126
- const defaultBackend = resolveCodingBackend(codingConfig);
127
- const defaultLabel = BACKEND_LABELS[defaultBackend];
189
+ const tools: AnyAgentTool[] = [];
128
190
 
129
- // Build alias description for each backend so the LLM knows what names to use
130
- const aliasDescParts: string[] = [];
131
- for (const backendId of Object.keys(BACKEND_RUNNERS) as CodingBackend[]) {
132
- const aliases = codingConfig.backends?.[backendId]?.aliases ?? [backendId];
133
- aliasDescParts.push(`${BACKEND_LABELS[backendId]}: ${aliases.map(a => `"${a}"`).join(", ")}`);
134
- }
135
- const aliasDesc = aliasDescParts.join("; ");
191
+ for (const [backendId, backend] of Object.entries(BACKENDS) as [CodingBackend, typeof BACKENDS[CodingBackend]][]) {
192
+ const tool: AnyAgentTool = {
193
+ name: backend.toolName,
194
+ label: backend.label,
195
+ description: backend.description,
196
+ parameters: {
197
+ type: "object",
198
+ properties: {
199
+ prompt: {
200
+ type: "string",
201
+ description:
202
+ "What the coding agent should do. Be specific: include file paths, function names, " +
203
+ "expected behavior, and test requirements.",
204
+ },
205
+ workingDir: {
206
+ type: "string",
207
+ description: "Override working directory (default: ~/ai-workspace).",
208
+ },
209
+ model: {
210
+ type: "string",
211
+ description: "Model override for the coding backend.",
212
+ },
213
+ timeoutMs: {
214
+ type: "number",
215
+ description: "Max runtime in milliseconds (default: 600000 = 10 min).",
216
+ },
217
+ },
218
+ required: ["prompt"],
219
+ },
220
+ execute: async (toolCallId: string, params: CliToolParams, ...rest: unknown[]) => {
221
+ const originalOnUpdate = typeof rest[1] === "function"
222
+ ? rest[1] as (update: Record<string, unknown>) => void
223
+ : undefined;
136
224
 
137
- api.logger.info(`code_run: default backend=${defaultBackend}, aliases=${JSON.stringify(Object.fromEntries(aliasMap))}, per-agent overrides=${JSON.stringify(codingConfig.agentCodingTools ?? {})}`);
225
+ // Inject Linear session context
226
+ injectSessionInfo(params, ctx);
138
227
 
139
- return {
140
- name: "code_run",
141
- label: "Run Coding Agent",
142
- description:
143
- `Run an agentic coding CLI to perform a hands-on coding task. ` +
144
- `Default backend: ${defaultLabel}. You can override with the 'backend' parameter ` +
145
- `if the user asks for a specific tool. ` +
146
- `Known aliases — ${aliasDesc}. ` +
147
- `The CLI can read/write files, run commands, search code, run tests, and more. ` +
148
- `Streams progress to Linear in real-time. Use this for writing code, debugging, ` +
149
- `refactoring, creating files, running tests, and other hands-on development work.`,
150
- parameters: {
151
- type: "object",
152
- properties: {
153
- prompt: {
154
- type: "string",
155
- description:
156
- "What the coding agent should do. Be specific: include file paths, function names, " +
157
- "expected behavior, and test requirements.",
158
- },
159
- backend: {
160
- type: "string",
161
- description:
162
- `Which coding CLI to use. Accepts any known alias: ${aliasDesc}. ` +
163
- "If omitted, uses the configured default.",
164
- },
165
- workingDir: {
166
- type: "string",
167
- description: "Override working directory (default: ~/ai-workspace).",
168
- },
169
- model: {
170
- type: "string",
171
- description: "Model override for the coding backend.",
172
- },
173
- timeoutMs: {
174
- type: "number",
175
- description: "Max runtime in milliseconds (default: 600000 = 10 min).",
176
- },
228
+ const workingDir = params.workingDir
229
+ ?? (pluginConfig?.[backend.configKeyBaseRepo] as string)
230
+ ?? DEFAULT_BASE_REPO;
231
+ const prompt = params.prompt ?? "";
232
+
233
+ api.logger.info(`${backend.toolName}: agent=${ctx.agentId ?? "unknown"} dir=${workingDir}`);
234
+ api.logger.info(`${backend.toolName} prompt: ${prompt.slice(0, 200)}`);
235
+
236
+ // Channel progress messaging
237
+ const channelSend = createChannelSender(api, ctx.sessionKey);
238
+ if (channelSend) {
239
+ const initMsg = [
240
+ `**${backend.toolName}** — ${backend.label}`,
241
+ `\`${workingDir}\``,
242
+ `> ${prompt.slice(0, 800)}${prompt.length > 800 ? "..." : ""}`,
243
+ ].join("\n");
244
+ channelSend(initMsg).catch(() => {});
245
+ }
246
+
247
+ // Throttled progress forwarding
248
+ let lastForwardMs = 0;
249
+ let lastChannelMs = 0;
250
+ const FORWARD_THROTTLE_MS = 30_000;
251
+ const CHANNEL_THROTTLE_MS = 20_000;
252
+
253
+ const wrappedOnUpdate: OnProgressUpdate = (update) => {
254
+ const now = Date.now();
255
+ if (originalOnUpdate && now - lastForwardMs >= FORWARD_THROTTLE_MS) {
256
+ lastForwardMs = now;
257
+ try { originalOnUpdate(update); } catch {}
258
+ }
259
+ if (channelSend && now - lastChannelMs >= CHANNEL_THROTTLE_MS) {
260
+ lastChannelMs = now;
261
+ const summary = String(update.summary ?? "");
262
+ if (summary) {
263
+ const logIdx = summary.indexOf("\n---\n");
264
+ const logPart = logIdx >= 0 ? summary.slice(logIdx + 5) : "";
265
+ if (logPart.trim()) {
266
+ const tail = logPart.length > 1200 ? "..." + logPart.slice(-1200) : logPart;
267
+ channelSend(`\`\`\`\n${tail}\n\`\`\``).catch(() => {});
268
+ }
269
+ }
270
+ }
271
+ };
272
+
273
+ const result = await backend.runner(api, params, pluginConfig, wrappedOnUpdate);
274
+
275
+ return jsonResult({
276
+ success: result.success,
277
+ backend: backendId,
278
+ output: result.output,
279
+ ...(result.error ? { error: result.error } : {}),
280
+ });
177
281
  },
178
- required: ["prompt"],
179
- },
180
- execute: async (toolCallId: string, params: CliToolParams & { backend?: string }, ...rest: unknown[]) => {
181
- // Extract onUpdate callback for progress reporting to Linear
182
- const onUpdate = typeof rest[1] === "function"
183
- ? rest[1] as (update: Record<string, unknown>) => void
184
- : undefined;
185
-
186
- // Resolve backend: explicit alias → per-agent config → global default
187
- const currentSession = getCurrentSession();
188
- const agentId = currentSession?.agentId;
189
- const explicitBackend = params.backend
190
- ? resolveAlias(aliasMap, params.backend)
191
- : undefined;
192
- const backend = explicitBackend ?? resolveCodingBackend(codingConfig, agentId);
193
- const runner = BACKEND_RUNNERS[backend];
194
-
195
- api.logger.info(`code_run: backend=${backend} agent=${agentId ?? "unknown"}`);
196
-
197
- // Emit prompt summary so Linear users see what's being built
198
- const promptSummary = (params.prompt ?? "").slice(0, 200);
199
- api.logger.info(`code_run prompt: [${backend}] ${promptSummary}`);
200
- if (onUpdate) {
201
- try { onUpdate({ status: "running", summary: `[${backend}] ${promptSummary}` }); } catch {}
202
- }
282
+ } as unknown as AnyAgentTool;
203
283
 
204
- const result = await runner(api, params, pluginConfig);
284
+ tools.push(tool);
285
+ }
286
+
287
+ const defaultBackend = resolveCodingBackend(codingConfig, ctx.agentId);
288
+ api.logger.info(`cli tools registered: ${tools.map(t => (t as any).name).join(", ")} (agent default: ${BACKENDS[defaultBackend].toolName})`);
205
289
 
206
- return jsonResult({
207
- success: result.success,
208
- backend,
209
- output: result.output,
210
- ...(result.error ? { error: result.error } : {}),
211
- });
212
- },
213
- } as unknown as AnyAgentTool;
290
+ return tools;
214
291
  }
292
+
293
+ // Keep backward-compat export for tests that reference the old name
294
+ export const createCodeTool = createCodeTools;