tmux-watch 2026.2.1
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/LICENSE +21 -0
- package/README.md +256 -0
- package/index.ts +29 -0
- package/openclaw.plugin.json +47 -0
- package/package.json +47 -0
- package/skills/SKILL.md +124 -0
- package/src/cli.ts +120 -0
- package/src/config.ts +129 -0
- package/src/manager.ts +837 -0
- package/src/service.ts +17 -0
- package/src/tmux-watch-tool.ts +189 -0
- package/types/openclaw-plugin-sdk.d.ts +71 -0
package/src/manager.ts
ADDED
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
import type { OpenClawPluginApi, OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_CAPTURE_INTERVAL_SECONDS,
|
|
7
|
+
DEFAULT_STABLE_COUNT,
|
|
8
|
+
resolveTmuxWatchConfig,
|
|
9
|
+
type NotifyMode,
|
|
10
|
+
type NotifyTarget,
|
|
11
|
+
type TmuxWatchConfig,
|
|
12
|
+
} from "./config.js";
|
|
13
|
+
|
|
14
|
+
export type TmuxWatchSubscription = {
|
|
15
|
+
id: string;
|
|
16
|
+
label?: string;
|
|
17
|
+
note?: string;
|
|
18
|
+
target: string;
|
|
19
|
+
socket?: string;
|
|
20
|
+
sessionKey?: string;
|
|
21
|
+
captureIntervalSeconds?: number;
|
|
22
|
+
intervalMs?: number;
|
|
23
|
+
stableCount?: number;
|
|
24
|
+
stableSeconds?: number;
|
|
25
|
+
captureLines?: number;
|
|
26
|
+
stripAnsi?: boolean;
|
|
27
|
+
enabled?: boolean;
|
|
28
|
+
notify?: {
|
|
29
|
+
mode?: NotifyMode;
|
|
30
|
+
targets?: NotifyTarget[];
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type PersistedState = {
|
|
35
|
+
version: number;
|
|
36
|
+
subscriptions: TmuxWatchSubscription[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type WatchRuntime = {
|
|
40
|
+
running: boolean;
|
|
41
|
+
stableTicks: number;
|
|
42
|
+
lastHash?: string;
|
|
43
|
+
lastOutput?: string;
|
|
44
|
+
lastCapturedAt?: number;
|
|
45
|
+
lastNotifiedHash?: string;
|
|
46
|
+
lastNotifiedAt?: number;
|
|
47
|
+
lastError?: string;
|
|
48
|
+
timer?: NodeJS.Timeout;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type WatchEntry = {
|
|
52
|
+
subscription: TmuxWatchSubscription;
|
|
53
|
+
runtime: WatchRuntime;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type ResolvedTarget = {
|
|
57
|
+
channel: string;
|
|
58
|
+
target: string;
|
|
59
|
+
accountId?: string;
|
|
60
|
+
threadId?: string | number;
|
|
61
|
+
label?: string;
|
|
62
|
+
source: "targets" | "last";
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
type SessionEntryLike = {
|
|
66
|
+
deliveryContext?: {
|
|
67
|
+
channel?: string;
|
|
68
|
+
to?: string;
|
|
69
|
+
accountId?: string;
|
|
70
|
+
threadId?: string | number;
|
|
71
|
+
};
|
|
72
|
+
lastChannel?: string;
|
|
73
|
+
lastTo?: string;
|
|
74
|
+
lastAccountId?: string;
|
|
75
|
+
lastThreadId?: string | number;
|
|
76
|
+
channel?: string;
|
|
77
|
+
origin?: { threadId?: string | number };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
type MinimalConfig = {
|
|
81
|
+
session?: { scope?: string; mainKey?: string };
|
|
82
|
+
agents?: { list?: Array<{ id?: string; default?: boolean }> };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const STATE_VERSION = 1;
|
|
86
|
+
|
|
87
|
+
export class TmuxWatchManager {
|
|
88
|
+
private readonly api: OpenClawPluginApi;
|
|
89
|
+
private readonly config: TmuxWatchConfig;
|
|
90
|
+
private readonly entries = new Map<string, WatchEntry>();
|
|
91
|
+
private stateDir: string | null = null;
|
|
92
|
+
private loaded = false;
|
|
93
|
+
private active = false;
|
|
94
|
+
private tmuxChecked = false;
|
|
95
|
+
private tmuxAvailable = false;
|
|
96
|
+
|
|
97
|
+
constructor(api: OpenClawPluginApi) {
|
|
98
|
+
this.api = api;
|
|
99
|
+
this.config = resolveTmuxWatchConfig(api.pluginConfig);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async start(ctx: OpenClawPluginServiceContext): Promise<void> {
|
|
103
|
+
if (!this.config.enabled) {
|
|
104
|
+
this.api.logger.info("[tmux-watch] disabled via config");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
this.stateDir = ctx.stateDir ?? null;
|
|
108
|
+
this.active = true;
|
|
109
|
+
await this.ensureLoaded();
|
|
110
|
+
await this.ensureTmuxAvailable();
|
|
111
|
+
if (!this.tmuxAvailable) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
for (const entry of this.entries.values()) {
|
|
115
|
+
this.startWatch(entry);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async stop(): Promise<void> {
|
|
120
|
+
this.active = false;
|
|
121
|
+
for (const entry of this.entries.values()) {
|
|
122
|
+
if (entry.runtime.timer) {
|
|
123
|
+
clearInterval(entry.runtime.timer);
|
|
124
|
+
entry.runtime.timer = undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async listSubscriptions(options?: { includeOutput?: boolean }) {
|
|
130
|
+
await this.ensureLoaded();
|
|
131
|
+
const includeOutput = options?.includeOutput !== false;
|
|
132
|
+
const maxOutputChars = this.config.maxOutputChars;
|
|
133
|
+
const items = [];
|
|
134
|
+
for (const entry of this.entries.values()) {
|
|
135
|
+
const runtime = entry.runtime;
|
|
136
|
+
const outputInfo = includeOutput
|
|
137
|
+
? truncateOutput(runtime.lastOutput ?? "", maxOutputChars)
|
|
138
|
+
: { text: undefined, truncated: false };
|
|
139
|
+
items.push({
|
|
140
|
+
...entry.subscription,
|
|
141
|
+
enabled: entry.subscription.enabled !== false,
|
|
142
|
+
runtime: {
|
|
143
|
+
stableTicks: runtime.stableTicks,
|
|
144
|
+
lastCapturedAt: runtime.lastCapturedAt,
|
|
145
|
+
lastNotifiedAt: runtime.lastNotifiedAt,
|
|
146
|
+
lastError: runtime.lastError,
|
|
147
|
+
output: outputInfo.text,
|
|
148
|
+
outputTruncated: outputInfo.truncated,
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return items;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async addSubscription(input: Partial<TmuxWatchSubscription> & { target: string }) {
|
|
156
|
+
await this.ensureLoaded();
|
|
157
|
+
const id = input.id?.trim() || randomUUID();
|
|
158
|
+
const existing = this.entries.get(id);
|
|
159
|
+
const subscription: TmuxWatchSubscription = {
|
|
160
|
+
...(existing?.subscription ?? {}),
|
|
161
|
+
...sanitizeSubscriptionInput(input),
|
|
162
|
+
id,
|
|
163
|
+
};
|
|
164
|
+
const runtime = existing?.runtime ?? createRuntime();
|
|
165
|
+
this.entries.set(id, { subscription, runtime });
|
|
166
|
+
await this.saveState();
|
|
167
|
+
if (this.active && this.tmuxAvailable && subscription.enabled !== false) {
|
|
168
|
+
this.startWatch(this.entries.get(id)!);
|
|
169
|
+
} else if (existing?.runtime.timer) {
|
|
170
|
+
clearInterval(existing.runtime.timer);
|
|
171
|
+
existing.runtime.timer = undefined;
|
|
172
|
+
}
|
|
173
|
+
return subscription;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async removeSubscription(id: string): Promise<boolean> {
|
|
177
|
+
await this.ensureLoaded();
|
|
178
|
+
const entry = this.entries.get(id);
|
|
179
|
+
if (!entry) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
if (entry.runtime.timer) {
|
|
183
|
+
clearInterval(entry.runtime.timer);
|
|
184
|
+
entry.runtime.timer = undefined;
|
|
185
|
+
}
|
|
186
|
+
this.entries.delete(id);
|
|
187
|
+
await this.saveState();
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async ensureLoaded(): Promise<void> {
|
|
192
|
+
if (this.loaded) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const state = await this.loadState();
|
|
196
|
+
for (const subscription of state.subscriptions) {
|
|
197
|
+
if (!subscription.id || !subscription.target) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
this.entries.set(subscription.id, {
|
|
201
|
+
subscription,
|
|
202
|
+
runtime: createRuntime(),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
this.loaded = true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private getStatePath(): string {
|
|
209
|
+
const stateDir = this.stateDir ?? this.api.runtime.state.resolveStateDir();
|
|
210
|
+
return path.join(stateDir, "tmux-watch", "subscriptions.json");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private async loadState(): Promise<PersistedState> {
|
|
214
|
+
const filePath = this.getStatePath();
|
|
215
|
+
try {
|
|
216
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
217
|
+
const parsed = JSON.parse(raw) as PersistedState;
|
|
218
|
+
if (!parsed || parsed.version !== STATE_VERSION || !Array.isArray(parsed.subscriptions)) {
|
|
219
|
+
return { version: STATE_VERSION, subscriptions: [] };
|
|
220
|
+
}
|
|
221
|
+
return parsed;
|
|
222
|
+
} catch {
|
|
223
|
+
return { version: STATE_VERSION, subscriptions: [] };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private async saveState(): Promise<void> {
|
|
228
|
+
const filePath = this.getStatePath();
|
|
229
|
+
const dir = path.dirname(filePath);
|
|
230
|
+
await fs.mkdir(dir, { recursive: true });
|
|
231
|
+
const payload: PersistedState = {
|
|
232
|
+
version: STATE_VERSION,
|
|
233
|
+
subscriptions: Array.from(this.entries.values()).map((entry) => entry.subscription),
|
|
234
|
+
};
|
|
235
|
+
await fs.writeFile(filePath, JSON.stringify(payload, null, 2));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private startWatch(entry: WatchEntry): void {
|
|
239
|
+
if (entry.runtime.timer) {
|
|
240
|
+
clearInterval(entry.runtime.timer);
|
|
241
|
+
entry.runtime.timer = undefined;
|
|
242
|
+
}
|
|
243
|
+
if (entry.subscription.enabled === false) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const intervalMs = resolveIntervalMs(entry.subscription, this.config);
|
|
247
|
+
entry.runtime.timer = setInterval(() => {
|
|
248
|
+
void this.pollWatch(entry).catch((err) => {
|
|
249
|
+
entry.runtime.lastError = err instanceof Error ? err.message : String(err);
|
|
250
|
+
});
|
|
251
|
+
}, intervalMs);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async pollWatch(entry: WatchEntry): Promise<void> {
|
|
255
|
+
if (entry.runtime.running) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
entry.runtime.running = true;
|
|
259
|
+
try {
|
|
260
|
+
const output = await this.captureOutput(entry.subscription);
|
|
261
|
+
if (output === null) {
|
|
262
|
+
entry.runtime.stableTicks = 0;
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
entry.runtime.lastCapturedAt = Date.now();
|
|
266
|
+
entry.runtime.lastError = undefined;
|
|
267
|
+
const hash = hashOutput(output);
|
|
268
|
+
if (entry.runtime.lastHash && entry.runtime.lastHash === hash) {
|
|
269
|
+
entry.runtime.stableTicks += 1;
|
|
270
|
+
} else {
|
|
271
|
+
entry.runtime.lastHash = hash;
|
|
272
|
+
entry.runtime.lastOutput = output;
|
|
273
|
+
entry.runtime.stableTicks = 0;
|
|
274
|
+
entry.runtime.lastNotifiedHash = undefined;
|
|
275
|
+
}
|
|
276
|
+
const stableTicks = resolveStableTicks(entry.subscription, this.config);
|
|
277
|
+
if (entry.runtime.stableTicks >= stableTicks) {
|
|
278
|
+
if (entry.runtime.lastNotifiedHash !== hash) {
|
|
279
|
+
entry.runtime.lastNotifiedHash = hash;
|
|
280
|
+
entry.runtime.lastNotifiedAt = Date.now();
|
|
281
|
+
await this.notifyStable(entry.subscription, output);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
} finally {
|
|
285
|
+
entry.runtime.running = false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private async captureOutput(subscription: TmuxWatchSubscription): Promise<string | null> {
|
|
290
|
+
if (!this.tmuxAvailable) {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
const target = subscription.target.trim();
|
|
294
|
+
if (!target) {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const captureLines = resolveCaptureLines(subscription, this.config);
|
|
299
|
+
const socket = subscription.socket ?? this.config.socket;
|
|
300
|
+
const argv = socket
|
|
301
|
+
? ["tmux", "-S", socket, "capture-pane", "-p", "-J", "-t", target]
|
|
302
|
+
: ["tmux", "capture-pane", "-p", "-J", "-t", target];
|
|
303
|
+
if (captureLines > 0) {
|
|
304
|
+
argv.push("-S", `-${captureLines}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
const result = await this.api.runtime.system.runCommandWithTimeout(argv, {
|
|
309
|
+
timeoutMs: Math.max(1000, resolveIntervalMs(subscription, this.config) - 50),
|
|
310
|
+
});
|
|
311
|
+
if (result.code !== 0) {
|
|
312
|
+
const stderr = result.stderr?.trim();
|
|
313
|
+
if (stderr) {
|
|
314
|
+
this.api.logger.warn(`[tmux-watch] tmux error: ${stderr}`);
|
|
315
|
+
}
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
let output = result.stdout ?? "";
|
|
319
|
+
output = output.replace(/\r\n/g, "\n").trimEnd();
|
|
320
|
+
if (resolveStripAnsi(subscription, this.config)) {
|
|
321
|
+
output = stripAnsi(output);
|
|
322
|
+
}
|
|
323
|
+
return output;
|
|
324
|
+
} catch (err) {
|
|
325
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
326
|
+
this.api.logger.warn(`[tmux-watch] tmux capture failed: ${message}`);
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private async notifyStable(
|
|
332
|
+
subscription: TmuxWatchSubscription,
|
|
333
|
+
output: string,
|
|
334
|
+
): Promise<void> {
|
|
335
|
+
const sessionKey = normalizeSessionKey(
|
|
336
|
+
subscription.sessionKey ?? this.config.sessionKey,
|
|
337
|
+
this.api.config,
|
|
338
|
+
);
|
|
339
|
+
if (!sessionKey) {
|
|
340
|
+
this.api.logger.warn("[tmux-watch] missing sessionKey; skipping notify");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const targets = await this.resolveNotifyTargets(subscription, sessionKey);
|
|
344
|
+
if (targets.length === 0) {
|
|
345
|
+
this.api.logger.warn("[tmux-watch] no notify targets resolved; skipping notify");
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const primary = targets[0]!;
|
|
349
|
+
const outputInfo = truncateOutput(output, this.config.maxOutputChars);
|
|
350
|
+
|
|
351
|
+
const details = {
|
|
352
|
+
id: subscription.id,
|
|
353
|
+
label: subscription.label,
|
|
354
|
+
note: subscription.note,
|
|
355
|
+
target: subscription.target,
|
|
356
|
+
sessionKey,
|
|
357
|
+
notifyMode: resolveNotifyMode(subscription, this.config),
|
|
358
|
+
notifyTargets: targets.map((target) => ({
|
|
359
|
+
channel: target.channel,
|
|
360
|
+
target: target.target,
|
|
361
|
+
accountId: target.accountId,
|
|
362
|
+
threadId: target.threadId,
|
|
363
|
+
label: target.label,
|
|
364
|
+
source: target.source,
|
|
365
|
+
})),
|
|
366
|
+
notifyExpectation: "required",
|
|
367
|
+
primary: {
|
|
368
|
+
channel: primary.channel,
|
|
369
|
+
target: primary.target,
|
|
370
|
+
accountId: primary.accountId,
|
|
371
|
+
threadId: primary.threadId,
|
|
372
|
+
},
|
|
373
|
+
outputTruncated: outputInfo.truncated,
|
|
374
|
+
stableCount: resolveStableCount(subscription, this.config),
|
|
375
|
+
captureIntervalSeconds: resolveIntervalMs(subscription, this.config) / 1000,
|
|
376
|
+
stableDurationSeconds: resolveStableDurationSeconds(subscription, this.config),
|
|
377
|
+
intervalMs: resolveIntervalMs(subscription, this.config),
|
|
378
|
+
capturedAt: new Date().toISOString(),
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const lines = [
|
|
382
|
+
"SYSTEM EVENT (tmux-watch): Not user input. Summarize the output and notify the user.",
|
|
383
|
+
"policy: notify user (do not reply NO_REPLY unless user explicitly requested silence)",
|
|
384
|
+
`subscription: ${subscription.label ? `${subscription.label} (${subscription.id})` : subscription.id}`,
|
|
385
|
+
subscription.note ? `subscription_note: ${subscription.note}` : null,
|
|
386
|
+
`tmux target: ${subscription.target}`,
|
|
387
|
+
`session: ${sessionKey}`,
|
|
388
|
+
`notify.mode: ${details.notifyMode}`,
|
|
389
|
+
`notify.primary: ${primary.channel} ${primary.target}`,
|
|
390
|
+
`notify.targets: ${targets
|
|
391
|
+
.map((target) => `${target.channel}:${target.target}${target.label ? ` (${target.label})` : ""}`)
|
|
392
|
+
.join(", ")}`,
|
|
393
|
+
"details_json:",
|
|
394
|
+
JSON.stringify(details, null, 2),
|
|
395
|
+
].filter((line): line is string => Boolean(line));
|
|
396
|
+
|
|
397
|
+
if (outputInfo.text) {
|
|
398
|
+
lines.push("output:");
|
|
399
|
+
lines.push(outputInfo.text);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const body = lines.join("\n");
|
|
403
|
+
const ctx = {
|
|
404
|
+
Body: body,
|
|
405
|
+
RawBody: body,
|
|
406
|
+
CommandBody: body,
|
|
407
|
+
Provider: "tmux-watch",
|
|
408
|
+
Surface: "tmux-watch",
|
|
409
|
+
SessionKey: sessionKey,
|
|
410
|
+
MessageSid: `tmux-watch:${subscription.id}:${Date.now()}`,
|
|
411
|
+
OriginatingChannel: primary.channel,
|
|
412
|
+
OriginatingTo: primary.target,
|
|
413
|
+
AccountId: primary.accountId,
|
|
414
|
+
MessageThreadId: primary.threadId,
|
|
415
|
+
To: primary.target,
|
|
416
|
+
From: "tmux-watch",
|
|
417
|
+
ChatType: "direct",
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
await this.api.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
421
|
+
ctx,
|
|
422
|
+
cfg: this.api.config,
|
|
423
|
+
dispatcherOptions: {
|
|
424
|
+
deliver: async () => {},
|
|
425
|
+
onError: (err: unknown) => {
|
|
426
|
+
this.api.logger.warn(
|
|
427
|
+
`[tmux-watch] dispatch error: ${err instanceof Error ? err.message : String(err)}`,
|
|
428
|
+
);
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private async resolveNotifyTargets(
|
|
435
|
+
subscription: TmuxWatchSubscription,
|
|
436
|
+
sessionKey: string,
|
|
437
|
+
): Promise<ResolvedTarget[]> {
|
|
438
|
+
const targets: ResolvedTarget[] = [];
|
|
439
|
+
const mode = resolveNotifyMode(subscription, this.config);
|
|
440
|
+
const includeTargets = mode === "targets" || mode === "targets+last";
|
|
441
|
+
const includeLast = mode === "last" || mode === "targets+last";
|
|
442
|
+
|
|
443
|
+
if (includeTargets) {
|
|
444
|
+
const configured = resolveNotifyTargetList(subscription, this.config);
|
|
445
|
+
for (const target of configured) {
|
|
446
|
+
targets.push({
|
|
447
|
+
channel: target.channel,
|
|
448
|
+
target: target.target,
|
|
449
|
+
accountId: target.accountId,
|
|
450
|
+
threadId: parseThreadId(target.threadId),
|
|
451
|
+
label: target.label,
|
|
452
|
+
source: "targets",
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (includeLast) {
|
|
458
|
+
const last = await this.resolveLastTarget(sessionKey);
|
|
459
|
+
if (last) {
|
|
460
|
+
targets.push({
|
|
461
|
+
...last,
|
|
462
|
+
source: "last",
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return dedupeTargets(targets);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private async resolveLastTarget(sessionKey: string): Promise<ResolvedTarget | null> {
|
|
471
|
+
const entry = await this.readSessionEntry(sessionKey);
|
|
472
|
+
if (!entry) {
|
|
473
|
+
return null;
|
|
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;
|
|
492
|
+
}
|
|
493
|
+
const accountId =
|
|
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
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private async readSessionEntry(sessionKey: string): Promise<SessionEntryLike | null> {
|
|
512
|
+
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
|
513
|
+
const storePath = this.api.runtime.channel.session.resolveStorePath(
|
|
514
|
+
this.api.config.session?.store,
|
|
515
|
+
{ agentId },
|
|
516
|
+
);
|
|
517
|
+
try {
|
|
518
|
+
const raw = await fs.readFile(storePath, "utf8");
|
|
519
|
+
const store = JSON.parse(raw) as Record<string, SessionEntryLike>;
|
|
520
|
+
if (!store || typeof store !== "object") {
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
return store[sessionKey] ?? store[sessionKey.toLowerCase()] ?? null;
|
|
524
|
+
} catch {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private async ensureTmuxAvailable(): Promise<void> {
|
|
530
|
+
if (this.tmuxChecked) {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
this.tmuxChecked = true;
|
|
534
|
+
try {
|
|
535
|
+
const res = await this.api.runtime.system.runCommandWithTimeout(["tmux", "-V"], {
|
|
536
|
+
timeoutMs: 2000,
|
|
537
|
+
});
|
|
538
|
+
this.tmuxAvailable = res.code === 0;
|
|
539
|
+
if (!this.tmuxAvailable) {
|
|
540
|
+
this.api.logger.warn("[tmux-watch] tmux not available (tmux -V failed)");
|
|
541
|
+
}
|
|
542
|
+
} catch (err) {
|
|
543
|
+
this.tmuxAvailable = false;
|
|
544
|
+
this.api.logger.warn(
|
|
545
|
+
`[tmux-watch] tmux not available: ${err instanceof Error ? err.message : String(err)}`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export function createTmuxWatchManager(api: OpenClawPluginApi) {
|
|
552
|
+
return new TmuxWatchManager(api);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function createRuntime(): WatchRuntime {
|
|
556
|
+
return {
|
|
557
|
+
running: false,
|
|
558
|
+
stableTicks: 0,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export function resolveIntervalMs(
|
|
563
|
+
subscription: TmuxWatchSubscription,
|
|
564
|
+
cfg: TmuxWatchConfig,
|
|
565
|
+
): number {
|
|
566
|
+
const captureIntervalSeconds =
|
|
567
|
+
typeof subscription.captureIntervalSeconds === "number" &&
|
|
568
|
+
Number.isFinite(subscription.captureIntervalSeconds)
|
|
569
|
+
? subscription.captureIntervalSeconds
|
|
570
|
+
: typeof cfg.captureIntervalSeconds === "number" && Number.isFinite(cfg.captureIntervalSeconds)
|
|
571
|
+
? cfg.captureIntervalSeconds
|
|
572
|
+
: undefined;
|
|
573
|
+
if (typeof captureIntervalSeconds === "number") {
|
|
574
|
+
return Math.max(200, Math.trunc(captureIntervalSeconds * 1000));
|
|
575
|
+
}
|
|
576
|
+
const raw =
|
|
577
|
+
typeof subscription.intervalMs === "number" && Number.isFinite(subscription.intervalMs)
|
|
578
|
+
? subscription.intervalMs
|
|
579
|
+
: typeof cfg.pollIntervalMs === "number" && Number.isFinite(cfg.pollIntervalMs)
|
|
580
|
+
? cfg.pollIntervalMs
|
|
581
|
+
: DEFAULT_CAPTURE_INTERVAL_SECONDS * 1000;
|
|
582
|
+
return Math.max(200, Math.trunc(raw));
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export function resolveStableCount(
|
|
586
|
+
subscription: TmuxWatchSubscription,
|
|
587
|
+
cfg: TmuxWatchConfig,
|
|
588
|
+
): number {
|
|
589
|
+
const rawCount =
|
|
590
|
+
typeof subscription.stableCount === "number" && Number.isFinite(subscription.stableCount)
|
|
591
|
+
? subscription.stableCount
|
|
592
|
+
: typeof cfg.stableCount === "number" && Number.isFinite(cfg.stableCount)
|
|
593
|
+
? cfg.stableCount
|
|
594
|
+
: undefined;
|
|
595
|
+
if (typeof rawCount === "number") {
|
|
596
|
+
return Math.max(1, Math.trunc(rawCount));
|
|
597
|
+
}
|
|
598
|
+
const stableSeconds =
|
|
599
|
+
typeof subscription.stableSeconds === "number" && Number.isFinite(subscription.stableSeconds)
|
|
600
|
+
? subscription.stableSeconds
|
|
601
|
+
: typeof cfg.stableSeconds === "number" && Number.isFinite(cfg.stableSeconds)
|
|
602
|
+
? cfg.stableSeconds
|
|
603
|
+
: undefined;
|
|
604
|
+
if (typeof stableSeconds === "number") {
|
|
605
|
+
const intervalMs = resolveIntervalMs(subscription, cfg);
|
|
606
|
+
return Math.max(1, Math.ceil((stableSeconds * 1000) / intervalMs));
|
|
607
|
+
}
|
|
608
|
+
return DEFAULT_STABLE_COUNT;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function resolveStableTicks(subscription: TmuxWatchSubscription, cfg: TmuxWatchConfig): number {
|
|
612
|
+
return resolveStableCount(subscription, cfg);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export function resolveStableDurationSeconds(
|
|
616
|
+
subscription: TmuxWatchSubscription,
|
|
617
|
+
cfg: TmuxWatchConfig,
|
|
618
|
+
): number {
|
|
619
|
+
const intervalMs = resolveIntervalMs(subscription, cfg);
|
|
620
|
+
const stableCount = resolveStableCount(subscription, cfg);
|
|
621
|
+
return (stableCount * intervalMs) / 1000;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function resolveCaptureLines(subscription: TmuxWatchSubscription, cfg: TmuxWatchConfig): number {
|
|
625
|
+
const raw =
|
|
626
|
+
typeof subscription.captureLines === "number" && Number.isFinite(subscription.captureLines)
|
|
627
|
+
? subscription.captureLines
|
|
628
|
+
: cfg.captureLines;
|
|
629
|
+
return Math.max(10, Math.trunc(raw));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function resolveStripAnsi(subscription: TmuxWatchSubscription, cfg: TmuxWatchConfig): boolean {
|
|
633
|
+
return typeof subscription.stripAnsi === "boolean" ? subscription.stripAnsi : cfg.stripAnsi;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function resolveNotifyMode(subscription: TmuxWatchSubscription, cfg: TmuxWatchConfig): NotifyMode {
|
|
637
|
+
const mode = subscription.notify?.mode;
|
|
638
|
+
if (mode === "last" || mode === "targets" || mode === "targets+last") {
|
|
639
|
+
return mode;
|
|
640
|
+
}
|
|
641
|
+
return cfg.notify.mode;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function resolveNotifyTargetList(
|
|
645
|
+
subscription: TmuxWatchSubscription,
|
|
646
|
+
cfg: TmuxWatchConfig,
|
|
647
|
+
): NotifyTarget[] {
|
|
648
|
+
const targets = subscription.notify?.targets;
|
|
649
|
+
if (Array.isArray(targets) && targets.length > 0) {
|
|
650
|
+
return sanitizeTargets(targets);
|
|
651
|
+
}
|
|
652
|
+
return sanitizeTargets(cfg.notify.targets);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function sanitizeTargets(targets: NotifyTarget[]): NotifyTarget[] {
|
|
656
|
+
const out: NotifyTarget[] = [];
|
|
657
|
+
for (const target of targets) {
|
|
658
|
+
const channel = typeof target.channel === "string" ? target.channel.trim() : "";
|
|
659
|
+
const to = typeof target.target === "string" ? target.target.trim() : "";
|
|
660
|
+
if (!channel || !to) {
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
out.push({
|
|
664
|
+
channel,
|
|
665
|
+
target: to,
|
|
666
|
+
accountId: typeof target.accountId === "string" ? target.accountId.trim() : undefined,
|
|
667
|
+
threadId: typeof target.threadId === "string" ? target.threadId.trim() : undefined,
|
|
668
|
+
label: typeof target.label === "string" ? target.label.trim() : undefined,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
return out;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function sanitizeSubscriptionInput(
|
|
675
|
+
input: Partial<TmuxWatchSubscription> & { target: string },
|
|
676
|
+
): TmuxWatchSubscription {
|
|
677
|
+
const notifyTargets = Array.isArray(input.notify?.targets)
|
|
678
|
+
? sanitizeTargets(input.notify?.targets)
|
|
679
|
+
: undefined;
|
|
680
|
+
return {
|
|
681
|
+
id: input.id?.trim() ?? "",
|
|
682
|
+
label: typeof input.label === "string" ? input.label.trim() : undefined,
|
|
683
|
+
note: typeof input.note === "string" ? input.note.trim() : undefined,
|
|
684
|
+
target: input.target.trim(),
|
|
685
|
+
socket: typeof input.socket === "string" ? input.socket.trim() : undefined,
|
|
686
|
+
sessionKey: typeof input.sessionKey === "string" ? input.sessionKey.trim() : undefined,
|
|
687
|
+
captureIntervalSeconds: input.captureIntervalSeconds,
|
|
688
|
+
intervalMs: input.intervalMs,
|
|
689
|
+
stableCount: input.stableCount,
|
|
690
|
+
stableSeconds: input.stableSeconds,
|
|
691
|
+
captureLines: input.captureLines,
|
|
692
|
+
stripAnsi: input.stripAnsi,
|
|
693
|
+
enabled: input.enabled,
|
|
694
|
+
notify:
|
|
695
|
+
input.notify?.mode || notifyTargets
|
|
696
|
+
? {
|
|
697
|
+
mode: input.notify?.mode,
|
|
698
|
+
targets: notifyTargets,
|
|
699
|
+
}
|
|
700
|
+
: undefined,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function resolveAgentIdFromSessionKey(sessionKey: string | undefined): string {
|
|
705
|
+
const raw = typeof sessionKey === "string" ? sessionKey.trim().toLowerCase() : "";
|
|
706
|
+
if (!raw) {
|
|
707
|
+
return "main";
|
|
708
|
+
}
|
|
709
|
+
if (raw.startsWith("agent:")) {
|
|
710
|
+
const parts = raw.split(":");
|
|
711
|
+
return parts[1] || "main";
|
|
712
|
+
}
|
|
713
|
+
return "main";
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function normalizeAgentId(value: string | undefined): string {
|
|
717
|
+
const trimmed = (value ?? "").trim().toLowerCase();
|
|
718
|
+
if (!trimmed) {
|
|
719
|
+
return "main";
|
|
720
|
+
}
|
|
721
|
+
const valid = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
|
722
|
+
if (valid.test(trimmed)) {
|
|
723
|
+
return trimmed;
|
|
724
|
+
}
|
|
725
|
+
return (
|
|
726
|
+
trimmed
|
|
727
|
+
.replace(/[^a-z0-9_-]+/gi, "-")
|
|
728
|
+
.replace(/^-+/, "")
|
|
729
|
+
.replace(/-+$/, "")
|
|
730
|
+
.slice(0, 64) || "main"
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function normalizeMainKey(value: string | undefined): string {
|
|
735
|
+
const trimmed = (value ?? "").trim().toLowerCase();
|
|
736
|
+
return trimmed || "main";
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function resolveDefaultSessionKey(cfg?: MinimalConfig): string {
|
|
740
|
+
if (cfg?.session?.scope === "global") {
|
|
741
|
+
return "global";
|
|
742
|
+
}
|
|
743
|
+
const agents = cfg?.agents?.list ?? [];
|
|
744
|
+
const defaultAgentId =
|
|
745
|
+
agents.find((entry) => entry?.default)?.id ?? agents[0]?.id ?? "main";
|
|
746
|
+
const agentId = normalizeAgentId(defaultAgentId);
|
|
747
|
+
const mainKey = normalizeMainKey(cfg?.session?.mainKey);
|
|
748
|
+
return `agent:${agentId}:${mainKey}`;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function normalizeSessionKey(input: string | undefined, cfg?: MinimalConfig) {
|
|
752
|
+
const trimmed = (input ?? "").trim();
|
|
753
|
+
if (!trimmed || trimmed === "main") {
|
|
754
|
+
return resolveDefaultSessionKey(cfg);
|
|
755
|
+
}
|
|
756
|
+
const mainKey = normalizeMainKey(cfg?.session?.mainKey);
|
|
757
|
+
if (trimmed === mainKey) {
|
|
758
|
+
return resolveDefaultSessionKey(cfg);
|
|
759
|
+
}
|
|
760
|
+
if (trimmed.toLowerCase() === "global" && cfg?.session?.scope === "global") {
|
|
761
|
+
return "global";
|
|
762
|
+
}
|
|
763
|
+
const lowered = trimmed.toLowerCase();
|
|
764
|
+
if (lowered.startsWith("agent:") || lowered.startsWith("subagent:") || lowered === "global") {
|
|
765
|
+
return lowered;
|
|
766
|
+
}
|
|
767
|
+
const defaultKey = resolveDefaultSessionKey(cfg);
|
|
768
|
+
const agentId = resolveAgentIdFromSessionKey(defaultKey);
|
|
769
|
+
return `agent:${normalizeAgentId(agentId)}:${lowered}`;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function hashOutput(output: string): string {
|
|
773
|
+
return createHash("sha256").update(output).digest("hex");
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
export function stripAnsi(input: string): string {
|
|
777
|
+
/* eslint-disable no-control-regex */
|
|
778
|
+
const sgr = new RegExp("\\u001b\\[[0-9;]*m", "g");
|
|
779
|
+
const osc8 = new RegExp("\\u001b]8;;.*?\\u001b\\\\|\\u001b]8;;\\u001b\\\\", "g");
|
|
780
|
+
/* eslint-enable no-control-regex */
|
|
781
|
+
return input.replace(osc8, "").replace(sgr, "");
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function parseThreadId(value: unknown): string | number | undefined {
|
|
785
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
786
|
+
return Math.trunc(value);
|
|
787
|
+
}
|
|
788
|
+
if (typeof value !== "string") {
|
|
789
|
+
return undefined;
|
|
790
|
+
}
|
|
791
|
+
const trimmed = value.trim();
|
|
792
|
+
if (!trimmed) {
|
|
793
|
+
return undefined;
|
|
794
|
+
}
|
|
795
|
+
if (/^\d+$/.test(trimmed)) {
|
|
796
|
+
return Number.parseInt(trimmed, 10);
|
|
797
|
+
}
|
|
798
|
+
return trimmed;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
export function truncateOutput(
|
|
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
|
+
}
|
|
819
|
+
|
|
820
|
+
function dedupeTargets(targets: ResolvedTarget[]): ResolvedTarget[] {
|
|
821
|
+
const seen = new Set<string>();
|
|
822
|
+
const out: ResolvedTarget[] = [];
|
|
823
|
+
for (const target of targets) {
|
|
824
|
+
const key = [
|
|
825
|
+
target.channel,
|
|
826
|
+
target.target,
|
|
827
|
+
target.accountId ?? "",
|
|
828
|
+
target.threadId ?? "",
|
|
829
|
+
].join("|");
|
|
830
|
+
if (seen.has(key)) {
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
seen.add(key);
|
|
834
|
+
out.push(target);
|
|
835
|
+
}
|
|
836
|
+
return out;
|
|
837
|
+
}
|