tmux-watch 2026.2.3 → 2026.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmux-watch",
3
- "version": "2026.2.3",
3
+ "version": "2026.2.4",
4
4
  "type": "module",
5
5
  "description": "OpenClaw tmux output watchdog plugin",
6
6
  "license": "MIT",
package/skills/SKILL.md CHANGED
@@ -63,6 +63,46 @@ tmux send-keys -t <session:window.pane> C-l
63
63
  tmux capture-pane -p -J -t <session:window.pane> -S -200
64
64
  ```
65
65
 
66
+ ## Screenshot tools (priority + install)
67
+
68
+ Priority detection order:
69
+
70
+ 1. System-level PATH (`command -v cryosnap` / `command -v freeze`)
71
+ 2. User-level bins (`~/.local/bin`, `~/bin`)
72
+ 3. OpenClaw tools dir (`$OPENCLAW_STATE_DIR/tools`, default `~/.openclaw/tools`)
73
+
74
+ If cryosnap exists, use it. If not, use freeze. If neither exists, **auto-install cryosnap**.
75
+
76
+ Install commands (downloads the latest GitHub release into the OpenClaw tools dir):
77
+
78
+ ```bash
79
+ openclaw tmux-watch install cryosnap
80
+ 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
+
87
+ ### cryosnap (preferred)
88
+
89
+ ```bash
90
+ # tmux pane -> PNG
91
+ cryosnap --tmux --tmux-args "-t %3 -S -200 -J" --config full -o out.png
92
+ ```
93
+
94
+ Notes:
95
+
96
+ - For zsh, wrap `%3` in quotes or escape `%` (e.g., `"-t %3 -S -200 -J"`).
97
+ - You can pass `-t session:window.pane` instead of `%pane_id`.
98
+
99
+ ### freeze (fallback)
100
+
101
+ ```bash
102
+ # Capture ANSI text first, then render with freeze
103
+ tmux capture-pane -p -J -e -t <session:window.pane> -S -200 | freeze -o out.png
104
+ ```
105
+
66
106
  ## Tool: tmux-watch
67
107
 
68
108
  ### Add subscription
@@ -72,6 +112,7 @@ tmux capture-pane -p -J -t <session:window.pane> -S -200
72
112
  "action": "add",
73
113
  "target": "session:0.0",
74
114
  "label": "my-job",
115
+ "note": "This pane runs an AI coding TUI; notify me when it appears stuck.",
75
116
  "sessionKey": "main",
76
117
  "captureIntervalSeconds": 10,
77
118
  "stableCount": 6,
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 { installTool, removeTool, type ToolId } from "./tool-install.js";
5
6
 
6
7
  type Logger = {
7
8
  info: (message: string) => void;
@@ -57,6 +58,14 @@ function resolveSocketFromEnv(): string | undefined {
57
58
  return socket || undefined;
58
59
  }
59
60
 
61
+ function normalizeToolId(raw: string): ToolId {
62
+ const normalized = raw.trim().toLowerCase();
63
+ if (normalized !== "cryosnap" && normalized !== "freeze") {
64
+ throw new Error("Tool must be cryosnap or freeze.");
65
+ }
66
+ return normalized as ToolId;
67
+ }
68
+
60
69
  export function registerTmuxWatchCli(params: {
61
70
  program: Command;
62
71
  api: OpenClawPluginApi;
@@ -117,4 +126,56 @@ export function registerTmuxWatchCli(params: {
117
126
  .action(() => {
118
127
  printSocketHelp(logger);
119
128
  });
129
+
130
+ root
131
+ .command("install")
132
+ .description("Install cryosnap or freeze into the OpenClaw tools directory")
133
+ .argument("[tool]", "cryosnap or freeze", "cryosnap")
134
+ .option("--force", "Replace existing tool binary")
135
+ .action(async (tool: string, options: { force?: boolean }) => {
136
+ const normalized = normalizeToolId(tool);
137
+ const result = await installTool({
138
+ tool: normalized,
139
+ api,
140
+ logger,
141
+ force: Boolean(options.force),
142
+ });
143
+ const version = result.version ? ` (${result.version})` : "";
144
+ logger.info(`Installed ${result.tool}${version}`);
145
+ logger.info(`Path: ${result.path}`);
146
+ });
147
+
148
+ root
149
+ .command("update")
150
+ .description("Update cryosnap or freeze in the OpenClaw tools directory")
151
+ .argument("[tool]", "cryosnap or freeze", "cryosnap")
152
+ .action(async (tool: string) => {
153
+ const normalized = normalizeToolId(tool);
154
+ const result = await installTool({
155
+ tool: normalized,
156
+ api,
157
+ logger,
158
+ force: true,
159
+ });
160
+ const version = result.version ? ` (${result.version})` : "";
161
+ logger.info(`Updated ${result.tool}${version}`);
162
+ logger.info(`Path: ${result.path}`);
163
+ });
164
+
165
+ root
166
+ .command("remove")
167
+ .description("Remove cryosnap or freeze from the OpenClaw tools directory")
168
+ .argument("[tool]", "cryosnap or freeze", "cryosnap")
169
+ .action(async (tool: string) => {
170
+ const normalized = normalizeToolId(tool);
171
+ const result = await removeTool({
172
+ tool: normalized,
173
+ api,
174
+ logger,
175
+ });
176
+ if (result.removed) {
177
+ logger.info(`Removed ${result.tool}`);
178
+ }
179
+ logger.info(`Path: ${result.path}`);
180
+ });
120
181
  }
@@ -0,0 +1,307 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+
6
+ type Logger = {
7
+ info: (message: string) => void;
8
+ warn: (message: string) => void;
9
+ error: (message: string) => void;
10
+ };
11
+
12
+ export type ToolId = "cryosnap" | "freeze";
13
+
14
+ export type ReleaseAsset = {
15
+ name: string;
16
+ browser_download_url: string;
17
+ };
18
+
19
+ type ReleaseResponse = {
20
+ tag_name?: string;
21
+ assets?: ReleaseAsset[];
22
+ };
23
+
24
+ type ToolSpec = {
25
+ id: ToolId;
26
+ repo: string;
27
+ binary: string;
28
+ };
29
+
30
+ const TOOL_SPECS: Record<ToolId, ToolSpec> = {
31
+ cryosnap: {
32
+ id: "cryosnap",
33
+ repo: "Wangnov/cryosnap",
34
+ binary: "cryosnap",
35
+ },
36
+ freeze: {
37
+ id: "freeze",
38
+ repo: "charmbracelet/freeze",
39
+ binary: "freeze",
40
+ },
41
+ };
42
+
43
+ const ARCH_TOKENS: Record<string, string[]> = {
44
+ x64: ["x86_64", "amd64", "x64"],
45
+ arm64: ["aarch64", "arm64"],
46
+ arm: ["armv7", "armv6", "arm"],
47
+ };
48
+
49
+ const PLATFORM_TOKENS: Record<string, string[]> = {
50
+ darwin: ["darwin", "apple-darwin", "macos", "mac"],
51
+ linux: ["linux", "unknown-linux", "linux-gnu", "linux-musl"],
52
+ win32: ["windows", "win32", "msvc", "pc-windows"],
53
+ };
54
+
55
+ const ARCHIVE_EXTENSIONS = [".tar.gz", ".tgz", ".zip", ".tar.xz"];
56
+
57
+ export async function installTool(params: {
58
+ tool: ToolId;
59
+ api: OpenClawPluginApi;
60
+ logger: Logger;
61
+ force?: boolean;
62
+ }): Promise<{ tool: ToolId; version?: string; path: string; asset: string }> {
63
+ if (process.env.OPENCLAW_NIX_MODE === "1") {
64
+ throw new Error("OPENCLAW_NIX_MODE=1; auto-install is disabled.");
65
+ }
66
+ const spec = TOOL_SPECS[params.tool];
67
+ if (!spec) {
68
+ throw new Error(`Unknown tool: ${params.tool}`);
69
+ }
70
+
71
+ const toolsDir = resolveToolsDir(params.api);
72
+ await fs.mkdir(toolsDir, { recursive: true, mode: 0o700 });
73
+
74
+ const binaryName = resolveBinaryName(spec.binary);
75
+ const destPath = path.join(toolsDir, binaryName);
76
+ if (!params.force) {
77
+ try {
78
+ await fs.access(destPath);
79
+ params.logger.info(`Tool already installed: ${destPath}`);
80
+ return { tool: spec.id, path: destPath, asset: binaryName };
81
+ } catch {
82
+ // proceed
83
+ }
84
+ }
85
+
86
+ params.logger.info(`Fetching latest release for ${spec.repo}...`);
87
+ const release = await fetchLatestRelease(spec.repo);
88
+ const assets = release.assets ?? [];
89
+ const selected = selectReleaseAsset({
90
+ assets,
91
+ binaryName: spec.binary,
92
+ });
93
+ if (!selected) {
94
+ throw new Error(
95
+ `No matching release asset found for ${spec.repo} (${process.platform}/${process.arch}).`,
96
+ );
97
+ }
98
+
99
+ const tmpDir = await fs.mkdtemp(path.join(toolsDir, `.tmp-${spec.id}-`));
100
+ try {
101
+ const downloadPath = path.join(tmpDir, selected.name);
102
+ params.logger.info(`Downloading ${selected.name}...`);
103
+ await downloadFile(selected.browser_download_url, downloadPath);
104
+
105
+ const extracted = await extractIfNeeded(params.api, downloadPath, tmpDir);
106
+ const binPath = extracted
107
+ ? await findBinary(tmpDir, binaryName)
108
+ : downloadPath;
109
+
110
+ if (!binPath) {
111
+ throw new Error(`Binary ${binaryName} not found in extracted archive.`);
112
+ }
113
+
114
+ await fs.copyFile(binPath, destPath);
115
+ if (process.platform !== "win32") {
116
+ await fs.chmod(destPath, 0o755);
117
+ }
118
+
119
+ params.logger.info(`Installed ${spec.id} -> ${destPath}`);
120
+ return {
121
+ tool: spec.id,
122
+ version: release.tag_name,
123
+ path: destPath,
124
+ asset: selected.name,
125
+ };
126
+ } finally {
127
+ await fs.rm(tmpDir, { recursive: true, force: true });
128
+ }
129
+ }
130
+
131
+ export async function removeTool(params: {
132
+ tool: ToolId;
133
+ api: OpenClawPluginApi;
134
+ logger: Logger;
135
+ }): Promise<{ tool: ToolId; path: string; removed: boolean }> {
136
+ const spec = TOOL_SPECS[params.tool];
137
+ if (!spec) {
138
+ throw new Error(`Unknown tool: ${params.tool}`);
139
+ }
140
+ const toolsDir = resolveToolsDir(params.api);
141
+ const binaryName = resolveBinaryName(spec.binary);
142
+ const destPath = path.join(toolsDir, binaryName);
143
+ try {
144
+ await fs.access(destPath);
145
+ } catch {
146
+ params.logger.info(`Tool not found: ${destPath}`);
147
+ return { tool: spec.id, path: destPath, removed: false };
148
+ }
149
+ await fs.rm(destPath, { force: true });
150
+ params.logger.info(`Removed ${spec.id} -> ${destPath}`);
151
+ return { tool: spec.id, path: destPath, removed: true };
152
+ }
153
+
154
+ function resolveToolsDir(api: OpenClawPluginApi): string {
155
+ const stateDir = resolveStateDir(api);
156
+ return path.join(stateDir, "tools");
157
+ }
158
+
159
+ function resolveStateDir(api: OpenClawPluginApi): string {
160
+ const resolver = api.runtime?.state?.resolveStateDir;
161
+ if (typeof resolver === "function") {
162
+ return resolver();
163
+ }
164
+ return path.join(os.homedir(), ".openclaw");
165
+ }
166
+
167
+ function resolveBinaryName(base: string): string {
168
+ if (process.platform === "win32") {
169
+ return `${base}.exe`;
170
+ }
171
+ return base;
172
+ }
173
+
174
+ async function fetchLatestRelease(repo: string): Promise<ReleaseResponse> {
175
+ const url = `https://api.github.com/repos/${repo}/releases/latest`;
176
+ const response = await fetch(url, {
177
+ headers: {
178
+ "User-Agent": "openclaw-tmux-watch",
179
+ Accept: "application/vnd.github+json",
180
+ },
181
+ });
182
+ if (!response.ok) {
183
+ const body = await response.text();
184
+ throw new Error(`GitHub release fetch failed (${response.status}): ${body}`);
185
+ }
186
+ return (await response.json()) as ReleaseResponse;
187
+ }
188
+
189
+ export function selectReleaseAsset(params: {
190
+ assets: ReleaseAsset[];
191
+ binaryName: string;
192
+ platform?: string;
193
+ arch?: string;
194
+ }): ReleaseAsset | null {
195
+ const platform = params.platform ?? process.platform;
196
+ const arch = params.arch ?? process.arch;
197
+ const assets = params.assets ?? [];
198
+ let best: { asset: ReleaseAsset; score: number } | null = null;
199
+
200
+ for (const asset of assets) {
201
+ const score = scoreAsset(asset.name, params.binaryName, platform, arch);
202
+ if (score <= 0) {
203
+ continue;
204
+ }
205
+ if (!best || score > best.score) {
206
+ best = { asset, score };
207
+ }
208
+ }
209
+
210
+ return best?.asset ?? null;
211
+ }
212
+
213
+ function scoreAsset(
214
+ name: string,
215
+ binaryName: string,
216
+ platform: string,
217
+ arch: string,
218
+ ): number {
219
+ const lowered = name.toLowerCase();
220
+ if (/(sha256|checksums|sbom|sig|signature|\.txt)$/.test(lowered)) {
221
+ return 0;
222
+ }
223
+
224
+ const platformTokens = PLATFORM_TOKENS[platform] ?? [];
225
+ const archTokens = ARCH_TOKENS[arch] ?? [];
226
+
227
+ let score = 0;
228
+ if (lowered.includes(binaryName)) {
229
+ score += 2;
230
+ }
231
+ for (const token of platformTokens) {
232
+ if (lowered.includes(token)) {
233
+ score += 3;
234
+ break;
235
+ }
236
+ }
237
+ for (const token of archTokens) {
238
+ if (lowered.includes(token)) {
239
+ score += 2;
240
+ break;
241
+ }
242
+ }
243
+ if (ARCHIVE_EXTENSIONS.some((ext) => lowered.endsWith(ext))) {
244
+ score += 1;
245
+ }
246
+ return score;
247
+ }
248
+
249
+ async function downloadFile(url: string, destPath: string): Promise<void> {
250
+ const response = await fetch(url, { headers: { "User-Agent": "openclaw-tmux-watch" } });
251
+ if (!response.ok) {
252
+ const body = await response.text();
253
+ throw new Error(`Download failed (${response.status}): ${body}`);
254
+ }
255
+ const buffer = Buffer.from(await response.arrayBuffer());
256
+ await fs.writeFile(destPath, buffer);
257
+ }
258
+
259
+ async function extractIfNeeded(
260
+ api: OpenClawPluginApi,
261
+ archivePath: string,
262
+ destDir: string,
263
+ ): Promise<boolean> {
264
+ const lowered = archivePath.toLowerCase();
265
+ if (lowered.endsWith(".zip")) {
266
+ await runCommand(api, ["unzip", "-o", archivePath, "-d", destDir]);
267
+ return true;
268
+ }
269
+ if (lowered.endsWith(".tar.gz") || lowered.endsWith(".tgz")) {
270
+ await runCommand(api, ["tar", "-xzf", archivePath, "-C", destDir]);
271
+ return true;
272
+ }
273
+ if (lowered.endsWith(".tar.xz")) {
274
+ await runCommand(api, ["tar", "-xJf", archivePath, "-C", destDir]);
275
+ return true;
276
+ }
277
+ return false;
278
+ }
279
+
280
+ async function runCommand(api: OpenClawPluginApi, argv: string[]): Promise<void> {
281
+ const result = await api.runtime.system.runCommandWithTimeout(argv, {
282
+ timeoutMs: 120_000,
283
+ });
284
+ if (result.code !== 0) {
285
+ throw new Error(`Command failed: ${argv.join(" ")}\n${result.stderr ?? ""}`.trim());
286
+ }
287
+ }
288
+
289
+ async function findBinary(dir: string, binaryName: string): Promise<string | null> {
290
+ const entries = await fs.readdir(dir, { withFileTypes: true });
291
+ for (const entry of entries) {
292
+ if (entry.name === "__MACOSX") {
293
+ continue;
294
+ }
295
+ const fullPath = path.join(dir, entry.name);
296
+ if (entry.isFile() && entry.name === binaryName) {
297
+ return fullPath;
298
+ }
299
+ if (entry.isDirectory()) {
300
+ const nested = await findBinary(fullPath, binaryName);
301
+ if (nested) {
302
+ return nested;
303
+ }
304
+ }
305
+ }
306
+ return null;
307
+ }