tmux-watch 2026.2.5 → 2026.2.6

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/README.md CHANGED
@@ -123,9 +123,49 @@ echo $TMUX
123
123
  }
124
124
  ```
125
125
 
126
+ ### 发送输入到 pane
127
+
128
+ ```bash
129
+ openclaw tmux-watch send test-dir:0.0 "your text"
130
+ ```
131
+
132
+ 默认行为等同于两步:先 `send-keys -l` 输入文本,再单独 `send-keys C-m` 提交。两步之间默认延迟 `20ms`。
133
+
134
+ 常用选项:
135
+
136
+ - `--no-enter`:只输入,不回车。
137
+ - `--delay-ms 50`:调整输入与回车之间的延迟。
138
+ - `--socket /path/to/socket`:指定 tmux socket。
139
+ - `--target ... --text ...`:用参数替代位置参数。
140
+
141
+ ### 捕获输出 / 截图
142
+
143
+ ```bash
144
+ # 文本(默认)
145
+ openclaw tmux-watch capture session:0.0
146
+
147
+ # 图片(临时文件,默认 10 分钟 TTL)
148
+ openclaw tmux-watch capture session:0.0 --format image
149
+
150
+ # 文本 + 图片(可选 base64)
151
+ openclaw tmux-watch capture session:0.0 --format both --base64
152
+
153
+ # 指定输出路径(不会自动清理)
154
+ openclaw tmux-watch capture session:0.0 --format image --output /tmp/pane.png
155
+ ```
156
+
157
+ 说明:
158
+
159
+ - 图片优先使用 `cryosnap`,其次使用 `freeze`。若均未安装,请手动安装:
160
+ - `openclaw tmux-watch install cryosnap`
161
+ - `openclaw tmux-watch install freeze`
162
+ - 临时图片默认 TTL 为 10 分钟,可用 `--ttl-seconds` 覆盖。
163
+ - 可用 `--image-format png|svg|webp` 指定格式。
164
+
126
165
  ### 依赖
127
166
 
128
167
  - 系统依赖:`tmux`
168
+ - 可选截图依赖:`cryosnap`(优先)或 `freeze`
129
169
  - peer 依赖:`openclaw >= 2026.1.29`
130
170
 
131
171
  <a id="en"></a>
@@ -250,7 +290,48 @@ openclaw tmux-watch setup --socket "/private/tmp/tmux-501/default"
250
290
  }
251
291
  ```
252
292
 
293
+ ### Send input to a pane
294
+
295
+ ```bash
296
+ openclaw tmux-watch send test-dir:0.0 "your text"
297
+ ```
298
+
299
+ Default behavior is two-step: send text with `send-keys -l`, then send `C-m` (Enter) separately.
300
+ The default delay between the two steps is `20ms`.
301
+
302
+ Common options:
303
+
304
+ - `--no-enter`: type only, do not press Enter.
305
+ - `--delay-ms 50`: adjust the delay between text and Enter.
306
+ - `--socket /path/to/socket`: specify tmux socket.
307
+ - `--target ... --text ...`: use flags instead of positional args.
308
+
309
+ ### Capture output / snapshot
310
+
311
+ ```bash
312
+ # Text only (default)
313
+ openclaw tmux-watch capture session:0.0
314
+
315
+ # Image only (temporary file, 10-min TTL)
316
+ openclaw tmux-watch capture session:0.0 --format image
317
+
318
+ # Both text + image (optional base64)
319
+ openclaw tmux-watch capture session:0.0 --format both --base64
320
+
321
+ # Persist image to a path (no TTL cleanup)
322
+ openclaw tmux-watch capture session:0.0 --format image --output /tmp/pane.png
323
+ ```
324
+
325
+ Notes:
326
+
327
+ - Image capture prefers `cryosnap`, then falls back to `freeze`. If neither exists, install one:
328
+ - `openclaw tmux-watch install cryosnap`
329
+ - `openclaw tmux-watch install freeze`
330
+ - Temporary images default to a 10-minute TTL; override with `--ttl-seconds`.
331
+ - Use `--image-format png|svg|webp` to select the output format.
332
+
253
333
  ### Requirements
254
334
 
255
335
  - System dependency: `tmux`
336
+ - Optional image tools: `cryosnap` (preferred) or `freeze`
256
337
  - Peer dependency: `openclaw >= 2026.1.29`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmux-watch",
3
- "version": "2026.2.5",
3
+ "version": "2026.2.6",
4
4
  "type": "module",
5
5
  "description": "OpenClaw tmux output watchdog plugin",
6
6
  "license": "MIT",
package/skills/SKILL.md CHANGED
@@ -37,9 +37,7 @@ tmux -S /path/to/socket capture-pane -p -J -t <session:window.pane> -S -200
37
37
 
38
38
  ## Controlling a TUI in tmux (high-signal tips)
39
39
 
40
- - Prefer `send-keys -l` for literal text input to avoid tmux interpreting key names.
41
- - For TUIs with input boxes (Codex/Claude Code), send text with `-l` first, then send `C-m`
42
- as a separate command to submit reliably.
40
+ - Prefer `openclaw tmux-watch send` for reliable input (two-step, default 20ms delay).
43
41
  - Use `C-c` to interrupt a stuck process; use `C-m` (Enter) to submit commands.
44
42
  - For TUIs, avoid rapid key spam; send a small sequence, then capture output to verify state.
45
43
  - Use `capture-pane -p -J -S -200` to get recent context before and after an action.
@@ -48,9 +46,8 @@ tmux -S /path/to/socket capture-pane -p -J -t <session:window.pane> -S -200
48
46
  Examples:
49
47
 
50
48
  ```bash
51
- # Send a command to the pane (two-step: text then Enter)
52
- tmux send-keys -t <session:window.pane> -l -- "status"
53
- tmux send-keys -t <session:window.pane> C-m
49
+ # Send a command via tmux-watch (default delay 20ms + Enter)
50
+ openclaw tmux-watch send <session:window.pane> "status"
54
51
 
55
52
  # Interrupt a stuck process
56
53
  tmux send-keys -t <session:window.pane> C-c
@@ -63,46 +60,45 @@ tmux send-keys -t <session:window.pane> C-l
63
60
  tmux capture-pane -p -J -t <session:window.pane> -S -200
64
61
  ```
65
62
 
66
- ## Screenshot tools (priority + install)
63
+ ## Screenshot capture (preferred)
64
+
65
+ Use `openclaw tmux-watch capture` to capture text/images from a tmux target. The plugin selects
66
+ `cryosnap` first, then falls back to `freeze`.
67
67
 
68
68
  Priority detection order:
69
69
 
70
- 1. System-level PATH (`command -v cryosnap` / `command -v freeze`)
70
+ 1. System-level PATH (`cryosnap` / `freeze`)
71
71
  2. User-level bins (`~/.local/bin`, `~/bin`)
72
72
  3. OpenClaw tools dir (`$OPENCLAW_STATE_DIR/tools`, default `~/.openclaw/tools`)
73
73
 
74
- If cryosnap exists, use it. If not, use freeze. If neither exists, **auto-install cryosnap**.
75
-
76
- Install commands (downloads the latest GitHub release into the OpenClaw tools dir):
74
+ If neither tool exists, return an error and ask the user to install one:
77
75
 
78
76
  ```bash
79
77
  openclaw tmux-watch install cryosnap
80
78
  openclaw tmux-watch install freeze
81
- openclaw tmux-watch update cryosnap
82
- openclaw tmux-watch update freeze
83
- openclaw tmux-watch remove cryosnap
84
- openclaw tmux-watch remove freeze
85
79
  ```
86
80
 
87
- ### cryosnap (preferred)
81
+ Examples:
88
82
 
89
83
  ```bash
90
- # tmux pane -> PNG
91
- cryosnap --tmux --tmux-args "-t %3 -S -200 -J" --config full -o out.png
92
- ```
84
+ # Text only (uses plugin defaults for lines/strip)
85
+ openclaw tmux-watch capture <session:window.pane>
93
86
 
94
- Notes:
87
+ # Image only (temporary file, auto-cleaned after 10 minutes)
88
+ openclaw tmux-watch capture <session:window.pane> --format image
95
89
 
96
- - For zsh, wrap `%3` in quotes or escape `%` (e.g., `"-t %3 -S -200 -J"`).
97
- - You can pass `-t session:window.pane` instead of `%pane_id`.
90
+ # Both text + image, include base64 (optional)
91
+ openclaw tmux-watch capture <session:window.pane> --format both --base64
98
92
 
99
- ### freeze (fallback)
100
-
101
- ```bash
102
- # Capture ANSI text first, then render with freeze
103
- tmux capture-pane -p -J -e -t <session:window.pane> -S -200 | freeze -o out.png
93
+ # Persist image to a path (no TTL cleanup)
94
+ openclaw tmux-watch capture <session:window.pane> --format image --output /tmp/pane.png
104
95
  ```
105
96
 
97
+ Notes:
98
+
99
+ - Temporary images default to a 10-minute TTL. Override with `--ttl-seconds`.
100
+ - Use `--image-format png|svg|webp` to select output format.
101
+
106
102
  ## Tool: tmux-watch
107
103
 
108
104
  ### Add subscription
@@ -147,6 +143,20 @@ Optional routing overrides:
147
143
  { "action": "list", "includeOutput": true }
148
144
  ```
149
145
 
146
+ ### Capture once
147
+
148
+ ```json
149
+ {
150
+ "action": "capture",
151
+ "target": "session:0.0",
152
+ "format": "both",
153
+ "captureLines": 200,
154
+ "stripAnsi": true,
155
+ "imageFormat": "png",
156
+ "base64": false
157
+ }
158
+ ```
159
+
150
160
  ## Handling tmux-watch events
151
161
 
152
162
  When tmux-watch detects stable output, it sends a message containing:
package/src/capture.ts ADDED
@@ -0,0 +1,445 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import type { TmuxWatchConfig } from "./config.js";
3
+ import { randomUUID } from "node:crypto";
4
+ import { constants as fsConstants } from "node:fs";
5
+ import fs from "node:fs/promises";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { stripAnsi, truncateOutput } from "./text-utils.js";
9
+ import { resolveBinaryName, resolveToolsDir, type ToolId } from "./tool-install.js";
10
+
11
+ export type CaptureFormat = "text" | "image" | "both";
12
+ export type ImageFormat = "png" | "svg" | "webp";
13
+
14
+ export type CaptureParams = {
15
+ target: string;
16
+ socket?: string;
17
+ captureLines?: number;
18
+ stripAnsi?: boolean;
19
+ format?: string;
20
+ imageFormat?: string;
21
+ outputPath?: string;
22
+ base64?: boolean;
23
+ ttlSeconds?: number;
24
+ maxChars?: number;
25
+ };
26
+
27
+ export type CaptureResult = {
28
+ target: string;
29
+ format: CaptureFormat;
30
+ capturedAt: string;
31
+ text?: string;
32
+ textTruncated?: boolean;
33
+ imagePath?: string;
34
+ imageTool?: ToolId;
35
+ imageFormat?: ImageFormat;
36
+ imageBase64?: string;
37
+ temporary?: boolean;
38
+ ttlSeconds?: number;
39
+ };
40
+
41
+ type ToolLocation = {
42
+ id: ToolId;
43
+ path: string;
44
+ source: "path" | "user-bin" | "openclaw";
45
+ };
46
+
47
+ const DEFAULT_TTL_SECONDS = 600;
48
+ const TEMP_DIR_NAME = "openclaw-tmux-watch";
49
+
50
+ export function resolveCaptureFormat(raw?: string, outputPath?: string): CaptureFormat {
51
+ const value = typeof raw === "string" ? raw.trim().toLowerCase() : "";
52
+ if (value === "text" || value === "image" || value === "both") {
53
+ return value;
54
+ }
55
+ if (outputPath && outputPath.trim()) {
56
+ return "image";
57
+ }
58
+ return "text";
59
+ }
60
+
61
+ export function resolveImageFormat(raw?: string, outputPath?: string): ImageFormat {
62
+ const value = typeof raw === "string" ? raw.trim().toLowerCase() : "";
63
+ if (value === "png" || value === "svg" || value === "webp") {
64
+ return value;
65
+ }
66
+ const ext = outputPath ? path.extname(outputPath).slice(1).toLowerCase() : "";
67
+ if (ext === "png" || ext === "svg" || ext === "webp") {
68
+ return ext as ImageFormat;
69
+ }
70
+ return "png";
71
+ }
72
+
73
+ export function pickTool(candidates: ToolLocation[]): ToolLocation | null {
74
+ const cryosnap = candidates.find((tool) => tool.id === "cryosnap");
75
+ if (cryosnap) {
76
+ return cryosnap;
77
+ }
78
+ const freeze = candidates.find((tool) => tool.id === "freeze");
79
+ return freeze ?? null;
80
+ }
81
+
82
+ export async function captureTmux(params: {
83
+ api: OpenClawPluginApi;
84
+ config: TmuxWatchConfig;
85
+ } & CaptureParams): Promise<CaptureResult> {
86
+ const target = params.target.trim();
87
+ if (!target) {
88
+ throw new Error("target required for capture");
89
+ }
90
+ const socket = normalizeSocket(params.socket ?? params.config.socket);
91
+ const format = resolveCaptureFormat(params.format, params.outputPath);
92
+ const captureLines = resolveCaptureLines(params.captureLines, params.config);
93
+ const stripOutput = resolveStripAnsi(params.stripAnsi, params.config);
94
+ const includeAnsi = format !== "text" || !stripOutput;
95
+
96
+ const rawOutput = await capturePaneOutput({
97
+ api: params.api,
98
+ target,
99
+ socket,
100
+ captureLines,
101
+ includeAnsi,
102
+ });
103
+
104
+ const capturedAt = new Date().toISOString();
105
+ const result: CaptureResult = {
106
+ target,
107
+ format,
108
+ capturedAt,
109
+ };
110
+
111
+ if (format !== "image") {
112
+ let text = rawOutput;
113
+ if (stripOutput) {
114
+ text = stripAnsi(text);
115
+ }
116
+ const maxChars = resolveMaxChars(params.maxChars, params.config);
117
+ if (maxChars > 0) {
118
+ const outputInfo = truncateOutput(text, maxChars);
119
+ result.text = outputInfo.text;
120
+ result.textTruncated = outputInfo.truncated;
121
+ } else {
122
+ result.text = text;
123
+ result.textTruncated = false;
124
+ }
125
+ }
126
+
127
+ if (format !== "text") {
128
+ const imageFormat = resolveImageFormat(params.imageFormat, params.outputPath);
129
+ const ttlSeconds = resolveTtlSeconds(params.ttlSeconds);
130
+ const image = await renderImage({
131
+ api: params.api,
132
+ output: rawOutput,
133
+ imageFormat,
134
+ outputPath: params.outputPath,
135
+ ttlSeconds,
136
+ });
137
+ result.imagePath = image.path;
138
+ result.imageTool = image.tool;
139
+ result.imageFormat = imageFormat;
140
+ result.temporary = image.temporary;
141
+ result.ttlSeconds = image.temporary ? ttlSeconds : undefined;
142
+ if (params.base64) {
143
+ const bytes = await fs.readFile(image.path);
144
+ result.imageBase64 = bytes.toString("base64");
145
+ }
146
+ }
147
+
148
+ return result;
149
+ }
150
+
151
+ type CapturePaneParams = {
152
+ api: OpenClawPluginApi;
153
+ target: string;
154
+ socket?: string;
155
+ captureLines: number;
156
+ includeAnsi: boolean;
157
+ };
158
+
159
+ async function capturePaneOutput(params: CapturePaneParams): Promise<string> {
160
+ const argv = params.socket
161
+ ? ["tmux", "-S", params.socket, "capture-pane", "-p", "-J", "-t", params.target]
162
+ : ["tmux", "capture-pane", "-p", "-J", "-t", params.target];
163
+ if (params.includeAnsi) {
164
+ argv.push("-e");
165
+ }
166
+ if (params.captureLines > 0) {
167
+ argv.push("-S", `-${params.captureLines}`);
168
+ }
169
+
170
+ try {
171
+ const result = await params.api.runtime.system.runCommandWithTimeout(argv, {
172
+ timeoutMs: 5000,
173
+ });
174
+ if (result.code !== 0) {
175
+ const err = (result.stderr ?? result.stdout ?? "").trim();
176
+ throw new Error(err ? `tmux capture failed: ${err}` : "tmux capture failed");
177
+ }
178
+ let output = result.stdout ?? "";
179
+ output = output.replace(/\r\n/g, "\n").trimEnd();
180
+ return output;
181
+ } catch (err) {
182
+ const message = err instanceof Error ? err.message : String(err);
183
+ throw new Error(`tmux capture failed: ${message}`);
184
+ }
185
+ }
186
+
187
+ type RenderImageParams = {
188
+ api: OpenClawPluginApi;
189
+ output: string;
190
+ imageFormat: ImageFormat;
191
+ outputPath?: string;
192
+ ttlSeconds: number;
193
+ };
194
+
195
+ type RenderImageResult = {
196
+ path: string;
197
+ tool: ToolId;
198
+ temporary: boolean;
199
+ };
200
+
201
+ async function renderImage(params: RenderImageParams): Promise<RenderImageResult> {
202
+ const tool = await resolveRenderTool(params.api);
203
+ const outputPath = await resolveOutputPath(params.outputPath, params.imageFormat);
204
+ const tempDir = await ensureTempDir();
205
+ await cleanupTempDir(tempDir, params.ttlSeconds * 1000);
206
+
207
+ const inputPath = await writeTempInput(params.output, tempDir);
208
+ try {
209
+ if (tool.id === "cryosnap") {
210
+ await runCryosnap(params.api, tool.path, inputPath, outputPath);
211
+ } else {
212
+ await runFreeze(params.api, tool.path, inputPath, outputPath);
213
+ }
214
+ } finally {
215
+ await fs.rm(inputPath, { force: true });
216
+ }
217
+
218
+ if (!params.outputPath && params.ttlSeconds > 0) {
219
+ scheduleCleanup(outputPath, params.ttlSeconds * 1000);
220
+ }
221
+
222
+ return { path: outputPath, tool: tool.id, temporary: !params.outputPath };
223
+ }
224
+
225
+ async function runCryosnap(
226
+ api: OpenClawPluginApi,
227
+ toolPath: string,
228
+ inputPath: string,
229
+ outputPath: string,
230
+ ): Promise<void> {
231
+ const argv = [toolPath, "--language", "ansi", "-o", outputPath, inputPath];
232
+ const result = await api.runtime.system.runCommandWithTimeout(argv, {
233
+ timeoutMs: 120_000,
234
+ });
235
+ if (result.code !== 0) {
236
+ const err = (result.stderr ?? result.stdout ?? "").trim();
237
+ throw new Error(err ? `cryosnap failed: ${err}` : "cryosnap failed");
238
+ }
239
+ }
240
+
241
+ async function runFreeze(
242
+ api: OpenClawPluginApi,
243
+ toolPath: string,
244
+ inputPath: string,
245
+ outputPath: string,
246
+ ): Promise<void> {
247
+ const cmd = `cat -- ${shellQuote(inputPath)} | ${shellQuote(toolPath)} -o ${shellQuote(outputPath)}`;
248
+ const result = await api.runtime.system.runCommandWithTimeout(["bash", "-lc", cmd], {
249
+ timeoutMs: 120_000,
250
+ });
251
+ if (result.code !== 0) {
252
+ const err = (result.stderr ?? result.stdout ?? "").trim();
253
+ throw new Error(err ? `freeze failed: ${err}` : "freeze failed");
254
+ }
255
+ }
256
+
257
+ async function resolveRenderTool(api: OpenClawPluginApi): Promise<ToolLocation> {
258
+ const cryosnap = await resolveToolBinary(api, "cryosnap");
259
+ if (cryosnap) {
260
+ return cryosnap;
261
+ }
262
+ const freeze = await resolveToolBinary(api, "freeze");
263
+ if (freeze) {
264
+ return freeze;
265
+ }
266
+ throw new Error(
267
+ "No screenshot tool found. Install with `openclaw tmux-watch install cryosnap` or `openclaw tmux-watch install freeze`.",
268
+ );
269
+ }
270
+
271
+ async function resolveToolBinary(api: OpenClawPluginApi, id: ToolId): Promise<ToolLocation | null> {
272
+ const binaryName = resolveBinaryName(id);
273
+ const fromPath = await findInPath(binaryName);
274
+ if (fromPath) {
275
+ return { id, path: fromPath, source: "path" };
276
+ }
277
+
278
+ const home = os.homedir();
279
+ const userBins = [path.join(home, ".local", "bin"), path.join(home, "bin")];
280
+ for (const dir of userBins) {
281
+ const found = await findInDir(dir, binaryName);
282
+ if (found) {
283
+ return { id, path: found, source: "user-bin" };
284
+ }
285
+ }
286
+
287
+ const toolsDir = resolveToolsDir(api);
288
+ const openclawPath = await findInDir(toolsDir, binaryName);
289
+ if (openclawPath) {
290
+ return { id, path: openclawPath, source: "openclaw" };
291
+ }
292
+
293
+ return null;
294
+ }
295
+
296
+ async function findInPath(binaryName: string): Promise<string | null> {
297
+ const raw = process.env.PATH ?? "";
298
+ const paths = raw.split(path.delimiter).filter(Boolean);
299
+ for (const dir of paths) {
300
+ const found = await findInDir(dir, binaryName);
301
+ if (found) {
302
+ return found;
303
+ }
304
+ }
305
+ return null;
306
+ }
307
+
308
+ async function findInDir(dir: string, binaryName: string): Promise<string | null> {
309
+ if (!dir) {
310
+ return null;
311
+ }
312
+ const candidate = path.join(dir, binaryName);
313
+ if (await isExecutable(candidate)) {
314
+ return candidate;
315
+ }
316
+ return null;
317
+ }
318
+
319
+ async function isExecutable(candidate: string): Promise<boolean> {
320
+ try {
321
+ const stat = await fs.stat(candidate);
322
+ if (!stat.isFile()) {
323
+ return false;
324
+ }
325
+ if (process.platform === "win32") {
326
+ return true;
327
+ }
328
+ await fs.access(candidate, fsConstants.X_OK);
329
+ return true;
330
+ } catch {
331
+ return false;
332
+ }
333
+ }
334
+
335
+ function resolveCaptureLines(raw: unknown, config: TmuxWatchConfig): number {
336
+ if (typeof raw === "number" && Number.isFinite(raw)) {
337
+ return Math.max(10, Math.trunc(raw));
338
+ }
339
+ return Math.max(10, Math.trunc(config.captureLines));
340
+ }
341
+
342
+ function resolveStripAnsi(raw: unknown, config: TmuxWatchConfig): boolean {
343
+ return typeof raw === "boolean" ? raw : config.stripAnsi;
344
+ }
345
+
346
+ function resolveMaxChars(raw: unknown, config: TmuxWatchConfig): number {
347
+ if (typeof raw === "number" && Number.isFinite(raw)) {
348
+ return Math.max(0, Math.trunc(raw));
349
+ }
350
+ return Math.max(0, Math.trunc(config.maxOutputChars));
351
+ }
352
+
353
+ function resolveTtlSeconds(raw: unknown): number {
354
+ if (typeof raw === "number" && Number.isFinite(raw)) {
355
+ return Math.max(1, Math.trunc(raw));
356
+ }
357
+ if (typeof raw === "string" && raw.trim()) {
358
+ const parsed = Number(raw);
359
+ if (Number.isFinite(parsed)) {
360
+ return Math.max(1, Math.trunc(parsed));
361
+ }
362
+ }
363
+ return DEFAULT_TTL_SECONDS;
364
+ }
365
+
366
+ function normalizeSocket(raw: string | undefined): string | undefined {
367
+ if (!raw) {
368
+ return undefined;
369
+ }
370
+ const trimmed = raw.trim();
371
+ if (!trimmed) {
372
+ return undefined;
373
+ }
374
+ const comma = trimmed.indexOf(",");
375
+ if (comma > 0) {
376
+ return trimmed.slice(0, comma);
377
+ }
378
+ return trimmed;
379
+ }
380
+
381
+ async function ensureTempDir(): Promise<string> {
382
+ const dir = path.join(os.tmpdir(), TEMP_DIR_NAME);
383
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
384
+ return dir;
385
+ }
386
+
387
+ async function resolveOutputPath(outputPath: string | undefined, format: ImageFormat): Promise<string> {
388
+ const trimmed = outputPath?.trim();
389
+ if (!trimmed) {
390
+ const dir = await ensureTempDir();
391
+ return path.join(dir, `tmux-watch-${Date.now()}-${randomUUID()}.${format}`);
392
+ }
393
+ const ext = path.extname(trimmed);
394
+ const finalPath = ext ? trimmed : `${trimmed}.${format}`;
395
+ await fs.mkdir(path.dirname(finalPath), { recursive: true });
396
+ return finalPath;
397
+ }
398
+
399
+ async function writeTempInput(content: string, dir: string): Promise<string> {
400
+ const filePath = path.join(dir, `tmux-watch-input-${Date.now()}-${randomUUID()}.ansi`);
401
+ await fs.writeFile(filePath, content);
402
+ return filePath;
403
+ }
404
+
405
+ async function cleanupTempDir(dir: string, ttlMs: number): Promise<void> {
406
+ if (ttlMs <= 0) {
407
+ return;
408
+ }
409
+ try {
410
+ const entries = await fs.readdir(dir, { withFileTypes: true });
411
+ const now = Date.now();
412
+ await Promise.all(
413
+ entries.map(async (entry) => {
414
+ if (!entry.isFile()) {
415
+ return;
416
+ }
417
+ const fullPath = path.join(dir, entry.name);
418
+ try {
419
+ const stat = await fs.stat(fullPath);
420
+ if (now - stat.mtimeMs > ttlMs) {
421
+ await fs.rm(fullPath, { force: true });
422
+ }
423
+ } catch {
424
+ // ignore
425
+ }
426
+ }),
427
+ );
428
+ } catch {
429
+ // ignore
430
+ }
431
+ }
432
+
433
+ function scheduleCleanup(filePath: string, ttlMs: number): void {
434
+ if (ttlMs <= 0) {
435
+ return;
436
+ }
437
+ const timer = setTimeout(() => {
438
+ void fs.rm(filePath, { force: true });
439
+ }, ttlMs);
440
+ timer.unref?.();
441
+ }
442
+
443
+ function shellQuote(input: string): string {
444
+ return `'${input.replace(/'/g, `'"'"'`)}'`;
445
+ }
package/src/cli.ts CHANGED
@@ -2,6 +2,7 @@ import type { Command } from "commander";
2
2
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
3
3
  import readline from "node:readline/promises";
4
4
  import { stdin as input, stdout as output } from "node:process";
5
+ import { createTmuxWatchManager } from "./manager.js";
5
6
  import { installTool, removeTool, type ToolId } from "./tool-install.js";
6
7
 
7
8
  type Logger = {
@@ -66,6 +67,32 @@ function normalizeToolId(raw: string): ToolId {
66
67
  return normalized as ToolId;
67
68
  }
68
69
 
70
+ function normalizeDelayMs(raw: unknown, fallback: number): number {
71
+ if (typeof raw === "number" && Number.isFinite(raw)) {
72
+ return Math.max(0, Math.trunc(raw));
73
+ }
74
+ if (typeof raw === "string" && raw.trim()) {
75
+ const parsed = Number(raw);
76
+ if (Number.isFinite(parsed)) {
77
+ return Math.max(0, Math.trunc(parsed));
78
+ }
79
+ }
80
+ return fallback;
81
+ }
82
+
83
+ function normalizeNumber(raw: unknown): number | undefined {
84
+ if (typeof raw === "number" && Number.isFinite(raw)) {
85
+ return Math.trunc(raw);
86
+ }
87
+ if (typeof raw === "string" && raw.trim()) {
88
+ const parsed = Number(raw);
89
+ if (Number.isFinite(parsed)) {
90
+ return Math.trunc(parsed);
91
+ }
92
+ }
93
+ return undefined;
94
+ }
95
+
69
96
  export function registerTmuxWatchCli(params: {
70
97
  program: Command;
71
98
  api: OpenClawPluginApi;
@@ -127,6 +154,144 @@ export function registerTmuxWatchCli(params: {
127
154
  printSocketHelp(logger);
128
155
  });
129
156
 
157
+ root
158
+ .command("send")
159
+ .description("Send text to a tmux target (text then Enter)")
160
+ .argument("[target]", "tmux target (session:window.pane or %pane_id)")
161
+ .argument("[text...]", "text to send (can be multiple words)")
162
+ .option("--target <target>", "tmux target (overrides positional)")
163
+ .option("--text <text>", "text to send (overrides positional)")
164
+ .option("--socket <path>", "tmux socket path (or full $TMUX value)")
165
+ .option("--delay-ms <ms>", "delay between text and Enter (default: 20)", "20")
166
+ .option("--no-enter", "do not send Enter after text")
167
+ .action(
168
+ async (
169
+ targetArg: string | undefined,
170
+ textArgs: string[] | undefined,
171
+ options: {
172
+ target?: string;
173
+ text?: string;
174
+ socket?: string;
175
+ delayMs?: string;
176
+ enter?: boolean;
177
+ },
178
+ ) => {
179
+ const target = (options.target ?? targetArg ?? "").trim();
180
+ const textFromArgs =
181
+ Array.isArray(textArgs) && textArgs.length > 0 ? textArgs.join(" ") : "";
182
+ const text = (options.text ?? textFromArgs ?? "").trim();
183
+ if (!target || !text) {
184
+ logger.error("Usage: openclaw tmux-watch send <target> <text> [--no-enter]");
185
+ process.exitCode = 1;
186
+ return;
187
+ }
188
+
189
+ const socket = options.socket
190
+ ? extractSocket(options.socket)
191
+ : resolveSocketFromEnv();
192
+ const delayMs = normalizeDelayMs(options.delayMs, 20);
193
+ const enter = options.enter !== false;
194
+
195
+ const argvBase = socket ? ["tmux", "-S", socket] : ["tmux"];
196
+ const sendText = [...argvBase, "send-keys", "-t", target, "-l", "--", text];
197
+ const res = await api.runtime.system.runCommandWithTimeout(sendText, {
198
+ timeoutMs: 5000,
199
+ });
200
+ if (res.code !== 0) {
201
+ const err = (res.stderr ?? res.stdout ?? "").trim();
202
+ logger.error(err ? `tmux send-keys failed: ${err}` : "tmux send-keys failed");
203
+ process.exitCode = 1;
204
+ return;
205
+ }
206
+
207
+ if (enter) {
208
+ if (delayMs > 0) {
209
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
210
+ }
211
+ const sendEnter = [...argvBase, "send-keys", "-t", target, "C-m"];
212
+ const resEnter = await api.runtime.system.runCommandWithTimeout(sendEnter, {
213
+ timeoutMs: 2000,
214
+ });
215
+ if (resEnter.code !== 0) {
216
+ const err = (resEnter.stderr ?? resEnter.stdout ?? "").trim();
217
+ logger.error(err ? `tmux send-keys Enter failed: ${err}` : "tmux send-keys Enter failed");
218
+ process.exitCode = 1;
219
+ return;
220
+ }
221
+ }
222
+
223
+ logger.info(`Sent to ${target}${enter ? " (Enter)" : ""}.`);
224
+ },
225
+ );
226
+
227
+ root
228
+ .command("capture")
229
+ .description("Capture tmux output as text/image")
230
+ .argument("[target]", "tmux target (session:window.pane or %pane_id)")
231
+ .option("--target <target>", "tmux target (overrides positional)")
232
+ .option("--socket <path>", "tmux socket path (or full $TMUX value)")
233
+ .option("--lines <n>", "lines to capture (default: config)")
234
+ .option("--strip-ansi", "strip ANSI for text output")
235
+ .option("--keep-ansi", "keep ANSI in text output")
236
+ .option("--format <format>", "text | image | both")
237
+ .option("--image-format <format>", "png | svg | webp")
238
+ .option("--output <path>", "image output path (optional)")
239
+ .option("--base64", "include base64 for image output")
240
+ .option("--ttl-seconds <n>", "temporary image TTL in seconds (default: 600)")
241
+ .option("--max-chars <n>", "max characters for text output (default: config)")
242
+ .action(
243
+ async (
244
+ targetArg: string | undefined,
245
+ options: {
246
+ target?: string;
247
+ socket?: string;
248
+ lines?: string;
249
+ stripAnsi?: boolean;
250
+ keepAnsi?: boolean;
251
+ format?: string;
252
+ imageFormat?: string;
253
+ output?: string;
254
+ base64?: boolean;
255
+ ttlSeconds?: string;
256
+ maxChars?: string;
257
+ },
258
+ ) => {
259
+ const target = (options.target ?? targetArg ?? "").trim();
260
+ if (!target) {
261
+ logger.error("Usage: openclaw tmux-watch capture <target> [options]");
262
+ process.exitCode = 1;
263
+ return;
264
+ }
265
+
266
+ let stripAnsi: boolean | undefined;
267
+ if (options.stripAnsi) {
268
+ stripAnsi = true;
269
+ } else if (options.keepAnsi) {
270
+ stripAnsi = false;
271
+ }
272
+
273
+ try {
274
+ const manager = createTmuxWatchManager(api);
275
+ const result = await manager.capture({
276
+ target,
277
+ socket: options.socket ? extractSocket(options.socket) : undefined,
278
+ captureLines: normalizeNumber(options.lines),
279
+ stripAnsi,
280
+ format: options.format,
281
+ imageFormat: options.imageFormat,
282
+ outputPath: options.output,
283
+ base64: options.base64,
284
+ ttlSeconds: normalizeNumber(options.ttlSeconds),
285
+ maxChars: normalizeNumber(options.maxChars),
286
+ });
287
+ process.stdout.write(`${JSON.stringify({ ok: true, capture: result }, null, 2)}\n`);
288
+ } catch (err) {
289
+ logger.error(err instanceof Error ? err.message : String(err));
290
+ process.exitCode = 1;
291
+ }
292
+ },
293
+ );
294
+
130
295
  root
131
296
  .command("install")
132
297
  .description("Install cryosnap or freeze into the OpenClaw tools directory")
package/src/manager.ts CHANGED
@@ -2,6 +2,8 @@ import type { OpenClawPluginApi, OpenClawPluginServiceContext } from "openclaw/p
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
+ import { captureTmux, type CaptureParams, type CaptureResult } from "./capture.js";
6
+ import { stripAnsi, truncateOutput } from "./text-utils.js";
5
7
  import {
6
8
  DEFAULT_CAPTURE_INTERVAL_SECONDS,
7
9
  DEFAULT_STABLE_COUNT,
@@ -154,6 +156,14 @@ export class TmuxWatchManager {
154
156
  return items;
155
157
  }
156
158
 
159
+ async capture(params: CaptureParams): Promise<CaptureResult> {
160
+ await this.ensureTmuxAvailable();
161
+ if (!this.tmuxAvailable) {
162
+ throw new Error("tmux not available");
163
+ }
164
+ return captureTmux({ api: this.api, config: this.config, ...params });
165
+ }
166
+
157
167
  async addSubscription(input: Partial<TmuxWatchSubscription> & { target: string }) {
158
168
  await this.ensureLoaded();
159
169
  const id = input.id?.trim() || randomUUID();
@@ -848,28 +858,20 @@ export function resolveLastTargetsFromStore(params: {
848
858
  if (!primary) {
849
859
  return [];
850
860
  }
851
- const targets: ResolvedTarget[] = [toResolvedTarget(primary, "last")];
852
- if (isInternalLastChannel(primary.channel)) {
853
- const fallback = findLatestExternalTarget(params.store, primary);
854
- if (fallback) {
855
- targets.push(toResolvedTarget(fallback, "last-fallback"));
856
- }
861
+ if (!isInternalLastChannel(primary.channel)) {
862
+ return [toResolvedTarget(primary, "last")];
863
+ }
864
+ const fallback = findLatestExternalTarget(params.store, primary);
865
+ if (fallback) {
866
+ return [toResolvedTarget(fallback, "last-fallback")];
857
867
  }
858
- return targets;
868
+ return [toResolvedTarget(primary, "last")];
859
869
  }
860
870
 
861
871
  function hashOutput(output: string): string {
862
872
  return createHash("sha256").update(output).digest("hex");
863
873
  }
864
874
 
865
- export function stripAnsi(input: string): string {
866
- /* eslint-disable no-control-regex */
867
- const sgr = new RegExp("\\u001b\\[[0-9;]*m", "g");
868
- const osc8 = new RegExp("\\u001b]8;;.*?\\u001b\\\\|\\u001b]8;;\\u001b\\\\", "g");
869
- /* eslint-enable no-control-regex */
870
- return input.replace(osc8, "").replace(sgr, "");
871
- }
872
-
873
875
  function parseThreadId(value: unknown): string | number | undefined {
874
876
  if (typeof value === "number" && Number.isFinite(value)) {
875
877
  return Math.trunc(value);
@@ -887,24 +889,7 @@ function parseThreadId(value: unknown): string | number | undefined {
887
889
  return trimmed;
888
890
  }
889
891
 
890
- export function truncateOutput(
891
- text: string,
892
- maxChars: number,
893
- ): { text: string; truncated: boolean } {
894
- if (!text) {
895
- return { text: "", truncated: false };
896
- }
897
- if (text.length <= maxChars) {
898
- return { text, truncated: false };
899
- }
900
- let tail = text.slice(-maxChars);
901
- const firstNewline = tail.indexOf("\n");
902
- if (firstNewline > 0 && firstNewline < tail.length - 1) {
903
- tail = tail.slice(firstNewline + 1);
904
- }
905
- tail = tail.trimStart();
906
- return { text: `...[truncated]\n${tail}`, truncated: true };
907
- }
892
+ export { stripAnsi, truncateOutput };
908
893
 
909
894
  function dedupeTargets(targets: ResolvedTarget[]): ResolvedTarget[] {
910
895
  const seen = new Set<string>();
@@ -0,0 +1,26 @@
1
+ export function stripAnsi(input: string): string {
2
+ /* eslint-disable no-control-regex */
3
+ const sgr = new RegExp("\\u001b\\[[0-9;]*m", "g");
4
+ const osc8 = new RegExp("\\u001b]8;;.*?\\u001b\\\\|\\u001b]8;;\\u001b\\\\", "g");
5
+ /* eslint-enable no-control-regex */
6
+ return input.replace(osc8, "").replace(sgr, "");
7
+ }
8
+
9
+ export function truncateOutput(
10
+ text: string,
11
+ maxChars: number,
12
+ ): { text: string; truncated: boolean } {
13
+ if (!text) {
14
+ return { text: "", truncated: false };
15
+ }
16
+ if (text.length <= maxChars) {
17
+ return { text, truncated: false };
18
+ }
19
+ let tail = text.slice(-maxChars);
20
+ const firstNewline = tail.indexOf("\n");
21
+ if (firstNewline > 0 && firstNewline < tail.length - 1) {
22
+ tail = tail.slice(firstNewline + 1);
23
+ }
24
+ tail = tail.trimStart();
25
+ return { text: `...[truncated]\n${tail}`, truncated: true };
26
+ }
@@ -1,7 +1,7 @@
1
1
  import type { NotifyMode, NotifyTarget } from "./config.js";
2
2
  import type { TmuxWatchManager, TmuxWatchSubscription } from "./manager.js";
3
3
 
4
- const ACTIONS = ["add", "remove", "list"] as const;
4
+ const ACTIONS = ["add", "remove", "list", "capture"] as const;
5
5
  const NOTIFY_MODES = ["last", "targets", "targets+last"] as const;
6
6
 
7
7
  type ToolParams = {
@@ -18,6 +18,12 @@ type ToolParams = {
18
18
  stableSeconds?: number;
19
19
  captureLines?: number;
20
20
  stripAnsi?: boolean;
21
+ format?: string;
22
+ imageFormat?: string;
23
+ outputPath?: string;
24
+ base64?: boolean;
25
+ ttlSeconds?: number;
26
+ maxChars?: number;
21
27
  enabled?: boolean;
22
28
  notifyMode?: NotifyMode;
23
29
  targets?: NotifyTarget[];
@@ -70,7 +76,7 @@ export function createTmuxWatchTool(manager: TmuxWatchManager) {
70
76
  return {
71
77
  name: "tmux-watch",
72
78
  description:
73
- "Manage tmux-watch subscriptions (add/remove/list) that monitor tmux pane output.",
79
+ "Manage tmux-watch subscriptions (add/remove/list) or capture tmux output.",
74
80
  parameters: {
75
81
  type: "object",
76
82
  additionalProperties: false,
@@ -95,6 +101,15 @@ export function createTmuxWatchTool(manager: TmuxWatchManager) {
95
101
  stableSeconds: { type: "number", description: "Legacy: stable duration in seconds." },
96
102
  captureLines: { type: "number", description: "Lines to capture." },
97
103
  stripAnsi: { type: "boolean", description: "Strip ANSI escape codes." },
104
+ format: { type: "string", description: "Capture format: text, image, or both." },
105
+ imageFormat: { type: "string", description: "Image format: png, svg, webp." },
106
+ outputPath: { type: "string", description: "Image output path (optional)." },
107
+ base64: { type: "boolean", description: "Include base64 image output." },
108
+ ttlSeconds: {
109
+ type: "number",
110
+ description: "Temporary image TTL in seconds (default 600).",
111
+ },
112
+ maxChars: { type: "number", description: "Max characters for text output." },
98
113
  enabled: { type: "boolean", description: "Enable or disable subscription." },
99
114
  notifyMode: {
100
115
  type: "string",
@@ -173,6 +188,27 @@ export function createTmuxWatchTool(manager: TmuxWatchManager) {
173
188
  });
174
189
  return jsonResult({ ok: true, subscriptions: items });
175
190
  }
191
+ case "capture": {
192
+ const target = readString(params.target);
193
+ if (!target) {
194
+ throw new Error("target required for capture action");
195
+ }
196
+ const result = await manager.capture({
197
+ target,
198
+ socket: readString(params.socket),
199
+ captureLines:
200
+ typeof params.captureLines === "number" ? params.captureLines : undefined,
201
+ stripAnsi: typeof params.stripAnsi === "boolean" ? params.stripAnsi : undefined,
202
+ format: readString(params.format),
203
+ imageFormat: readString(params.imageFormat),
204
+ outputPath: readString(params.outputPath),
205
+ base64: typeof params.base64 === "boolean" ? params.base64 : undefined,
206
+ ttlSeconds:
207
+ typeof params.ttlSeconds === "number" ? params.ttlSeconds : undefined,
208
+ maxChars: typeof params.maxChars === "number" ? params.maxChars : undefined,
209
+ });
210
+ return jsonResult({ ok: true, capture: result });
211
+ }
176
212
  default: {
177
213
  params.action satisfies never;
178
214
  throw new Error(`Unknown action: ${String(params.action)}`);
@@ -151,7 +151,7 @@ export async function removeTool(params: {
151
151
  return { tool: spec.id, path: destPath, removed: true };
152
152
  }
153
153
 
154
- function resolveToolsDir(api: OpenClawPluginApi): string {
154
+ export function resolveToolsDir(api: OpenClawPluginApi): string {
155
155
  const stateDir = resolveStateDir(api);
156
156
  return path.join(stateDir, "tools");
157
157
  }
@@ -164,7 +164,7 @@ function resolveStateDir(api: OpenClawPluginApi): string {
164
164
  return path.join(os.homedir(), ".openclaw");
165
165
  }
166
166
 
167
- function resolveBinaryName(base: string): string {
167
+ export function resolveBinaryName(base: string): string {
168
168
  if (process.platform === "win32") {
169
169
  return `${base}.exe`;
170
170
  }