tmux-watch 2026.2.5 → 2026.2.7
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 +81 -0
- package/package.json +1 -1
- package/skills/SKILL.md +46 -27
- package/src/capture.ts +445 -0
- package/src/cli.ts +165 -0
- package/src/manager.ts +18 -33
- package/src/text-utils.ts +26 -0
- package/src/tmux-watch-tool.ts +38 -2
- package/src/tool-install.ts +2 -2
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
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 `
|
|
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
|
|
52
|
-
tmux send
|
|
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,54 @@ 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
|
|
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
|
+
|
|
68
|
+
Default behavior when the user asks for a screenshot:
|
|
69
|
+
|
|
70
|
+
- Use `format=image` by default.
|
|
71
|
+
- Only use `format=text` if the user explicitly asks for text output.
|
|
72
|
+
- Do not return only a file path. **After capturing an image, immediately send the image via the
|
|
73
|
+
same channel used by the user.**
|
|
67
74
|
|
|
68
75
|
Priority detection order:
|
|
69
76
|
|
|
70
|
-
1. System-level PATH (`
|
|
77
|
+
1. System-level PATH (`cryosnap` / `freeze`)
|
|
71
78
|
2. User-level bins (`~/.local/bin`, `~/bin`)
|
|
72
79
|
3. OpenClaw tools dir (`$OPENCLAW_STATE_DIR/tools`, default `~/.openclaw/tools`)
|
|
73
80
|
|
|
74
|
-
If
|
|
75
|
-
|
|
76
|
-
Install commands (downloads the latest GitHub release into the OpenClaw tools dir):
|
|
81
|
+
If neither tool exists, return an error and ask the user to install one:
|
|
77
82
|
|
|
78
83
|
```bash
|
|
79
84
|
openclaw tmux-watch install cryosnap
|
|
80
85
|
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
86
|
```
|
|
86
87
|
|
|
87
|
-
|
|
88
|
+
Examples:
|
|
88
89
|
|
|
89
90
|
```bash
|
|
90
|
-
#
|
|
91
|
-
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
Notes:
|
|
91
|
+
# Text only (uses plugin defaults for lines/strip)
|
|
92
|
+
openclaw tmux-watch capture <session:window.pane>
|
|
95
93
|
|
|
96
|
-
|
|
97
|
-
-
|
|
94
|
+
# Image only (temporary file, auto-cleaned after 10 minutes)
|
|
95
|
+
openclaw tmux-watch capture <session:window.pane> --format image
|
|
98
96
|
|
|
99
|
-
|
|
97
|
+
# Both text + image, include base64 (optional)
|
|
98
|
+
openclaw tmux-watch capture <session:window.pane> --format both --base64
|
|
100
99
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
tmux capture-pane -p -J -e -t <session:window.pane> -S -200 | freeze -o out.png
|
|
100
|
+
# Persist image to a path (no TTL cleanup)
|
|
101
|
+
openclaw tmux-watch capture <session:window.pane> --format image --output /tmp/pane.png
|
|
104
102
|
```
|
|
105
103
|
|
|
104
|
+
Notes:
|
|
105
|
+
|
|
106
|
+
- Temporary images default to a 10-minute TTL. Override with `--ttl-seconds`.
|
|
107
|
+
- Use `--image-format png|svg|webp` to select output format.
|
|
108
|
+
- If an image is captured, send the image immediately using the channel’s image-send capability
|
|
109
|
+
(not just a path).
|
|
110
|
+
|
|
106
111
|
## Tool: tmux-watch
|
|
107
112
|
|
|
108
113
|
### Add subscription
|
|
@@ -147,6 +152,20 @@ Optional routing overrides:
|
|
|
147
152
|
{ "action": "list", "includeOutput": true }
|
|
148
153
|
```
|
|
149
154
|
|
|
155
|
+
### Capture once
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{
|
|
159
|
+
"action": "capture",
|
|
160
|
+
"target": "session:0.0",
|
|
161
|
+
"format": "both",
|
|
162
|
+
"captureLines": 200,
|
|
163
|
+
"stripAnsi": true,
|
|
164
|
+
"imageFormat": "png",
|
|
165
|
+
"base64": false
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
150
169
|
## Handling tmux-watch events
|
|
151
170
|
|
|
152
171
|
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
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/tmux-watch-tool.ts
CHANGED
|
@@ -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)
|
|
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)}`);
|
package/src/tool-install.ts
CHANGED
|
@@ -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
|
}
|