@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.
@@ -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;
@@ -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 CODEX_BIN = "codex";
17
22
 
@@ -51,7 +56,7 @@ function mapCodexEventToActivity(event: any): ActivityContent | null {
51
56
  const cleaned = typeof cmd === "string"
52
57
  ? cmd.replace(/^\/usr\/bin\/\w+ -lc ['"]?/, "").replace(/['"]?$/, "")
53
58
  : JSON.stringify(cmd);
54
- const truncated = output.length > 500 ? output.slice(0, 500) + "..." : output;
59
+ const truncated = output.length > 1000 ? output.slice(0, 1000) + "..." : output;
55
60
  return {
56
61
  type: "action",
57
62
  action: `${cleaned.slice(0, 150)}`,
@@ -63,7 +68,8 @@ function mapCodexEventToActivity(event: any): ActivityContent | null {
63
68
  if (eventType === "item.completed" && item?.type === "file_changes") {
64
69
  const files = item.files ?? [];
65
70
  const fileList = Array.isArray(files) ? files.join(", ") : String(files);
66
- return { type: "action", action: "Modified files", parameter: fileList || "unknown files" };
71
+ const preview = (item.diff ?? item.content ?? "").slice(0, 500) || undefined;
72
+ return { type: "action", action: "Modified files", parameter: fileList || "unknown files", result: preview };
67
73
  }
68
74
 
69
75
  if (eventType === "turn.completed") {
@@ -87,6 +93,7 @@ export async function runCodex(
87
93
  api: OpenClawPluginApi,
88
94
  params: CliToolParams,
89
95
  pluginConfig?: Record<string, unknown>,
96
+ onUpdate?: OnProgressUpdate,
90
97
  ): Promise<CliResult> {
91
98
  api.logger.info(`codex_run params: ${JSON.stringify(params).slice(0, 500)}`);
92
99
 
@@ -100,7 +107,7 @@ export async function runCodex(
100
107
  }
101
108
 
102
109
  const { model, timeoutMs } = params;
103
- const { agentSessionId, issueIdentifier } = resolveSession(params);
110
+ const { agentSessionId, issueId, issueIdentifier } = resolveSession(params);
104
111
 
105
112
  api.logger.info(`codex_run: session=${agentSessionId ?? "none"}, issue=${issueIdentifier ?? "none"}`);
106
113
 
@@ -127,11 +134,50 @@ export async function runCodex(
127
134
  args.push("-C", workingDir);
128
135
  args.push(prompt);
129
136
 
130
- api.logger.info(`Codex exec: ${CODEX_BIN} ${args.join(" ").slice(0, 200)}...`);
137
+ const fullCommand = `${CODEX_BIN} ${args.join(" ")}`;
138
+ api.logger.info(`Codex exec: ${fullCommand.slice(0, 200)}...`);
139
+
140
+ const progressHeader = `[codex] ${workingDir}\n$ ${fullCommand.slice(0, 500)}\n\nPrompt: ${prompt}`;
141
+
142
+ // --- tmux path: run inside a tmux session with pipe-pane streaming ---
143
+ const tmuxEnabled = pluginConfig?.enableTmux !== false;
144
+ if (tmuxEnabled && isTmuxAvailable()) {
145
+ const sessionName = buildSessionName(issueIdentifier ?? "unknown", "codex", 0);
146
+ const tmuxIssueId = issueId ?? sessionName;
147
+ const modelArgs = (model ?? pluginConfig?.codexModel)
148
+ ? `--model ${shellEscape((model ?? pluginConfig?.codexModel) as string)}`
149
+ : "";
150
+ const cmdStr = [
151
+ CODEX_BIN, "exec", "--full-auto", "--json", "--ephemeral",
152
+ modelArgs,
153
+ "-C", shellEscape(workingDir),
154
+ shellEscape(prompt),
155
+ ].filter(Boolean).join(" ");
156
+
157
+ return runInTmux({
158
+ issueId: tmuxIssueId,
159
+ issueIdentifier: issueIdentifier ?? "unknown",
160
+ sessionName,
161
+ command: cmdStr,
162
+ cwd: workingDir,
163
+ timeoutMs: timeout,
164
+ watchdogMs: wdConfig.inactivityMs,
165
+ logPath: path.join(workingDir, ".claw", `tmux-${sessionName}.jsonl`),
166
+ mapEvent: mapCodexEventToActivity,
167
+ linearApi: linearApi ?? undefined,
168
+ agentSessionId: agentSessionId ?? undefined,
169
+ steeringMode: "one-shot",
170
+ logger: api.logger,
171
+ onUpdate,
172
+ progressHeader,
173
+ });
174
+ }
131
175
 
176
+ // --- fallback: direct spawn ---
132
177
  return new Promise<CliResult>((resolve) => {
133
178
  const child = spawn(CODEX_BIN, args, {
134
179
  stdio: ["ignore", "pipe", "pipe"],
180
+ cwd: workingDir,
135
181
  env: { ...process.env },
136
182
  timeout: 0,
137
183
  });
@@ -162,6 +208,9 @@ export async function runCodex(
162
208
  const collectedCommands: string[] = [];
163
209
  let stderrOutput = "";
164
210
 
211
+ const progress = createProgressEmitter({ header: progressHeader, onUpdate });
212
+ progress.emitHeader();
213
+
165
214
  const rl = createInterface({ input: child.stdout! });
166
215
  rl.on("line", (line) => {
167
216
  if (!line.trim()) return;
@@ -200,10 +249,13 @@ export async function runCodex(
200
249
  }
201
250
 
202
251
  const activity = mapCodexEventToActivity(event);
203
- if (activity && linearApi && agentSessionId) {
204
- linearApi.emitActivity(agentSessionId, activity).catch((err) => {
205
- api.logger.warn(`Failed to emit Codex activity: ${err}`);
206
- });
252
+ if (activity) {
253
+ if (linearApi && agentSessionId) {
254
+ linearApi.emitActivity(agentSessionId, activity).catch((err) => {
255
+ api.logger.warn(`Failed to emit Codex activity: ${err}`);
256
+ });
257
+ }
258
+ progress.push(formatActivityLogLine(activity));
207
259
  }
208
260
  });
209
261