tmux-watch 2026.2.3 → 2026.2.5
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 +1 -1
- package/skills/SKILL.md +41 -0
- package/src/cli.ts +61 -0
- package/src/manager.ts +139 -50
- package/src/tool-install.ts +307 -0
package/package.json
CHANGED
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
|
}
|
package/src/manager.ts
CHANGED
|
@@ -53,22 +53,23 @@ type WatchEntry = {
|
|
|
53
53
|
runtime: WatchRuntime;
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
-
type ResolvedTarget = {
|
|
56
|
+
export type ResolvedTarget = {
|
|
57
57
|
channel: string;
|
|
58
58
|
target: string;
|
|
59
59
|
accountId?: string;
|
|
60
60
|
threadId?: string | number;
|
|
61
61
|
label?: string;
|
|
62
|
-
source: "targets" | "last";
|
|
62
|
+
source: "targets" | "last" | "last-fallback";
|
|
63
63
|
};
|
|
64
64
|
|
|
65
|
-
type SessionEntryLike = {
|
|
65
|
+
export type SessionEntryLike = {
|
|
66
66
|
deliveryContext?: {
|
|
67
67
|
channel?: string;
|
|
68
68
|
to?: string;
|
|
69
69
|
accountId?: string;
|
|
70
70
|
threadId?: string | number;
|
|
71
71
|
};
|
|
72
|
+
updatedAt?: number;
|
|
72
73
|
lastChannel?: string;
|
|
73
74
|
lastTo?: string;
|
|
74
75
|
lastAccountId?: string;
|
|
@@ -83,6 +84,7 @@ type MinimalConfig = {
|
|
|
83
84
|
};
|
|
84
85
|
|
|
85
86
|
const STATE_VERSION = 1;
|
|
87
|
+
const INTERNAL_LAST_CHANNELS = new Set(["webchat", "tui"]);
|
|
86
88
|
|
|
87
89
|
export class TmuxWatchManager {
|
|
88
90
|
private readonly api: OpenClawPluginApi;
|
|
@@ -455,60 +457,26 @@ export class TmuxWatchManager {
|
|
|
455
457
|
}
|
|
456
458
|
|
|
457
459
|
if (includeLast) {
|
|
458
|
-
const
|
|
459
|
-
if (
|
|
460
|
-
targets.push(
|
|
461
|
-
...last,
|
|
462
|
-
source: "last",
|
|
463
|
-
});
|
|
460
|
+
const lastTargets = await this.resolveLastTargets(sessionKey);
|
|
461
|
+
if (lastTargets.length > 0) {
|
|
462
|
+
targets.push(...lastTargets);
|
|
464
463
|
}
|
|
465
464
|
}
|
|
466
465
|
|
|
467
466
|
return dedupeTargets(targets);
|
|
468
467
|
}
|
|
469
468
|
|
|
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;
|
|
469
|
+
private async resolveLastTargets(sessionKey: string): Promise<ResolvedTarget[]> {
|
|
470
|
+
const store = await this.readSessionStore(sessionKey);
|
|
471
|
+
if (!store) {
|
|
472
|
+
return [];
|
|
492
473
|
}
|
|
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
|
-
};
|
|
474
|
+
return resolveLastTargetsFromStore({ store, sessionKey });
|
|
509
475
|
}
|
|
510
476
|
|
|
511
|
-
private async
|
|
477
|
+
private async readSessionStore(
|
|
478
|
+
sessionKey: string,
|
|
479
|
+
): Promise<Record<string, SessionEntryLike> | null> {
|
|
512
480
|
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
|
513
481
|
const storePath = this.api.runtime.channel.session.resolveStorePath(
|
|
514
482
|
this.api.config.session?.store,
|
|
@@ -520,8 +488,10 @@ export class TmuxWatchManager {
|
|
|
520
488
|
if (!store || typeof store !== "object") {
|
|
521
489
|
return null;
|
|
522
490
|
}
|
|
523
|
-
return store
|
|
524
|
-
} catch {
|
|
491
|
+
return store;
|
|
492
|
+
} catch (err) {
|
|
493
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
494
|
+
this.api.logger.warn(`[tmux-watch] session store read failed: ${message}`);
|
|
525
495
|
return null;
|
|
526
496
|
}
|
|
527
497
|
}
|
|
@@ -769,6 +739,125 @@ function normalizeSessionKey(input: string | undefined, cfg?: MinimalConfig) {
|
|
|
769
739
|
return `agent:${normalizeAgentId(agentId)}:${lowered}`;
|
|
770
740
|
}
|
|
771
741
|
|
|
742
|
+
type TargetSnapshot = {
|
|
743
|
+
channel: string;
|
|
744
|
+
target: string;
|
|
745
|
+
accountId?: string;
|
|
746
|
+
threadId?: string | number;
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
function isInternalLastChannel(channel: string | undefined): boolean {
|
|
750
|
+
if (!channel) {
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
return INTERNAL_LAST_CHANNELS.has(channel.trim().toLowerCase());
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function extractTargetSnapshot(entry: SessionEntryLike): TargetSnapshot | null {
|
|
757
|
+
const delivery = entry.deliveryContext ?? {};
|
|
758
|
+
const channel =
|
|
759
|
+
typeof delivery.channel === "string"
|
|
760
|
+
? delivery.channel.trim()
|
|
761
|
+
: typeof entry.lastChannel === "string"
|
|
762
|
+
? entry.lastChannel.trim()
|
|
763
|
+
: typeof entry.channel === "string"
|
|
764
|
+
? entry.channel.trim()
|
|
765
|
+
: undefined;
|
|
766
|
+
const target =
|
|
767
|
+
typeof delivery.to === "string"
|
|
768
|
+
? delivery.to.trim()
|
|
769
|
+
: typeof entry.lastTo === "string"
|
|
770
|
+
? entry.lastTo.trim()
|
|
771
|
+
: undefined;
|
|
772
|
+
if (!channel || !target) {
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
const accountId =
|
|
776
|
+
typeof delivery.accountId === "string"
|
|
777
|
+
? delivery.accountId.trim()
|
|
778
|
+
: typeof entry.lastAccountId === "string"
|
|
779
|
+
? entry.lastAccountId.trim()
|
|
780
|
+
: undefined;
|
|
781
|
+
const threadId =
|
|
782
|
+
delivery.threadId ?? entry.lastThreadId ?? entry.origin?.threadId ?? undefined;
|
|
783
|
+
return {
|
|
784
|
+
channel,
|
|
785
|
+
target,
|
|
786
|
+
accountId: accountId || undefined,
|
|
787
|
+
threadId,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function snapshotKey(snapshot: TargetSnapshot): string {
|
|
792
|
+
return [snapshot.channel, snapshot.target, snapshot.accountId ?? "", snapshot.threadId ?? ""].join(
|
|
793
|
+
"|",
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function findLatestExternalTarget(
|
|
798
|
+
store: Record<string, SessionEntryLike>,
|
|
799
|
+
exclude: TargetSnapshot,
|
|
800
|
+
): TargetSnapshot | null {
|
|
801
|
+
const excludeKey = snapshotKey(exclude);
|
|
802
|
+
let best: { updatedAt: number; target: TargetSnapshot } | null = null;
|
|
803
|
+
for (const entry of Object.values(store)) {
|
|
804
|
+
const snapshot = extractTargetSnapshot(entry);
|
|
805
|
+
if (!snapshot) {
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
if (isInternalLastChannel(snapshot.channel)) {
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
if (snapshotKey(snapshot) === excludeKey) {
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
const updatedAt = typeof entry.updatedAt === "number" ? entry.updatedAt : 0;
|
|
815
|
+
if (!best || updatedAt > best.updatedAt) {
|
|
816
|
+
best = { updatedAt, target: snapshot };
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return best?.target ?? null;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function toResolvedTarget(
|
|
823
|
+
snapshot: TargetSnapshot,
|
|
824
|
+
source: ResolvedTarget["source"],
|
|
825
|
+
): ResolvedTarget {
|
|
826
|
+
return {
|
|
827
|
+
channel: snapshot.channel,
|
|
828
|
+
target: snapshot.target,
|
|
829
|
+
accountId: snapshot.accountId,
|
|
830
|
+
threadId: parseThreadId(snapshot.threadId),
|
|
831
|
+
label: undefined,
|
|
832
|
+
source,
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
export function resolveLastTargetsFromStore(params: {
|
|
837
|
+
store: Record<string, SessionEntryLike>;
|
|
838
|
+
sessionKey: string;
|
|
839
|
+
}): ResolvedTarget[] {
|
|
840
|
+
const entry =
|
|
841
|
+
params.store[params.sessionKey] ??
|
|
842
|
+
params.store[params.sessionKey.toLowerCase()] ??
|
|
843
|
+
null;
|
|
844
|
+
if (!entry) {
|
|
845
|
+
return [];
|
|
846
|
+
}
|
|
847
|
+
const primary = extractTargetSnapshot(entry);
|
|
848
|
+
if (!primary) {
|
|
849
|
+
return [];
|
|
850
|
+
}
|
|
851
|
+
const targets: ResolvedTarget[] = [toResolvedTarget(primary, "last")];
|
|
852
|
+
if (isInternalLastChannel(primary.channel)) {
|
|
853
|
+
const fallback = findLatestExternalTarget(params.store, primary);
|
|
854
|
+
if (fallback) {
|
|
855
|
+
targets.push(toResolvedTarget(fallback, "last-fallback"));
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return targets;
|
|
859
|
+
}
|
|
860
|
+
|
|
772
861
|
function hashOutput(output: string): string {
|
|
773
862
|
return createHash("sha256").update(output).digest("hex");
|
|
774
863
|
}
|
|
@@ -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
|
+
}
|