screenhand 0.4.9 → 0.5.2

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.
@@ -0,0 +1,144 @@
1
+ // Copyright (C) 2025 Clazro Technology Private Limited
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ //
4
+ // This file is part of ScreenHand.
5
+ //
6
+ // ScreenHand is free software: you can redistribute it and/or modify
7
+ // it under the terms of the GNU Affero General Public License as
8
+ // published by the Free Software Foundation, version 3.
9
+ //
10
+ // ScreenHand is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with ScreenHand. If not, see <https://www.gnu.org/licenses/>.
17
+ export class StateWatcher {
18
+ worldModel;
19
+ execute;
20
+ rules = new Map();
21
+ interval = null;
22
+ pollMs;
23
+ constructor(worldModel, execute, pollMs = 2_000) {
24
+ this.worldModel = worldModel;
25
+ this.execute = execute;
26
+ this.pollMs = pollMs;
27
+ }
28
+ static MAX_RULES = 50;
29
+ /**
30
+ * Register a watch rule. Returns the rule ID for later removal.
31
+ */
32
+ register(rule) {
33
+ if (this.rules.size >= StateWatcher.MAX_RULES && !this.rules.has(rule.id)) {
34
+ throw new Error(`Maximum watch rules (${StateWatcher.MAX_RULES}) reached. Remove existing rules first.`);
35
+ }
36
+ this.rules.set(rule.id, {
37
+ rule,
38
+ fireCount: 0,
39
+ lastFiredAt: 0,
40
+ });
41
+ return rule.id;
42
+ }
43
+ /** Register a convenience rule: fire action when a control with matching title appears. */
44
+ watchForElement(id, elementTitle, action, bundleId) {
45
+ const titleLower = elementTitle.toLowerCase();
46
+ return this.register({
47
+ id,
48
+ description: `Watch for element "${elementTitle}"`,
49
+ condition: (state) => {
50
+ for (const win of state.windows.values()) {
51
+ for (const ctrl of win.controls.values()) {
52
+ if (ctrl.label?.value?.toLowerCase().includes(titleLower)) {
53
+ return true;
54
+ }
55
+ }
56
+ }
57
+ return false;
58
+ },
59
+ action,
60
+ maxFires: 1,
61
+ cooldownMs: 10_000,
62
+ ...(bundleId ? { bundleId } : {}),
63
+ });
64
+ }
65
+ /** Register: fire action when a dialog appears with matching text. */
66
+ watchForDialog(id, titlePattern, action) {
67
+ return this.register({
68
+ id,
69
+ description: `Watch for dialog matching ${titlePattern}`,
70
+ condition: (state) => state.activeDialogs.some((d) => titlePattern.test(d.title ?? "")),
71
+ action,
72
+ maxFires: 0, // unlimited — dialogs can recur
73
+ cooldownMs: 5_000,
74
+ });
75
+ }
76
+ /** Remove a watch rule by ID. */
77
+ unregister(id) {
78
+ return this.rules.delete(id);
79
+ }
80
+ /** Remove all rules. */
81
+ clear() {
82
+ this.rules.clear();
83
+ }
84
+ /** Get all registered rules. */
85
+ getRules() {
86
+ return [...this.rules.values()].map((rs) => ({
87
+ id: rs.rule.id,
88
+ description: rs.rule.description,
89
+ fireCount: rs.fireCount,
90
+ }));
91
+ }
92
+ /** Start the polling loop. */
93
+ start() {
94
+ if (this.interval)
95
+ return;
96
+ this.interval = setInterval(() => {
97
+ void this.tick();
98
+ }, this.pollMs);
99
+ }
100
+ /** Stop the polling loop. */
101
+ stop() {
102
+ if (this.interval) {
103
+ clearInterval(this.interval);
104
+ this.interval = null;
105
+ }
106
+ }
107
+ get isRunning() {
108
+ return this.interval !== null;
109
+ }
110
+ async tick() {
111
+ const state = this.worldModel.getState();
112
+ const now = Date.now();
113
+ const focusedBundleId = state.focusedApp?.bundleId;
114
+ for (const [id, rs] of this.rules) {
115
+ // Max fires check
116
+ if (rs.rule.maxFires > 0 && rs.fireCount >= rs.rule.maxFires)
117
+ continue;
118
+ // Cooldown check
119
+ if (now - rs.lastFiredAt < rs.rule.cooldownMs)
120
+ continue;
121
+ // BundleId filter
122
+ if (rs.rule.bundleId && rs.rule.bundleId !== focusedBundleId)
123
+ continue;
124
+ try {
125
+ if (rs.rule.condition(state)) {
126
+ rs.fireCount++;
127
+ rs.lastFiredAt = now;
128
+ process.stderr.write(`[state-watcher] Rule "${id}" fired (${rs.fireCount}x): ${rs.rule.description}\n`);
129
+ // Fire and forget — don't block the poll loop
130
+ this.execute(rs.rule.action.tool, rs.rule.action.params).catch((err) => {
131
+ process.stderr.write(`[state-watcher] Rule "${id}" action failed: ${err instanceof Error ? err.message : String(err)}\n`);
132
+ });
133
+ // Remove exhausted rules
134
+ if (rs.rule.maxFires > 0 && rs.fireCount >= rs.rule.maxFires) {
135
+ this.rules.delete(id);
136
+ }
137
+ }
138
+ }
139
+ catch (err) {
140
+ process.stderr.write(`[state-watcher] Rule "${id}" condition threw: ${err instanceof Error ? err.message : String(err)}\n`);
141
+ }
142
+ }
143
+ }
144
+ }
@@ -297,7 +297,7 @@ export class SessionSupervisor {
297
297
  this.log(`Poll error (${this.consecutiveErrors}/${this.config.maxConsecutiveErrors}): ${err instanceof Error ? err.message : String(err)}`);
298
298
  if (this.consecutiveErrors >= this.config.maxConsecutiveErrors) {
299
299
  this.log("Max consecutive errors reached — stopping supervisor");
300
- this.stop().catch(() => { });
300
+ this.stop().catch((e) => { process.stderr.write(`[supervisor] stop after max errors failed: ${e instanceof Error ? e.message : String(e)}\n`); });
301
301
  }
302
302
  }
303
303
  }