screenhand 0.5.0 → 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,213 @@
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
+ /**
18
+ * PlanRefiner — Self-improving plans.
19
+ *
20
+ * After a successful plan execution, diffs the original plan vs what actually
21
+ * happened (steps skipped, fallbacks used, extra retries). Produces a refined
22
+ * plan stripped of dead steps. After 3 successful refinements of the same goal
23
+ * pattern, graduates the plan to a playbook.
24
+ */
25
+ import * as fs from "node:fs";
26
+ import * as path from "node:path";
27
+ import { writeFileAtomicSync } from "../util/atomic-write.js";
28
+ const GRADUATION_THRESHOLD = 3;
29
+ const MAX_REFINED_PLANS = 200;
30
+ export class PlanRefiner {
31
+ stateDir;
32
+ cache = new Map();
33
+ filePath;
34
+ constructor(stateDir) {
35
+ this.stateDir = stateDir;
36
+ this.filePath = path.join(stateDir, "refined-plans.json");
37
+ this.load();
38
+ }
39
+ load() {
40
+ try {
41
+ if (fs.existsSync(this.filePath)) {
42
+ const data = JSON.parse(fs.readFileSync(this.filePath, "utf-8"));
43
+ if (Array.isArray(data)) {
44
+ for (const entry of data) {
45
+ if (entry.goalKey && typeof entry.goalKey === "string") {
46
+ this.cache.set(entry.goalKey, entry);
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
52
+ catch {
53
+ // Corrupted file — start fresh
54
+ this.cache.clear();
55
+ }
56
+ }
57
+ save() {
58
+ try {
59
+ // Evict oldest graduated plans if over limit
60
+ if (this.cache.size > MAX_REFINED_PLANS) {
61
+ const sorted = [...this.cache.entries()]
62
+ .sort((a, b) => new Date(a[1].lastRefinedAt).getTime() - new Date(b[1].lastRefinedAt).getTime());
63
+ while (this.cache.size > MAX_REFINED_PLANS && sorted.length > 0) {
64
+ const oldest = sorted.shift();
65
+ this.cache.delete(oldest[0]);
66
+ }
67
+ }
68
+ const data = [...this.cache.values()];
69
+ writeFileAtomicSync(this.filePath, JSON.stringify(data, null, 2));
70
+ }
71
+ catch (err) {
72
+ process.stderr.write(`[plan-refiner] Save failed: ${err instanceof Error ? err.message : String(err)}\n`);
73
+ }
74
+ }
75
+ normalizeGoal(description) {
76
+ return description.toLowerCase().trim().replace(/\s+/g, " ");
77
+ }
78
+ /**
79
+ * Refine a plan after successful execution.
80
+ * Extracts only the steps that actually completed (strips skipped/failed).
81
+ * Returns the refinement count and whether the plan was graduated.
82
+ */
83
+ refine(goal, result) {
84
+ if (!result.success)
85
+ return { refinementCount: 0, graduated: false };
86
+ const goalKey = this.normalizeGoal(goal.description);
87
+ // Extract completed steps from all subgoals
88
+ const completedSteps = [];
89
+ for (const sg of goal.subgoals) {
90
+ if (sg.plan) {
91
+ for (const step of sg.plan.steps) {
92
+ if (step.status === "completed") {
93
+ completedSteps.push({ tool: step.tool, params: step.params });
94
+ }
95
+ }
96
+ }
97
+ }
98
+ if (completedSteps.length === 0)
99
+ return { refinementCount: 0, graduated: false };
100
+ const existing = this.cache.get(goalKey);
101
+ const refinementCount = (existing?.refinementCount ?? 0) + 1;
102
+ const prevAvg = existing?.avgDurationMs ?? result.durationMs;
103
+ const avgDurationMs = Math.round((prevAvg * (refinementCount - 1) + result.durationMs) / refinementCount);
104
+ const refined = {
105
+ goalKey,
106
+ goalDescription: goal.description,
107
+ steps: completedSteps,
108
+ refinementCount,
109
+ avgDurationMs,
110
+ lastRefinedAt: new Date().toISOString(),
111
+ graduated: existing?.graduated ?? false,
112
+ };
113
+ this.cache.set(goalKey, refined);
114
+ this.save();
115
+ return { refinementCount, graduated: refined.graduated };
116
+ }
117
+ /**
118
+ * Check if a goal should graduate to a playbook.
119
+ * Returns the playbook if ready, null otherwise.
120
+ */
121
+ checkGraduation(goalDescription, bundleId, appName) {
122
+ const goalKey = this.normalizeGoal(goalDescription);
123
+ const refined = this.cache.get(goalKey);
124
+ if (!refined)
125
+ return null;
126
+ if (refined.graduated)
127
+ return null;
128
+ if (refined.refinementCount < GRADUATION_THRESHOLD)
129
+ return null;
130
+ // Graduate: create a playbook from the refined steps
131
+ const playbookSteps = [];
132
+ for (let i = 0; i < refined.steps.length; i++) {
133
+ const s = refined.steps[i];
134
+ const mapped = mapToolToPlaybookStep(s.tool, s.params, i);
135
+ if (mapped)
136
+ playbookSteps.push(mapped);
137
+ }
138
+ const playbook = {
139
+ id: `graduated_${goalKey.replace(/[^a-z0-9]/g, "_").slice(0, 40)}_${Date.now().toString(36)}`,
140
+ name: refined.goalDescription,
141
+ description: `Auto-graduated from ${refined.refinementCount} successful plan executions (avg ${refined.avgDurationMs}ms)`,
142
+ platform: appName.toLowerCase(),
143
+ bundleId,
144
+ version: "1.0.0",
145
+ steps: playbookSteps,
146
+ tags: [appName.toLowerCase(), "auto-graduated", "refined"],
147
+ successCount: refined.refinementCount,
148
+ failCount: 0,
149
+ selectors: {},
150
+ errors: [],
151
+ };
152
+ // Mark as graduated
153
+ refined.graduated = true;
154
+ this.save();
155
+ return playbook;
156
+ }
157
+ /** Get all refined plans (for debugging/status). */
158
+ getAll() {
159
+ return [...this.cache.values()];
160
+ }
161
+ /** Look up a refined plan for a goal. */
162
+ lookup(goalDescription) {
163
+ return this.cache.get(this.normalizeGoal(goalDescription)) ?? null;
164
+ }
165
+ }
166
+ /** Map MCP tool name + params to a PlaybookStep. Returns null if unmappable. */
167
+ function mapToolToPlaybookStep(tool, params, index) {
168
+ const TOOL_TO_ACTION = {
169
+ ui_press: "press",
170
+ click_text: "press",
171
+ click: "press",
172
+ click_with_fallback: "press",
173
+ type_text: "type_into",
174
+ type_with_fallback: "type_into",
175
+ key: "key",
176
+ browser_navigate: "navigate",
177
+ browser_click: "browser_click",
178
+ browser_type: "browser_type",
179
+ browser_human_click: "browser_human_click",
180
+ browser_js: "browser_js",
181
+ menu_click: "menu_click",
182
+ scroll: "scroll",
183
+ scroll_with_fallback: "scroll",
184
+ screenshot: "screenshot",
185
+ applescript: "applescript",
186
+ };
187
+ const action = TOOL_TO_ACTION[tool];
188
+ if (!action)
189
+ return null; // focus, launch, etc. are not playbook-able
190
+ const step = {
191
+ action,
192
+ description: `Step ${index + 1}: ${tool}`,
193
+ };
194
+ // Map params to playbook step fields
195
+ const target = params.title ?? params.text ?? params.selector ?? params.target;
196
+ if (typeof target === "string")
197
+ step.target = target;
198
+ if (typeof params.text === "string")
199
+ step.text = params.text;
200
+ if (typeof params.url === "string")
201
+ step.url = params.url;
202
+ if (typeof params.combo === "string")
203
+ step.keys = [params.combo];
204
+ if (typeof params.key === "string")
205
+ step.keys = [params.key];
206
+ if (Array.isArray(params.menuPath))
207
+ step.menuPath = params.menuPath;
208
+ if (typeof params.direction === "string")
209
+ step.direction = params.direction;
210
+ if (typeof params.amount === "number")
211
+ step.amount = params.amount;
212
+ return step;
213
+ }
@@ -23,9 +23,15 @@ export class PlaybookEngine {
23
23
  appleScriptRunner;
24
24
  /** Enable observer-based popup checks before each step */
25
25
  popupCheckEnabled = false;
26
+ /** Optional callback invoked after each step with outcome — wires into learning/memory. */
27
+ onOutcome;
26
28
  constructor(runtime) {
27
29
  this.runtime = runtime;
28
30
  }
31
+ /** Set callback for step outcomes so PlaybookEngine feeds learning. */
32
+ setOutcomeCallback(cb) {
33
+ this.onOutcome = cb;
34
+ }
29
35
  /** Enable/disable pre-step popup detection via observer daemon */
30
36
  setPopupCheck(enabled) {
31
37
  this.popupCheckEnabled = enabled;
@@ -61,6 +67,9 @@ export class PlaybookEngine {
61
67
  }
62
68
  const result = await this.executeStep(sessionId, step, playbook.cdpPort);
63
69
  stepsCompleted++;
70
+ // Report success to learning pipeline
71
+ if (this.onOutcome)
72
+ this.onOutcome(step, true, null);
64
73
  if (options.onStep) {
65
74
  options.onStep(i, step, result);
66
75
  }
@@ -68,6 +77,8 @@ export class PlaybookEngine {
68
77
  if (step.verify) {
69
78
  const verified = await this.verifyStep(sessionId, step);
70
79
  if (!verified && !step.optional) {
80
+ if (this.onOutcome)
81
+ this.onOutcome(step, false, `Verification failed at step ${i}`);
71
82
  return {
72
83
  playbook: playbook.id,
73
84
  success: false,
@@ -83,10 +94,14 @@ export class PlaybookEngine {
83
94
  await sleep(STEP_DELAY_MS);
84
95
  }
85
96
  catch (err) {
97
+ const errMsg = err instanceof Error ? err.message : String(err);
98
+ // Report failure to learning pipeline
99
+ if (this.onOutcome)
100
+ this.onOutcome(step, false, errMsg);
86
101
  if (step.optional) {
87
102
  stepsCompleted++;
88
103
  if (options.onStep) {
89
- options.onStep(i, step, `Skipped (optional): ${err instanceof Error ? err.message : String(err)}`);
104
+ options.onStep(i, step, `Skipped (optional): ${errMsg}`);
90
105
  }
91
106
  continue;
92
107
  }
@@ -96,7 +111,7 @@ export class PlaybookEngine {
96
111
  stepsCompleted,
97
112
  totalSteps: playbook.steps.length,
98
113
  failedAtStep: i,
99
- error: err instanceof Error ? err.message : String(err),
114
+ error: errMsg,
100
115
  durationMs: Date.now() - start,
101
116
  };
102
117
  }
@@ -115,7 +130,7 @@ export class PlaybookEngine {
115
130
  */
116
131
  /** Tools that the PlaybookEngine refuses to execute (defense in depth). */
117
132
  static BLOCKED_ACTIONS = new Set([
118
- "applescript", "browser_stealth",
133
+ "browser_stealth",
119
134
  "memory_save", "memory_clear", "memory_snapshot",
120
135
  "supervisor_start", "supervisor_stop", "supervisor_install", "supervisor_uninstall",
121
136
  "job_create", "worker_start",
@@ -98,7 +98,9 @@ export class PlaybookRecorder {
98
98
  try {
99
99
  await this.pollAXState();
100
100
  }
101
- catch { /* non-fatal */ }
101
+ catch (e) {
102
+ process.stderr.write(`[recorder] AX poll failed: ${e instanceof Error ? e.message : String(e)}\n`);
103
+ }
102
104
  }, AX_POLL_INTERVAL_MS);
103
105
  // Start screenshot capture (slower — every 2.5s)
104
106
  if (this.captureScreenshots) {
@@ -108,7 +110,9 @@ export class PlaybookRecorder {
108
110
  try {
109
111
  await this.takeScreenshot();
110
112
  }
111
- catch { /* non-fatal */ }
113
+ catch (e) {
114
+ process.stderr.write(`[recorder] screenshot capture failed: ${e instanceof Error ? e.message : String(e)}\n`);
115
+ }
112
116
  }, SCREENSHOT_INTERVAL_MS);
113
117
  }
114
118
  }
@@ -123,7 +127,9 @@ export class PlaybookRecorder {
123
127
  try {
124
128
  await this.takeScreenshot();
125
129
  }
126
- catch { /* ignore */ }
130
+ catch (e) {
131
+ process.stderr.write(`[recorder] final screenshot failed: ${e instanceof Error ? e.message : String(e)}\n`);
132
+ }
127
133
  }
128
134
  this.log(`Recording stopped. ${this.events.length} events, ${this.screenshots.length} screenshots captured.`);
129
135
  // Convert raw events + screenshots to playbook steps via AI
@@ -190,7 +196,9 @@ export class PlaybookRecorder {
190
196
  }
191
197
  }
192
198
  }
193
- catch { /* ignore */ }
199
+ catch (e) {
200
+ process.stderr.write(`[recorder] app list poll failed: ${e instanceof Error ? e.message : String(e)}\n`);
201
+ }
194
202
  // 2. Get accessibility tree — find focused element and text field values
195
203
  try {
196
204
  const tree = await this.runtime.elementTree({ sessionId: this.sessionId, maxDepth: 4 });
@@ -238,7 +246,9 @@ export class PlaybookRecorder {
238
246
  this.prevWindowTitle = title;
239
247
  }
240
248
  }
241
- catch { /* ignore */ }
249
+ catch (e) {
250
+ process.stderr.write(`[recorder] window title poll failed: ${e instanceof Error ? e.message : String(e)}\n`);
251
+ }
242
252
  }
243
253
  // ── Screenshot Capture ──
244
254
  async takeScreenshot() {
@@ -253,7 +263,9 @@ export class PlaybookRecorder {
253
263
  this.screenshots.push(record);
254
264
  }
255
265
  }
256
- catch { /* non-fatal */ }
266
+ catch (e) {
267
+ process.stderr.write(`[recorder] takeScreenshot failed: ${e instanceof Error ? e.message : String(e)}\n`);
268
+ }
257
269
  }
258
270
  // ── State Capture ──
259
271
  async captureState(label) {
@@ -271,7 +283,9 @@ export class PlaybookRecorder {
271
283
  }
272
284
  }
273
285
  }
274
- catch { /* ignore */ }
286
+ catch (e) {
287
+ process.stderr.write(`[recorder] initial app state capture failed: ${e instanceof Error ? e.message : String(e)}\n`);
288
+ }
275
289
  // Capture initial tree state
276
290
  try {
277
291
  const tree = await this.runtime.elementTree({ sessionId: this.sessionId, maxDepth: 4 });
@@ -281,7 +295,9 @@ export class PlaybookRecorder {
281
295
  this.prevTextFields = collectTextFields(tree.data);
282
296
  }
283
297
  }
284
- catch { /* ignore */ }
298
+ catch (e) {
299
+ process.stderr.write(`[recorder] initial tree state capture failed: ${e instanceof Error ? e.message : String(e)}\n`);
300
+ }
285
301
  // Take initial screenshot
286
302
  if (this.captureScreenshots) {
287
303
  await this.takeScreenshot();
@@ -97,7 +97,9 @@ export class PlaybookRunner {
97
97
  if (shot.ok)
98
98
  screenshotInfo = `Screenshot saved to: ${shot.data.path}`;
99
99
  }
100
- catch { /* ignore */ }
100
+ catch (e) {
101
+ process.stderr.write(`[playbook-runner] screenshot for recovery failed: ${e instanceof Error ? e.message : String(e)}\n`);
102
+ }
101
103
  // Get current page state
102
104
  let pageState = "";
103
105
  try {
@@ -106,7 +108,9 @@ export class PlaybookRunner {
106
108
  pageState = JSON.stringify(tree.data).slice(0, 3000);
107
109
  }
108
110
  }
109
- catch { /* ignore */ }
111
+ catch (e) {
112
+ process.stderr.write(`[playbook-runner] elementTree for recovery failed: ${e instanceof Error ? e.message : String(e)}\n`);
113
+ }
110
114
  // Build rich context from playbook metadata
111
115
  const playbookContext = buildPlaybookContext(playbook);
112
116
  const prompt = `A playbook automation failed. Help me recover.
@@ -221,7 +225,9 @@ Or if unrecoverable, respond with: { "unrecoverable": true, "reason": "..." }`;
221
225
  if (tree.ok)
222
226
  pageState = JSON.stringify(tree.data).slice(0, 4000);
223
227
  }
224
- catch { /* ignore */ }
228
+ catch (e) {
229
+ process.stderr.write(`[playbook-runner] elementTree in AI loop failed: ${e instanceof Error ? e.message : String(e)}\n`);
230
+ }
225
231
  const prompt = `Task: ${task}
226
232
 
227
233
  Steps taken so far:
@@ -63,6 +63,10 @@ export class PlaybookStore {
63
63
  }
64
64
  }
65
65
  }
66
+ /** Reload all playbooks from disk (call after ingestion writes new data). */
67
+ reload() {
68
+ this.load();
69
+ }
66
70
  /** Get all loaded playbooks. */
67
71
  getAll() {
68
72
  return [...this.playbooks.values()];
@@ -274,6 +278,10 @@ export class PlaybookStore {
274
278
  if (raw.flows && typeof raw.flows === "object") {
275
279
  return this.convertLegacy(raw, filename);
276
280
  }
281
+ // Reference format: has selectors but no steps/flows (e.g. *-explore.json from platform_explore)
282
+ if (raw.selectors && typeof raw.selectors === "object") {
283
+ return this.convertLegacy(raw, filename);
284
+ }
277
285
  return null;
278
286
  }
279
287
  /**
@@ -109,7 +109,9 @@ export class RecoveryEngine {
109
109
  if (undoStrategy)
110
110
  candidates.push(undoStrategy);
111
111
  }
112
- catch { /* best-effort */ }
112
+ catch (e) {
113
+ process.stderr.write(`[recovery] buildUndoStrategy failed: ${e instanceof Error ? e.message : String(e)}\n`);
114
+ }
113
115
  }
114
116
  // Reference strategies second (app-specific)
115
117
  if (blocker.bundleId) {
@@ -355,7 +357,9 @@ export class RecoveryEngine {
355
357
  break;
356
358
  }
357
359
  }
358
- catch { /* skip malformed */ }
360
+ catch (e) {
361
+ process.stderr.write(`[recovery] malformed reference file: ${e instanceof Error ? e.message : String(e)}\n`);
362
+ }
359
363
  }
360
364
  }
361
365
  catch { /* dir doesn't exist */ }
@@ -366,7 +370,9 @@ export class RecoveryEngine {
366
370
  try {
367
371
  this.memory.recordError(`recovery:${event.strategyId}`, event.error ?? "", event.success ? event.strategyLabel : null, event.blocker.bundleId ?? undefined);
368
372
  }
369
- catch { /* best-effort */ }
373
+ catch (e) {
374
+ process.stderr.write(`[recovery] recordEvent failed: ${e instanceof Error ? e.message : String(e)}\n`);
375
+ }
370
376
  }
371
377
  }
372
378
  function sleep(ms) {
@@ -339,7 +339,9 @@ export class AppMap {
339
339
  fs.mkdirSync(this.config.mapsDir, { recursive: true });
340
340
  writeFileAtomicSync(this.ladderFilePath(bundleId), JSON.stringify(data, null, 2));
341
341
  }
342
- catch { /* non-critical */ }
342
+ catch (e) {
343
+ process.stderr.write(`[app-map] saveGeneratedLadder failed: ${e instanceof Error ? e.message : String(e)}\n`);
344
+ }
343
345
  }
344
346
  // ── Create ────────────────────────────────────────────────────────
345
347
  createEmpty(bundleId, appName, version = "unknown") {
@@ -1591,7 +1593,9 @@ export class AppMap {
1591
1593
  writeFileAtomicSync(this.filePath(data.app), JSON.stringify(data, null, 2) + "\n");
1592
1594
  this.dirty.delete(data.app); // Only remove the one we just wrote
1593
1595
  }
1594
- catch { /* non-fatal — will be picked up by next debounced save */ }
1596
+ catch (e) {
1597
+ process.stderr.write(`[app-map] urgent mastery save failed: ${e instanceof Error ? e.message : String(e)}\n`);
1598
+ }
1595
1599
  }
1596
1600
  }
1597
1601
  refreshMastery(bundleId) {
@@ -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
  }