tmux-watch 2026.2.4 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/manager.ts +139 -50
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmux-watch",
3
- "version": "2026.2.4",
3
+ "version": "2026.2.5",
4
4
  "type": "module",
5
5
  "description": "OpenClaw tmux output watchdog plugin",
6
6
  "license": "MIT",
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 last = await this.resolveLastTarget(sessionKey);
459
- if (last) {
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 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;
469
+ private async resolveLastTargets(sessionKey: string): Promise<ResolvedTarget[]> {
470
+ const store = await this.readSessionStore(sessionKey);
471
+ if (!store) {
472
+ return [];
492
473
  }
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
- };
474
+ return resolveLastTargetsFromStore({ store, sessionKey });
509
475
  }
510
476
 
511
- private async readSessionEntry(sessionKey: string): Promise<SessionEntryLike | null> {
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[sessionKey] ?? store[sessionKey.toLowerCase()] ?? null;
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
  }