tmux-watch 2026.2.4 → 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 +81 -0
- package/package.json +1 -1
- package/skills/SKILL.md +37 -27
- package/src/capture.ts +445 -0
- package/src/cli.ts +165 -0
- package/src/manager.ts +150 -76
- 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,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
|
|
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 (`
|
|
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
|
|
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
|
-
|
|
81
|
+
Examples:
|
|
88
82
|
|
|
89
83
|
```bash
|
|
90
|
-
#
|
|
91
|
-
|
|
92
|
-
```
|
|
84
|
+
# Text only (uses plugin defaults for lines/strip)
|
|
85
|
+
openclaw tmux-watch capture <session:window.pane>
|
|
93
86
|
|
|
94
|
-
|
|
87
|
+
# Image only (temporary file, auto-cleaned after 10 minutes)
|
|
88
|
+
openclaw tmux-watch capture <session:window.pane> --format image
|
|
95
89
|
|
|
96
|
-
|
|
97
|
-
-
|
|
90
|
+
# Both text + image, include base64 (optional)
|
|
91
|
+
openclaw tmux-watch capture <session:window.pane> --format both --base64
|
|
98
92
|
|
|
99
|
-
|
|
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,
|
|
@@ -53,22 +55,23 @@ type WatchEntry = {
|
|
|
53
55
|
runtime: WatchRuntime;
|
|
54
56
|
};
|
|
55
57
|
|
|
56
|
-
type ResolvedTarget = {
|
|
58
|
+
export type ResolvedTarget = {
|
|
57
59
|
channel: string;
|
|
58
60
|
target: string;
|
|
59
61
|
accountId?: string;
|
|
60
62
|
threadId?: string | number;
|
|
61
63
|
label?: string;
|
|
62
|
-
source: "targets" | "last";
|
|
64
|
+
source: "targets" | "last" | "last-fallback";
|
|
63
65
|
};
|
|
64
66
|
|
|
65
|
-
type SessionEntryLike = {
|
|
67
|
+
export type SessionEntryLike = {
|
|
66
68
|
deliveryContext?: {
|
|
67
69
|
channel?: string;
|
|
68
70
|
to?: string;
|
|
69
71
|
accountId?: string;
|
|
70
72
|
threadId?: string | number;
|
|
71
73
|
};
|
|
74
|
+
updatedAt?: number;
|
|
72
75
|
lastChannel?: string;
|
|
73
76
|
lastTo?: string;
|
|
74
77
|
lastAccountId?: string;
|
|
@@ -83,6 +86,7 @@ type MinimalConfig = {
|
|
|
83
86
|
};
|
|
84
87
|
|
|
85
88
|
const STATE_VERSION = 1;
|
|
89
|
+
const INTERNAL_LAST_CHANNELS = new Set(["webchat", "tui"]);
|
|
86
90
|
|
|
87
91
|
export class TmuxWatchManager {
|
|
88
92
|
private readonly api: OpenClawPluginApi;
|
|
@@ -152,6 +156,14 @@ export class TmuxWatchManager {
|
|
|
152
156
|
return items;
|
|
153
157
|
}
|
|
154
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
|
+
|
|
155
167
|
async addSubscription(input: Partial<TmuxWatchSubscription> & { target: string }) {
|
|
156
168
|
await this.ensureLoaded();
|
|
157
169
|
const id = input.id?.trim() || randomUUID();
|
|
@@ -455,60 +467,26 @@ export class TmuxWatchManager {
|
|
|
455
467
|
}
|
|
456
468
|
|
|
457
469
|
if (includeLast) {
|
|
458
|
-
const
|
|
459
|
-
if (
|
|
460
|
-
targets.push(
|
|
461
|
-
...last,
|
|
462
|
-
source: "last",
|
|
463
|
-
});
|
|
470
|
+
const lastTargets = await this.resolveLastTargets(sessionKey);
|
|
471
|
+
if (lastTargets.length > 0) {
|
|
472
|
+
targets.push(...lastTargets);
|
|
464
473
|
}
|
|
465
474
|
}
|
|
466
475
|
|
|
467
476
|
return dedupeTargets(targets);
|
|
468
477
|
}
|
|
469
478
|
|
|
470
|
-
private async
|
|
471
|
-
const
|
|
472
|
-
if (!
|
|
473
|
-
return
|
|
474
|
-
}
|
|
475
|
-
const delivery = entry.deliveryContext ?? {};
|
|
476
|
-
const channel =
|
|
477
|
-
typeof delivery.channel === "string"
|
|
478
|
-
? delivery.channel.trim()
|
|
479
|
-
: typeof entry.lastChannel === "string"
|
|
480
|
-
? entry.lastChannel.trim()
|
|
481
|
-
: typeof entry.channel === "string"
|
|
482
|
-
? entry.channel.trim()
|
|
483
|
-
: undefined;
|
|
484
|
-
const target =
|
|
485
|
-
typeof delivery.to === "string"
|
|
486
|
-
? delivery.to.trim()
|
|
487
|
-
: typeof entry.lastTo === "string"
|
|
488
|
-
? entry.lastTo.trim()
|
|
489
|
-
: undefined;
|
|
490
|
-
if (!channel || !target) {
|
|
491
|
-
return null;
|
|
479
|
+
private async resolveLastTargets(sessionKey: string): Promise<ResolvedTarget[]> {
|
|
480
|
+
const store = await this.readSessionStore(sessionKey);
|
|
481
|
+
if (!store) {
|
|
482
|
+
return [];
|
|
492
483
|
}
|
|
493
|
-
|
|
494
|
-
typeof delivery.accountId === "string"
|
|
495
|
-
? delivery.accountId.trim()
|
|
496
|
-
: typeof entry.lastAccountId === "string"
|
|
497
|
-
? entry.lastAccountId.trim()
|
|
498
|
-
: undefined;
|
|
499
|
-
const threadId =
|
|
500
|
-
delivery.threadId ?? entry.lastThreadId ?? entry.origin?.threadId ?? undefined;
|
|
501
|
-
return {
|
|
502
|
-
channel,
|
|
503
|
-
target,
|
|
504
|
-
accountId: accountId || undefined,
|
|
505
|
-
threadId: parseThreadId(threadId),
|
|
506
|
-
label: undefined,
|
|
507
|
-
source: "last",
|
|
508
|
-
};
|
|
484
|
+
return resolveLastTargetsFromStore({ store, sessionKey });
|
|
509
485
|
}
|
|
510
486
|
|
|
511
|
-
private async
|
|
487
|
+
private async readSessionStore(
|
|
488
|
+
sessionKey: string,
|
|
489
|
+
): Promise<Record<string, SessionEntryLike> | null> {
|
|
512
490
|
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
|
513
491
|
const storePath = this.api.runtime.channel.session.resolveStorePath(
|
|
514
492
|
this.api.config.session?.store,
|
|
@@ -520,8 +498,10 @@ export class TmuxWatchManager {
|
|
|
520
498
|
if (!store || typeof store !== "object") {
|
|
521
499
|
return null;
|
|
522
500
|
}
|
|
523
|
-
return store
|
|
524
|
-
} catch {
|
|
501
|
+
return store;
|
|
502
|
+
} catch (err) {
|
|
503
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
504
|
+
this.api.logger.warn(`[tmux-watch] session store read failed: ${message}`);
|
|
525
505
|
return null;
|
|
526
506
|
}
|
|
527
507
|
}
|
|
@@ -769,16 +749,127 @@ function normalizeSessionKey(input: string | undefined, cfg?: MinimalConfig) {
|
|
|
769
749
|
return `agent:${normalizeAgentId(agentId)}:${lowered}`;
|
|
770
750
|
}
|
|
771
751
|
|
|
772
|
-
|
|
773
|
-
|
|
752
|
+
type TargetSnapshot = {
|
|
753
|
+
channel: string;
|
|
754
|
+
target: string;
|
|
755
|
+
accountId?: string;
|
|
756
|
+
threadId?: string | number;
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
function isInternalLastChannel(channel: string | undefined): boolean {
|
|
760
|
+
if (!channel) {
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
return INTERNAL_LAST_CHANNELS.has(channel.trim().toLowerCase());
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function extractTargetSnapshot(entry: SessionEntryLike): TargetSnapshot | null {
|
|
767
|
+
const delivery = entry.deliveryContext ?? {};
|
|
768
|
+
const channel =
|
|
769
|
+
typeof delivery.channel === "string"
|
|
770
|
+
? delivery.channel.trim()
|
|
771
|
+
: typeof entry.lastChannel === "string"
|
|
772
|
+
? entry.lastChannel.trim()
|
|
773
|
+
: typeof entry.channel === "string"
|
|
774
|
+
? entry.channel.trim()
|
|
775
|
+
: undefined;
|
|
776
|
+
const target =
|
|
777
|
+
typeof delivery.to === "string"
|
|
778
|
+
? delivery.to.trim()
|
|
779
|
+
: typeof entry.lastTo === "string"
|
|
780
|
+
? entry.lastTo.trim()
|
|
781
|
+
: undefined;
|
|
782
|
+
if (!channel || !target) {
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
const accountId =
|
|
786
|
+
typeof delivery.accountId === "string"
|
|
787
|
+
? delivery.accountId.trim()
|
|
788
|
+
: typeof entry.lastAccountId === "string"
|
|
789
|
+
? entry.lastAccountId.trim()
|
|
790
|
+
: undefined;
|
|
791
|
+
const threadId =
|
|
792
|
+
delivery.threadId ?? entry.lastThreadId ?? entry.origin?.threadId ?? undefined;
|
|
793
|
+
return {
|
|
794
|
+
channel,
|
|
795
|
+
target,
|
|
796
|
+
accountId: accountId || undefined,
|
|
797
|
+
threadId,
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function snapshotKey(snapshot: TargetSnapshot): string {
|
|
802
|
+
return [snapshot.channel, snapshot.target, snapshot.accountId ?? "", snapshot.threadId ?? ""].join(
|
|
803
|
+
"|",
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function findLatestExternalTarget(
|
|
808
|
+
store: Record<string, SessionEntryLike>,
|
|
809
|
+
exclude: TargetSnapshot,
|
|
810
|
+
): TargetSnapshot | null {
|
|
811
|
+
const excludeKey = snapshotKey(exclude);
|
|
812
|
+
let best: { updatedAt: number; target: TargetSnapshot } | null = null;
|
|
813
|
+
for (const entry of Object.values(store)) {
|
|
814
|
+
const snapshot = extractTargetSnapshot(entry);
|
|
815
|
+
if (!snapshot) {
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
if (isInternalLastChannel(snapshot.channel)) {
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
if (snapshotKey(snapshot) === excludeKey) {
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
const updatedAt = typeof entry.updatedAt === "number" ? entry.updatedAt : 0;
|
|
825
|
+
if (!best || updatedAt > best.updatedAt) {
|
|
826
|
+
best = { updatedAt, target: snapshot };
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
return best?.target ?? null;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function toResolvedTarget(
|
|
833
|
+
snapshot: TargetSnapshot,
|
|
834
|
+
source: ResolvedTarget["source"],
|
|
835
|
+
): ResolvedTarget {
|
|
836
|
+
return {
|
|
837
|
+
channel: snapshot.channel,
|
|
838
|
+
target: snapshot.target,
|
|
839
|
+
accountId: snapshot.accountId,
|
|
840
|
+
threadId: parseThreadId(snapshot.threadId),
|
|
841
|
+
label: undefined,
|
|
842
|
+
source,
|
|
843
|
+
};
|
|
774
844
|
}
|
|
775
845
|
|
|
776
|
-
export function
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
846
|
+
export function resolveLastTargetsFromStore(params: {
|
|
847
|
+
store: Record<string, SessionEntryLike>;
|
|
848
|
+
sessionKey: string;
|
|
849
|
+
}): ResolvedTarget[] {
|
|
850
|
+
const entry =
|
|
851
|
+
params.store[params.sessionKey] ??
|
|
852
|
+
params.store[params.sessionKey.toLowerCase()] ??
|
|
853
|
+
null;
|
|
854
|
+
if (!entry) {
|
|
855
|
+
return [];
|
|
856
|
+
}
|
|
857
|
+
const primary = extractTargetSnapshot(entry);
|
|
858
|
+
if (!primary) {
|
|
859
|
+
return [];
|
|
860
|
+
}
|
|
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")];
|
|
867
|
+
}
|
|
868
|
+
return [toResolvedTarget(primary, "last")];
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function hashOutput(output: string): string {
|
|
872
|
+
return createHash("sha256").update(output).digest("hex");
|
|
782
873
|
}
|
|
783
874
|
|
|
784
875
|
function parseThreadId(value: unknown): string | number | undefined {
|
|
@@ -798,24 +889,7 @@ function parseThreadId(value: unknown): string | number | undefined {
|
|
|
798
889
|
return trimmed;
|
|
799
890
|
}
|
|
800
891
|
|
|
801
|
-
export
|
|
802
|
-
text: string,
|
|
803
|
-
maxChars: number,
|
|
804
|
-
): { text: string; truncated: boolean } {
|
|
805
|
-
if (!text) {
|
|
806
|
-
return { text: "", truncated: false };
|
|
807
|
-
}
|
|
808
|
-
if (text.length <= maxChars) {
|
|
809
|
-
return { text, truncated: false };
|
|
810
|
-
}
|
|
811
|
-
let tail = text.slice(-maxChars);
|
|
812
|
-
const firstNewline = tail.indexOf("\n");
|
|
813
|
-
if (firstNewline > 0 && firstNewline < tail.length - 1) {
|
|
814
|
-
tail = tail.slice(firstNewline + 1);
|
|
815
|
-
}
|
|
816
|
-
tail = tail.trimStart();
|
|
817
|
-
return { text: `...[truncated]\n${tail}`, truncated: true };
|
|
818
|
-
}
|
|
892
|
+
export { stripAnsi, truncateOutput };
|
|
819
893
|
|
|
820
894
|
function dedupeTargets(targets: ResolvedTarget[]): ResolvedTarget[] {
|
|
821
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
|
}
|