screenhand 0.3.2 → 0.3.4

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.
@@ -80,6 +80,31 @@ function redactStrings(strings) {
80
80
  * Atomic writes via writeFileAtomicSync + readJsonWithRecovery for
81
81
  * crash safety.
82
82
  */
83
+ // Max page:: zones per app (separate from maxZonesPerApp to prevent title explosion)
84
+ const MAX_PAGE_ZONES = 20;
85
+ /**
86
+ * Normalize dynamic page context strings to prevent zone explosion.
87
+ * Collapses UUIDs, timestamps, numeric IDs, file extensions, and hashes
88
+ * into stable placeholders so similar pages share a zone.
89
+ */
90
+ function normalizePageContext(ctx) {
91
+ return ctx
92
+ // UUIDs: 8-4-4-4-12 hex
93
+ .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<id>")
94
+ // Long hex hashes (8+ chars)
95
+ .replace(/\b[0-9a-f]{8,}\b/gi, "<hash>")
96
+ // ISO timestamps
97
+ .replace(/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?/g, "<time>")
98
+ // Date-like patterns
99
+ .replace(/\d{4}[-/]\d{2}[-/]\d{2}/g, "<date>")
100
+ // Standalone numeric IDs (3+ digits)
101
+ .replace(/\b\d{3,}\b/g, "<num>")
102
+ // File extensions at the end
103
+ .replace(/\.\w{1,5}$/, "")
104
+ // Collapse whitespace
105
+ .replace(/\s+/g, " ")
106
+ .trim();
107
+ }
83
108
  export class AppMap {
84
109
  config;
85
110
  cache = new Map();
@@ -87,6 +112,8 @@ export class AppMap {
87
112
  saveTimer = null;
88
113
  /** Cache of auto-generated ladders (from reference files) */
89
114
  generatedLadderCache = new Map();
115
+ /** Wire #11: TopologyPolicy reference for Bayesian edge scoring */
116
+ topologyPolicy = null;
90
117
  constructor(config) {
91
118
  this.config = {
92
119
  ...DEFAULT_APP_MAP_CONFIG,
@@ -98,6 +125,10 @@ export class AppMap {
98
125
  init() {
99
126
  fs.mkdirSync(this.config.mapsDir, { recursive: true });
100
127
  }
128
+ /** Wire #11: Connect TopologyPolicy for Bayesian edge scoring */
129
+ setTopologyPolicy(tp) {
130
+ this.topologyPolicy = tp;
131
+ }
101
132
  // ── Load / Save ───────────────────────────────────────────────────
102
133
  load(bundleId) {
103
134
  const cached = this.cache.get(bundleId);
@@ -285,6 +316,131 @@ export class AppMap {
285
316
  this.save(data);
286
317
  return data;
287
318
  }
319
+ /**
320
+ * Wire #12: L6→L7 — Bootstrap pre-built zones from a MenuScanner result.
321
+ * Creates toolbar zone + per-menu sub-zones so new apps start with structure.
322
+ * Only bootstraps if no map exists yet — never overwrites existing data.
323
+ * Capped at 10 zones to prevent menu-heavy apps from flooding.
324
+ */
325
+ bootstrapFromMenuScan(bundleId, appName, scanResult) {
326
+ // Only bootstrap if no menu_bar zone exists yet — perception may have
327
+ // auto-created other zones, but menu structure is separate
328
+ const existing = this.load(bundleId);
329
+ if (existing?.zones["menu_bar"])
330
+ return false;
331
+ const data = existing ?? this.createEmpty(bundleId, appName);
332
+ const now = new Date().toISOString();
333
+ // Filter out Apple menu, empty titles, and disabled items
334
+ const topMenus = scanResult.menuTree.filter((m) => m.title && m.title !== "Apple" && m.enabled !== false);
335
+ let zoneCount = 0;
336
+ // Sanitize a menu title for use as zone key or element label
337
+ const sanitize = (title) => redactPII(title)
338
+ .replace(/[\x00-\x1f\x7f-\x9f\u200b-\u200f\u202a-\u202e\ufeff]/g, "")
339
+ .slice(0, 100);
340
+ // 1. Create toolbar zone with top-level menu names as elements
341
+ const toolbarElements = [];
342
+ for (let i = 0; i < topMenus.length && i < 15; i++) {
343
+ const menu = topMenus[i];
344
+ const safeTitle = sanitize(menu.title);
345
+ if (!safeTitle)
346
+ continue;
347
+ toolbarElements.push({
348
+ label: safeTitle,
349
+ relativeX: Math.min(0.95, 0.02 + i * 0.08),
350
+ relativeY: 0.02,
351
+ anchor: "top-left",
352
+ ocrBackup: safeTitle,
353
+ successCount: 0,
354
+ failCount: 0,
355
+ lastInteracted: now,
356
+ sessionsSinceUse: 0,
357
+ });
358
+ }
359
+ data.zones["menu_bar"] = {
360
+ relativePosition: { top: 0, left: 0, width: 1, height: 0.04 },
361
+ type: "toolbar",
362
+ elements: toolbarElements,
363
+ verified: false,
364
+ lastSeen: now,
365
+ };
366
+ zoneCount++;
367
+ // 2. Create per-menu sub-zones with child items as elements
368
+ const seenZoneKeys = new Set();
369
+ for (let i = 0; i < topMenus.length && zoneCount < 10; i++) {
370
+ const menu = topMenus[i];
371
+ if (!menu.children || menu.children.length === 0)
372
+ continue;
373
+ // Filter disabled children
374
+ const enabledChildren = menu.children.filter((c) => c.title && c.enabled !== false);
375
+ if (enabledChildren.length === 0)
376
+ continue;
377
+ const menuElements = [];
378
+ for (let j = 0; j < enabledChildren.length && j < 20; j++) {
379
+ const child = enabledChildren[j];
380
+ const safeChildTitle = sanitize(child.title);
381
+ if (!safeChildTitle)
382
+ continue;
383
+ menuElements.push({
384
+ label: safeChildTitle,
385
+ relativeX: Math.min(0.95, 0.02 + i * 0.08),
386
+ relativeY: Math.min(0.95, 0.06 + j * 0.03),
387
+ anchor: "top-left",
388
+ ocrBackup: safeChildTitle,
389
+ successCount: 0,
390
+ failCount: 0,
391
+ lastInteracted: now,
392
+ sessionsSinceUse: 0,
393
+ });
394
+ }
395
+ // Sanitize zone key and deduplicate
396
+ const baseKey = `menu::${sanitize(menu.title).toLowerCase().replace(/\s+/g, "_")}`;
397
+ let zoneKey = baseKey;
398
+ if (seenZoneKeys.has(zoneKey)) {
399
+ zoneKey = `${baseKey}_${i}`;
400
+ }
401
+ seenZoneKeys.add(zoneKey);
402
+ // Skip if zone already exists (perception may have built it with verified data)
403
+ if (data.zones[zoneKey]) {
404
+ seenZoneKeys.add(zoneKey);
405
+ continue;
406
+ }
407
+ data.zones[zoneKey] = {
408
+ relativePosition: {
409
+ top: 0.04,
410
+ left: Math.min(0.9, i * 0.08),
411
+ width: 0.15,
412
+ height: Math.min(0.5, enabledChildren.length * 0.03 + 0.02),
413
+ },
414
+ type: "menu",
415
+ elements: menuElements,
416
+ verified: false,
417
+ lastSeen: now,
418
+ };
419
+ zoneCount++;
420
+ }
421
+ // 3. Record initial feature mastery at depth 2 for menu-derived features
422
+ const featureMap = {
423
+ file: "file_management", edit: "editing", view: "view_control",
424
+ window: "window_management", help: "help_usage",
425
+ };
426
+ for (const menu of topMenus) {
427
+ const featureId = featureMap[menu.title.toLowerCase()];
428
+ if (featureId && !data.featureMastery[featureId]) {
429
+ data.featureMastery[featureId] = {
430
+ depth: 2,
431
+ confidence: 0.3,
432
+ repeatCount: 0,
433
+ workflowCount: 0,
434
+ healingCount: 0,
435
+ failCount: 0,
436
+ lastSeen: now,
437
+ lastVerified: null,
438
+ };
439
+ }
440
+ }
441
+ this.save(data);
442
+ return true;
443
+ }
288
444
  // ── Zone Operations ───────────────────────────────────────────────
289
445
  addZone(bundleId, zoneKey, zone) {
290
446
  const data = this.ensureLoaded(bundleId);
@@ -417,7 +573,7 @@ export class AppMap {
417
573
  let zone = data.zones[zoneKey];
418
574
  if (!zone && zoneKey === "auto") {
419
575
  const targetZoneKey = pageContext
420
- ? `page::${pageContext}`
576
+ ? `page::${normalizePageContext(pageContext)}`
421
577
  : "auto_discovered";
422
578
  // When page context is known, prefer the page-specific zone
423
579
  // This ensures elements migrate OUT of auto_discovered into proper page zones
@@ -428,9 +584,9 @@ export class AppMap {
428
584
  zone = pageZone;
429
585
  }
430
586
  else {
431
- // Element might be in auto_discovered that's OK, we'll create a new
432
- // entry in the page zone to gradually migrate elements to proper zones
433
- if (Object.keys(data.zones).length >= this.config.maxZonesPerApp) {
587
+ // Check page:: zone count separately to prevent title explosion
588
+ const pageZoneCount = Object.keys(data.zones).filter((k) => k.startsWith("page::")).length;
589
+ if (Object.keys(data.zones).length >= this.config.maxZonesPerApp || pageZoneCount >= MAX_PAGE_ZONES) {
434
590
  // At zone limit — fall back to auto_discovered
435
591
  zone = data.zones["auto_discovered"];
436
592
  if (!zone) {
@@ -491,8 +647,8 @@ export class AppMap {
491
647
  return;
492
648
  el = {
493
649
  label,
494
- relativeX: 0,
495
- relativeY: 0,
650
+ relativeX: -1,
651
+ relativeY: -1,
496
652
  anchor: "top-left",
497
653
  ocrBackup: label,
498
654
  successCount: 0,
@@ -620,16 +776,17 @@ export class AppMap {
620
776
  }
621
777
  /**
622
778
  * Find the contract for an element across all zones.
623
- * Returns the first matching contract (by elementLabel), or null.
779
+ * When action is provided, only returns contracts matching that action type.
780
+ * Falls back to any-action match when action is omitted.
624
781
  */
625
- getContract(bundleId, elementLabel) {
782
+ getContract(bundleId, elementLabel, action) {
626
783
  const data = this.ensureLoaded(bundleId);
627
784
  if (!data)
628
785
  return null;
629
786
  for (const [zoneKey, zone] of Object.entries(data.zones)) {
630
787
  if (!zone.contracts)
631
788
  continue;
632
- const contract = zone.contracts.find((c) => c.elementLabel === elementLabel);
789
+ const contract = zone.contracts.find((c) => c.elementLabel === elementLabel && (action == null || c.action === action));
633
790
  if (contract)
634
791
  return { zone: zoneKey, contract };
635
792
  }
@@ -750,12 +907,28 @@ export class AppMap {
750
907
  // V2: Redact PII from page names before persistence
751
908
  fromPage = redactPII(fromPage);
752
909
  toPage = redactPII(toPage);
753
- // Re-check after redaction in case both pages redact to the same string
754
- if (fromPage === toPage)
910
+ // Sanitize: strip control chars, cap length to prevent zone key explosion
911
+ const sanitizeTitle = (t) => t.replace(/[\x00-\x1f\x7f-\x9f\u200b-\u200f\u202a-\u202e\ufeff]/g, "").slice(0, 100);
912
+ fromPage = sanitizeTitle(fromPage);
913
+ toPage = sanitizeTitle(toPage);
914
+ // Re-check after sanitization in case both pages redact/truncate to the same string
915
+ if (!fromPage || !toPage || fromPage === toPage)
755
916
  return;
756
917
  const data = this.ensureLoaded(bundleId);
757
918
  if (!data)
758
919
  return;
920
+ // Handle initial page entry (first page after app launch)
921
+ if (fromPage === "__initial__") {
922
+ // Just ensure the initial page node exists — no edge from __initial__
923
+ if (!data.navigationGraph.nodes[toPage]) {
924
+ data.navigationGraph.nodes[toPage] = {
925
+ type: "window",
926
+ description: toPage,
927
+ };
928
+ this.save(data);
929
+ }
930
+ return;
931
+ }
759
932
  // Find existing edge with same from/action/to
760
933
  const existing = data.navigationGraph.edges.find((e) => e.from === fromPage && e.action === action && e.to === toPage);
761
934
  if (existing) {
@@ -846,8 +1019,41 @@ export class AppMap {
846
1019
  edge.failCount++;
847
1020
  }
848
1021
  edge.lastUsed = new Date().toISOString();
1022
+ // Wire #11: stamp Bayesian score from TopologyPolicy if available
1023
+ if (this.topologyPolicy) {
1024
+ const entries = this.topologyPolicy.query(bundleId, from);
1025
+ const match = entries.find((e) => e.action === action && e.toNode === to);
1026
+ if (match) {
1027
+ edge.topologyScore = match.score;
1028
+ }
1029
+ }
849
1030
  this.save(data);
850
1031
  }
1032
+ /**
1033
+ * Wire #11: Get reliability score for a nav edge.
1034
+ * Prefers TopologyPolicy Bayesian score when available,
1035
+ * falls back to simple success ratio from AppMap edge data.
1036
+ */
1037
+ getEdgeScore(bundleId, from, action, to) {
1038
+ // Prefer live TopologyPolicy score
1039
+ if (this.topologyPolicy) {
1040
+ const entries = this.topologyPolicy.query(bundleId, from);
1041
+ const match = entries.find((e) => e.action === action && e.toNode === to);
1042
+ if (match)
1043
+ return match.score;
1044
+ }
1045
+ // Fallback to AppMap edge data
1046
+ const data = this.load(bundleId);
1047
+ if (!data)
1048
+ return null;
1049
+ const edge = data.navigationGraph.edges.find((e) => e.from === from && e.action === action && e.to === to);
1050
+ if (!edge)
1051
+ return null;
1052
+ if (edge.topologyScore !== undefined)
1053
+ return edge.topologyScore;
1054
+ const total = edge.successCount + edge.failCount;
1055
+ return total > 0 ? edge.successCount / total : null;
1056
+ }
851
1057
  // ── Hierarchy ────────────────────────────────────────────────────
852
1058
  /**
853
1059
  * Record a parent/child containment relationship within a zone.
@@ -1082,7 +1288,7 @@ export class AppMap {
1082
1288
  }
1083
1289
  resolvePosition(bundleId, label, windowBounds) {
1084
1290
  const found = this.findElement(bundleId, label);
1085
- if (!found || found.element.relativeX === 0 && found.element.relativeY === 0)
1291
+ if (!found || (found.element.relativeX === -1 && found.element.relativeY === -1))
1086
1292
  return null;
1087
1293
  return {
1088
1294
  x: Math.round(windowBounds.x + found.element.relativeX * windowBounds.width),
@@ -1299,6 +1505,7 @@ export class AppMap {
1299
1505
  * Recompute tier and confidence from current feature mastery state.
1300
1506
  */
1301
1507
  recomputeTier(data) {
1508
+ const prevTier = data.masteryLevel;
1302
1509
  const metrics = this.computeMetrics(data);
1303
1510
  data.masteryMetrics = metrics;
1304
1511
  data.confidence = this.computeConfidence(data);
@@ -1307,7 +1514,16 @@ export class AppMap {
1307
1514
  const factors = this.computeRatingFactors(data);
1308
1515
  data.ratingFactors = factors;
1309
1516
  data.rating = this.computeRating(factors);
1310
- data.lastValidated = new Date().toISOString();
1517
+ // Use lastRecomputed — lastValidated is reserved for perception coordinator
1518
+ data.lastRecomputed = new Date().toISOString();
1519
+ // Urgent flush when mastery tier changes — write ONLY this app immediately
1520
+ if (prevTier !== data.masteryLevel) {
1521
+ try {
1522
+ writeFileAtomicSync(this.filePath(data.app), JSON.stringify(data, null, 2) + "\n");
1523
+ this.dirty.delete(data.app); // Only remove the one we just wrote
1524
+ }
1525
+ catch { /* non-fatal — will be picked up by next debounced save */ }
1526
+ }
1311
1527
  }
1312
1528
  refreshMastery(bundleId) {
1313
1529
  const data = this.ensureLoaded(bundleId);
@@ -1511,8 +1727,9 @@ export class AppMap {
1511
1727
  }
1512
1728
  }
1513
1729
  }
1514
- // Remove features that no longer exist in builtin (if using builtin ladder)
1515
- if (builtinIds.size > 0) {
1730
+ // Remove features that no longer exist in builtin (only if app has a handcrafted ladder)
1731
+ const hasBuiltinLadder = !!BUILTIN_LADDERS[data.app];
1732
+ if (hasBuiltinLadder && builtinIds.size > 0) {
1516
1733
  // Migrate renamed features: old ID → closest new ID by mastery data
1517
1734
  const OLD_TO_NEW = {
1518
1735
  roles_notifications: "roles_permissions",
@@ -1567,7 +1784,7 @@ export class AppMap {
1567
1784
  return;
1568
1785
  data.version = newVersion;
1569
1786
  data.confidence *= this.config.versionDecayFactor;
1570
- data.masteryLevel = this.computeMasteryLevel(data.confidence);
1787
+ this.recomputeTier(data);
1571
1788
  // Unverify all edges on version change
1572
1789
  for (const edge of data.navigationGraph.edges) {
1573
1790
  edge.verified = false;
@@ -1579,7 +1796,7 @@ export class AppMap {
1579
1796
  if (!data)
1580
1797
  return;
1581
1798
  data.confidence = this.computeConfidence(data);
1582
- data.masteryLevel = this.computeMasteryLevel(data.confidence);
1799
+ this.recomputeTier(data);
1583
1800
  this.save(data);
1584
1801
  }
1585
1802
  // ── Pruning ───────────────────────────────────────────────────────
@@ -1940,6 +2157,79 @@ export class AppMap {
1940
2157
  }
1941
2158
  return maxTypical;
1942
2159
  }
2160
+ /**
2161
+ * Wire #15: Check if an element is well-known and recently verified.
2162
+ * Returns true if the element has 3+ successes and was interacted with
2163
+ * within maxAgeMs (default 5 minutes). Used by Executor to skip verify.
2164
+ */
2165
+ isElementVerified(bundleId, label, maxAgeMs = 300_000) {
2166
+ const data = this.ensureLoaded(bundleId);
2167
+ if (!data)
2168
+ return false;
2169
+ const now = Date.now();
2170
+ for (const zone of Object.values(data.zones)) {
2171
+ for (const el of zone.elements) {
2172
+ if (el.label === label && el.successCount >= 3) {
2173
+ const lastTime = new Date(el.lastInteracted).getTime();
2174
+ const elapsed = now - lastTime;
2175
+ // Guard: elapsed must be non-negative (rejects future dates from clock skew)
2176
+ // and within the staleness window
2177
+ if (elapsed >= 0 && elapsed <= maxAgeMs) {
2178
+ return true;
2179
+ }
2180
+ }
2181
+ }
2182
+ }
2183
+ return false;
2184
+ }
2185
+ /**
2186
+ * Wire F9: Import element knowledge from a community playbook.
2187
+ * Creates the app entry if it doesn't exist, then records each step's
2188
+ * target as a successful element interaction.
2189
+ */
2190
+ importFromPlaybook(bundleId, appName, steps) {
2191
+ let data = this.ensureLoaded(bundleId);
2192
+ if (!data) {
2193
+ data = this.createEmpty(bundleId, appName);
2194
+ this.save(data); // Persist to cache + disk so recordElementOutcome can find it
2195
+ }
2196
+ for (const step of steps) {
2197
+ const label = (step.params?.text ?? step.params?.title ?? step.params?.target ?? step.description);
2198
+ if (!label || typeof label !== "string")
2199
+ continue;
2200
+ this.recordElementOutcome(bundleId, "auto", label, true);
2201
+ }
2202
+ }
2203
+ /**
2204
+ * List all known app bundleIds by scanning the maps directory.
2205
+ * Returns bundleIds derived from filenames (excludes .ladder.json files).
2206
+ */
2207
+ listKnownApps() {
2208
+ try {
2209
+ const dirents = fs.readdirSync(this.config.mapsDir, { withFileTypes: true });
2210
+ const bundleIds = [];
2211
+ for (const dirent of dirents) {
2212
+ // Skip symlinks and directories — only read regular files
2213
+ if (!dirent.isFile())
2214
+ continue;
2215
+ const file = dirent.name;
2216
+ if (file.endsWith(".json") && !file.endsWith(".ladder.json")) {
2217
+ const stem = file.slice(0, -5);
2218
+ // Load the file and use data.app (canonical bundleId) instead of
2219
+ // filename stem, which may differ from the original bundleId due
2220
+ // to filesystem sanitization in filePath()
2221
+ const data = this.ensureLoaded(stem);
2222
+ if (data?.app) {
2223
+ bundleIds.push(data.app);
2224
+ }
2225
+ }
2226
+ }
2227
+ return bundleIds;
2228
+ }
2229
+ catch {
2230
+ return [];
2231
+ }
2232
+ }
1943
2233
  // ── Internals ─────────────────────────────────────────────────────
1944
2234
  ensureLoaded(bundleId) {
1945
2235
  return this.cache.get(bundleId) ?? this.load(bundleId);
@@ -36,14 +36,22 @@ export function writeFileAtomicSync(filePath, data) {
36
36
  try {
37
37
  fs.writeFileSync(tmp, data, { mode: 0o644 });
38
38
  // Back up current file before overwriting (ignore if it doesn't exist yet)
39
- // V3: Check for symlinks before backup to prevent data exfiltration
39
+ // V4: Check for symlinks on BOTH primary and .bak to prevent data exfiltration
40
40
  try {
41
41
  const stat = fs.lstatSync(filePath);
42
42
  if (stat.isSymbolicLink()) {
43
- // Target is a symlink — skip backup and remove the symlink before writing
43
+ // Primary is a symlink — remove it before writing
44
44
  fs.unlinkSync(filePath);
45
45
  }
46
46
  else {
47
+ // Check .bak target for symlinks before copying
48
+ try {
49
+ const bakStat = fs.lstatSync(filePath + ".bak");
50
+ if (bakStat.isSymbolicLink()) {
51
+ fs.unlinkSync(filePath + ".bak"); // Remove symlink, then copy
52
+ }
53
+ }
54
+ catch { /* .bak doesn't exist yet — fine */ }
47
55
  fs.copyFileSync(filePath, filePath + ".bak");
48
56
  }
49
57
  }
@@ -57,7 +65,10 @@ export function writeFileAtomicSync(filePath, data) {
57
65
  try {
58
66
  fs.unlinkSync(tmp);
59
67
  }
60
- catch { /* ignore */ }
68
+ catch {
69
+ // Temp file could not be cleaned up — log so it can be investigated
70
+ console.error(`[atomic-write] WARN: Failed to clean up temp file: ${tmp}`);
71
+ }
61
72
  throw err;
62
73
  }
63
74
  }
@@ -78,13 +89,20 @@ export function writeFileAtomic(filePath, data, callback) {
78
89
  return;
79
90
  }
80
91
  // Back up current file (best-effort, sync is fine for a copy)
81
- // V3: Check for symlinks before backup to prevent data exfiltration
92
+ // V4: Check for symlinks on BOTH primary and .bak
82
93
  try {
83
94
  const stat = fs.lstatSync(filePath);
84
95
  if (stat.isSymbolicLink()) {
85
96
  fs.unlinkSync(filePath);
86
97
  }
87
98
  else {
99
+ try {
100
+ const bakStat = fs.lstatSync(filePath + ".bak");
101
+ if (bakStat.isSymbolicLink()) {
102
+ fs.unlinkSync(filePath + ".bak");
103
+ }
104
+ }
105
+ catch { /* .bak doesn't exist — fine */ }
88
106
  fs.copyFileSync(filePath, filePath + ".bak");
89
107
  }
90
108
  }
@@ -113,6 +131,7 @@ export function readJsonWithRecovery(filePath) {
113
131
  // Primary is missing or corrupt — try backup
114
132
  const backup = tryParseJsonFile(filePath + ".bak");
115
133
  if (backup !== null) {
134
+ console.error(`[atomic-write] WARN: Primary file corrupt, recovered from backup: ${filePath}`);
116
135
  // Restore backup as primary so next read is fast
117
136
  try {
118
137
  fs.copyFileSync(filePath + ".bak", filePath);
@@ -120,6 +139,8 @@ export function readJsonWithRecovery(filePath) {
120
139
  catch { /* ignore */ }
121
140
  return backup;
122
141
  }
142
+ // Both primary and backup are missing or corrupt — log warning so data loss is visible
143
+ console.error(`[atomic-write] WARN: Both primary and backup corrupt for ${filePath}, returning null`);
123
144
  return null;
124
145
  }
125
146
  function tryParseJsonFile(filePath) {
@@ -52,7 +52,7 @@
52
52
  "downvote": "shreddit-post >> shadowRoot >> button:has-text('Downvote')",
53
53
  "comments": "shreddit-post >> shadowRoot >> button:has-text('Go to comments')",
54
54
  "share": "shreddit-post >> shadowRoot >> button:has-text('Share')",
55
- "note": "These selectors are conceptual — use JS to access shadow roots: post.shadowRoot.querySelectorAll('button')"
55
+ "_note": "These selectors are conceptual — use JS to access shadow roots: post.shadowRoot.querySelectorAll('button')"
56
56
  },
57
57
  "comment": {
58
58
  "composer": "shreddit-composer",
@@ -71,7 +71,7 @@
71
71
  "type_text": "[type='TEXT']",
72
72
  "type_link": "[type='LINK']",
73
73
  "type_image": "[type='IMAGE']",
74
- "note": "Title textarea is inside shadow root of faceplate-textarea-input — use native value setter via JS"
74
+ "_note": "Title textarea is inside shadow root of faceplate-textarea-input — use native value setter via JS"
75
75
  },
76
76
  "subreddit": {
77
77
  "header": "shreddit-subreddit-header",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screenhand",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "mcpName": "io.github.manushi4/screenhand",
5
5
  "description": "Give AI eyes and hands on your desktop. ScreenHand is an open-source MCP server that lets Claude and other AI agents see your screen, click buttons, type text, and control any app on macOS and Windows.",
6
6
  "homepage": "https://screenhand.com",