screenhand 0.1.1 → 0.3.0

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 (241) hide show
  1. package/README.md +193 -109
  2. package/bin/darwin-arm64/macos-bridge +0 -0
  3. package/dist/mcp-desktop.js +5876 -0
  4. package/dist/scripts/codex-monitor-daemon.js +335 -0
  5. package/dist/scripts/export-help-center.js +112 -0
  6. package/dist/scripts/marketing-loop.js +117 -0
  7. package/dist/scripts/observer-daemon.js +288 -0
  8. package/dist/scripts/orchestrator-daemon.js +399 -0
  9. package/dist/scripts/supervisor-daemon.js +272 -0
  10. package/dist/scripts/threads-campaign.js +208 -0
  11. package/dist/scripts/worker-daemon.js +228 -0
  12. package/dist/src/agent/cli.js +82 -0
  13. package/dist/src/agent/loop.js +274 -0
  14. package/dist/src/community/fetcher.js +109 -0
  15. package/dist/src/community/index.js +6 -0
  16. package/dist/src/community/publisher.js +191 -0
  17. package/dist/src/community/remote-api.js +121 -0
  18. package/dist/src/community/types.js +3 -0
  19. package/dist/src/community/validator.js +95 -0
  20. package/{src/config.ts → dist/src/config.js} +5 -10
  21. package/dist/src/context-tracker.js +489 -0
  22. package/{src/index.ts → dist/src/index.js} +32 -52
  23. package/dist/src/ingestion/coverage-auditor.js +233 -0
  24. package/dist/src/ingestion/doc-parser.js +164 -0
  25. package/dist/src/ingestion/index.js +8 -0
  26. package/dist/src/ingestion/menu-scanner.js +152 -0
  27. package/dist/src/ingestion/reference-merger.js +186 -0
  28. package/dist/src/ingestion/shortcut-extractor.js +180 -0
  29. package/dist/src/ingestion/tutorial-extractor.js +170 -0
  30. package/dist/src/ingestion/types.js +3 -0
  31. package/dist/src/jobs/manager.js +305 -0
  32. package/dist/src/jobs/runner.js +806 -0
  33. package/dist/src/jobs/store.js +102 -0
  34. package/dist/src/jobs/types.js +30 -0
  35. package/dist/src/jobs/worker.js +97 -0
  36. package/dist/src/learning/engine.js +356 -0
  37. package/dist/src/learning/index.js +9 -0
  38. package/dist/src/learning/locator-policy.js +120 -0
  39. package/dist/src/learning/pattern-policy.js +89 -0
  40. package/dist/src/learning/recovery-policy.js +116 -0
  41. package/dist/src/learning/sensor-policy.js +115 -0
  42. package/dist/src/learning/timing-model.js +204 -0
  43. package/dist/src/learning/topology-policy.js +90 -0
  44. package/dist/src/learning/types.js +9 -0
  45. package/dist/src/logging/timeline-logger.js +48 -0
  46. package/dist/src/mcp/mcp-stdio-server.js +464 -0
  47. package/dist/src/mcp/server.js +363 -0
  48. package/dist/src/mcp-entry.js +60 -0
  49. package/dist/src/memory/playbook-seeds.js +200 -0
  50. package/dist/src/memory/recall.js +222 -0
  51. package/dist/src/memory/research.js +104 -0
  52. package/dist/src/memory/seeds.js +101 -0
  53. package/dist/src/memory/service.js +446 -0
  54. package/dist/src/memory/session.js +169 -0
  55. package/dist/src/memory/store.js +451 -0
  56. package/{src/runtime/locator-cache.ts → dist/src/memory/types.js} +1 -17
  57. package/dist/src/monitor/codex-monitor.js +382 -0
  58. package/dist/src/monitor/task-queue.js +97 -0
  59. package/dist/src/monitor/types.js +62 -0
  60. package/dist/src/native/bridge-client.js +412 -0
  61. package/{src/native/macos-bridge-client.ts → dist/src/native/macos-bridge-client.js} +0 -1
  62. package/dist/src/observer/state.js +199 -0
  63. package/dist/src/observer/types.js +43 -0
  64. package/dist/src/orchestrator/state.js +68 -0
  65. package/dist/src/orchestrator/types.js +22 -0
  66. package/dist/src/perception/ax-source.js +162 -0
  67. package/dist/src/perception/cdp-source.js +162 -0
  68. package/dist/src/perception/coordinator.js +771 -0
  69. package/dist/src/perception/frame-differ.js +287 -0
  70. package/dist/src/perception/index.js +22 -0
  71. package/dist/src/perception/manager.js +199 -0
  72. package/dist/src/perception/types.js +47 -0
  73. package/dist/src/perception/vision-source.js +399 -0
  74. package/dist/src/planner/deterministic.js +298 -0
  75. package/dist/src/planner/executor.js +870 -0
  76. package/dist/src/planner/goal-store.js +92 -0
  77. package/dist/src/planner/index.js +21 -0
  78. package/dist/src/planner/planner.js +520 -0
  79. package/dist/src/planner/tool-registry.js +71 -0
  80. package/dist/src/planner/types.js +22 -0
  81. package/dist/src/platform/explorer.js +213 -0
  82. package/dist/src/platform/help-center-markdown.js +527 -0
  83. package/dist/src/platform/learner.js +257 -0
  84. package/dist/src/playbook/engine.js +486 -0
  85. package/dist/src/playbook/index.js +20 -0
  86. package/dist/src/playbook/mcp-recorder.js +204 -0
  87. package/dist/src/playbook/recorder.js +536 -0
  88. package/dist/src/playbook/runner.js +408 -0
  89. package/dist/src/playbook/store.js +312 -0
  90. package/dist/src/playbook/types.js +17 -0
  91. package/dist/src/recovery/detectors.js +156 -0
  92. package/dist/src/recovery/engine.js +327 -0
  93. package/dist/src/recovery/index.js +20 -0
  94. package/dist/src/recovery/strategies.js +274 -0
  95. package/dist/src/recovery/types.js +20 -0
  96. package/dist/src/runtime/accessibility-adapter.js +430 -0
  97. package/dist/src/runtime/app-adapter.js +64 -0
  98. package/dist/src/runtime/applescript-adapter.js +305 -0
  99. package/dist/src/runtime/ax-role-map.js +96 -0
  100. package/dist/src/runtime/browser-adapter.js +52 -0
  101. package/dist/src/runtime/cdp-chrome-adapter.js +521 -0
  102. package/dist/src/runtime/composite-adapter.js +221 -0
  103. package/dist/src/runtime/execution-contract.js +159 -0
  104. package/dist/src/runtime/executor.js +286 -0
  105. package/dist/src/runtime/locator-cache.js +50 -0
  106. package/dist/src/runtime/planning-loop.js +63 -0
  107. package/dist/src/runtime/service.js +432 -0
  108. package/dist/src/runtime/session-manager.js +63 -0
  109. package/dist/src/runtime/state-observer.js +121 -0
  110. package/dist/src/runtime/vision-adapter.js +225 -0
  111. package/dist/src/state/app-map-types.js +72 -0
  112. package/dist/src/state/app-map.js +1974 -0
  113. package/dist/src/state/entity-tracker.js +108 -0
  114. package/dist/src/state/fusion.js +96 -0
  115. package/dist/src/state/index.js +21 -0
  116. package/dist/src/state/ladder-generator.js +236 -0
  117. package/dist/src/state/persistence.js +156 -0
  118. package/dist/src/state/types.js +17 -0
  119. package/dist/src/state/world-model.js +1456 -0
  120. package/dist/src/supervisor/locks.js +186 -0
  121. package/dist/src/supervisor/supervisor.js +403 -0
  122. package/dist/src/supervisor/types.js +30 -0
  123. package/dist/src/test-mcp-protocol.js +154 -0
  124. package/dist/src/types.js +17 -0
  125. package/dist/src/util/atomic-write.js +133 -0
  126. package/dist/src/util/sanitize.js +146 -0
  127. package/dist-app-maps/com.figma.Desktop.json +959 -0
  128. package/dist-app-maps/com.hnc.Discord.json +1146 -0
  129. package/dist-app-maps/notion.id.json +2831 -0
  130. package/dist-playbooks/canva-screenhand-carousel.json +445 -0
  131. package/dist-playbooks/codex-desktop.json +76 -0
  132. package/dist-playbooks/competitor-research-stack.json +122 -0
  133. package/dist-playbooks/davinci-color-grade.json +153 -0
  134. package/dist-playbooks/davinci-edit-timeline.json +162 -0
  135. package/dist-playbooks/davinci-render.json +114 -0
  136. package/dist-playbooks/devto.json +52 -0
  137. package/dist-playbooks/discord.json +41 -0
  138. package/dist-playbooks/google-flow-create-project.json +59 -0
  139. package/dist-playbooks/google-flow-edit-image.json +90 -0
  140. package/dist-playbooks/google-flow-edit-video.json +90 -0
  141. package/dist-playbooks/google-flow-generate-image.json +68 -0
  142. package/dist-playbooks/google-flow-generate-video.json +191 -0
  143. package/dist-playbooks/google-flow-open-project.json +48 -0
  144. package/dist-playbooks/google-flow-open-scenebuilder.json +64 -0
  145. package/dist-playbooks/google-flow-search-assets.json +64 -0
  146. package/dist-playbooks/instagram.json +57 -0
  147. package/dist-playbooks/linkedin.json +52 -0
  148. package/dist-playbooks/n8n.json +43 -0
  149. package/dist-playbooks/reddit.json +52 -0
  150. package/dist-playbooks/threads.json +59 -0
  151. package/dist-playbooks/x-twitter.json +59 -0
  152. package/dist-playbooks/youtube.json +59 -0
  153. package/dist-references/canva.json +646 -0
  154. package/dist-references/codex-desktop.json +305 -0
  155. package/dist-references/davinci-resolve-keyboard.json +594 -0
  156. package/dist-references/davinci-resolve-menu-map.json +1139 -0
  157. package/dist-references/davinci-resolve-menus-batch1.json +116 -0
  158. package/dist-references/davinci-resolve-menus-batch2.json +372 -0
  159. package/dist-references/davinci-resolve-menus-batch3.json +330 -0
  160. package/dist-references/davinci-resolve-menus-batch4.json +297 -0
  161. package/dist-references/davinci-resolve-shortcuts.json +333 -0
  162. package/dist-references/devto.json +317 -0
  163. package/dist-references/discord.json +549 -0
  164. package/dist-references/figma.json +1186 -0
  165. package/dist-references/finder.json +146 -0
  166. package/dist-references/google-ads-transparency.json +95 -0
  167. package/dist-references/google-flow.json +649 -0
  168. package/dist-references/instagram.json +341 -0
  169. package/dist-references/linkedin.json +324 -0
  170. package/dist-references/meta-ad-library.json +86 -0
  171. package/dist-references/n8n.json +387 -0
  172. package/dist-references/notes.json +27 -0
  173. package/dist-references/notion.json +163 -0
  174. package/dist-references/reddit.json +341 -0
  175. package/dist-references/threads.json +337 -0
  176. package/dist-references/x-twitter.json +403 -0
  177. package/dist-references/youtube.json +373 -0
  178. package/native/macos-bridge/Package.swift +1 -0
  179. package/native/macos-bridge/Sources/AccessibilityBridge.swift +257 -36
  180. package/native/macos-bridge/Sources/AppManagement.swift +212 -2
  181. package/native/macos-bridge/Sources/CoreGraphicsBridge.swift +348 -53
  182. package/native/macos-bridge/Sources/StreamCapture.swift +136 -0
  183. package/native/macos-bridge/Sources/VisionBridge.swift +165 -7
  184. package/native/macos-bridge/Sources/main.swift +169 -16
  185. package/native/windows-bridge/Program.cs +5 -0
  186. package/native/windows-bridge/ScreenCapture.cs +124 -0
  187. package/package.json +29 -4
  188. package/scripts/postinstall.cjs +127 -0
  189. package/.claude/commands/automate.md +0 -28
  190. package/.claude/commands/debug-ui.md +0 -19
  191. package/.claude/commands/screenshot.md +0 -15
  192. package/.github/FUNDING.yml +0 -1
  193. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -27
  194. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
  195. package/.mcp.json +0 -8
  196. package/DESKTOP_MCP_GUIDE.md +0 -92
  197. package/SECURITY.md +0 -44
  198. package/docs/architecture.md +0 -47
  199. package/install-skills.sh +0 -19
  200. package/mcp-bridge.ts +0 -271
  201. package/mcp-desktop.ts +0 -1221
  202. package/playbooks/instagram.json +0 -41
  203. package/playbooks/instagram_v2.json +0 -201
  204. package/playbooks/x_v1.json +0 -211
  205. package/scripts/devpost-live-loop.mjs +0 -421
  206. package/src/logging/timeline-logger.ts +0 -55
  207. package/src/mcp/server.ts +0 -449
  208. package/src/memory/recall.ts +0 -191
  209. package/src/memory/research.ts +0 -146
  210. package/src/memory/seeds.ts +0 -123
  211. package/src/memory/session.ts +0 -201
  212. package/src/memory/store.ts +0 -434
  213. package/src/memory/types.ts +0 -69
  214. package/src/native/bridge-client.ts +0 -239
  215. package/src/runtime/accessibility-adapter.ts +0 -487
  216. package/src/runtime/app-adapter.ts +0 -169
  217. package/src/runtime/applescript-adapter.ts +0 -376
  218. package/src/runtime/ax-role-map.ts +0 -102
  219. package/src/runtime/browser-adapter.ts +0 -129
  220. package/src/runtime/cdp-chrome-adapter.ts +0 -676
  221. package/src/runtime/composite-adapter.ts +0 -274
  222. package/src/runtime/executor.ts +0 -396
  223. package/src/runtime/planning-loop.ts +0 -81
  224. package/src/runtime/service.ts +0 -448
  225. package/src/runtime/session-manager.ts +0 -50
  226. package/src/runtime/state-observer.ts +0 -136
  227. package/src/runtime/vision-adapter.ts +0 -297
  228. package/src/types.ts +0 -297
  229. package/tests/bridge-client.test.ts +0 -176
  230. package/tests/browser-stealth.test.ts +0 -210
  231. package/tests/composite-adapter.test.ts +0 -64
  232. package/tests/mcp-server.test.ts +0 -151
  233. package/tests/memory-recall.test.ts +0 -339
  234. package/tests/memory-research.test.ts +0 -159
  235. package/tests/memory-seeds.test.ts +0 -120
  236. package/tests/memory-store.test.ts +0 -392
  237. package/tests/types.test.ts +0 -92
  238. package/tsconfig.check.json +0 -17
  239. package/tsconfig.json +0 -19
  240. package/vitest.config.ts +0 -8
  241. /package/{playbooks → dist-references}/devpost.json +0 -0
@@ -0,0 +1,186 @@
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
+ * LeaseManager — filesystem-based session lease management.
19
+ *
20
+ * Each lease is a JSON lock file: {app}__{windowId}.lock
21
+ * Provides mutual exclusion so only one client controls a window at a time.
22
+ */
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import { writeFileAtomicSync } from "../util/atomic-write.js";
26
+ const DEFAULT_LEASE_TIMEOUT_MS = 300000; // 5 min
27
+ export class LeaseManager {
28
+ lockDir;
29
+ leaseTimeoutMs;
30
+ constructor(lockDir, leaseTimeoutMs = DEFAULT_LEASE_TIMEOUT_MS) {
31
+ this.lockDir = lockDir;
32
+ this.leaseTimeoutMs = leaseTimeoutMs;
33
+ fs.mkdirSync(this.lockDir, { recursive: true });
34
+ }
35
+ /**
36
+ * Claim a window for a client. Fails if another active lease exists.
37
+ * Returns the new SessionLease, or null if the window is already claimed.
38
+ */
39
+ claim(client, app, windowId) {
40
+ const existing = this.isLocked(app, windowId);
41
+ if (existing) {
42
+ return null;
43
+ }
44
+ const now = new Date();
45
+ const sessionId = "lease_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 8);
46
+ const lease = {
47
+ sessionId,
48
+ client,
49
+ app,
50
+ windowId,
51
+ claimedAt: now.toISOString(),
52
+ lastHeartbeat: now.toISOString(),
53
+ expiresAt: new Date(now.getTime() + this.leaseTimeoutMs).toISOString(),
54
+ };
55
+ const lockFile = this.lockFilePath(app, windowId);
56
+ try {
57
+ fs.writeFileSync(lockFile, JSON.stringify(lease, null, 2), { flag: "wx" });
58
+ }
59
+ catch {
60
+ // File already exists (race condition) — another client claimed it
61
+ return null;
62
+ }
63
+ return lease;
64
+ }
65
+ /**
66
+ * Refresh heartbeat for an existing lease.
67
+ * Returns true if the lease was found and updated, false otherwise.
68
+ */
69
+ heartbeat(sessionId) {
70
+ const leases = this.readAllLeases();
71
+ for (const { lease, filePath } of leases) {
72
+ if (lease.sessionId === sessionId) {
73
+ const now = new Date();
74
+ lease.lastHeartbeat = now.toISOString();
75
+ lease.expiresAt = new Date(now.getTime() + this.leaseTimeoutMs).toISOString();
76
+ writeFileAtomicSync(filePath, JSON.stringify(lease, null, 2));
77
+ return true;
78
+ }
79
+ }
80
+ return false;
81
+ }
82
+ /**
83
+ * Release a lease by session ID.
84
+ * Returns true if the lease was found and removed, false otherwise.
85
+ */
86
+ release(sessionId) {
87
+ const leases = this.readAllLeases();
88
+ for (const { lease, filePath } of leases) {
89
+ if (lease.sessionId === sessionId) {
90
+ try {
91
+ fs.unlinkSync(filePath);
92
+ }
93
+ catch {
94
+ // Already removed
95
+ }
96
+ return true;
97
+ }
98
+ }
99
+ return false;
100
+ }
101
+ /**
102
+ * Get all active (non-expired) leases.
103
+ */
104
+ getActive() {
105
+ const now = Date.now();
106
+ return this.readAllLeases()
107
+ .filter(({ lease }) => new Date(lease.expiresAt).getTime() > now)
108
+ .map(({ lease }) => lease);
109
+ }
110
+ /**
111
+ * Clean up expired leases by deleting their lock files.
112
+ * Returns the number of leases expired.
113
+ */
114
+ expireStale() {
115
+ const now = Date.now();
116
+ let count = 0;
117
+ for (const { lease, filePath } of this.readAllLeases()) {
118
+ if (new Date(lease.expiresAt).getTime() <= now) {
119
+ try {
120
+ fs.unlinkSync(filePath);
121
+ count++;
122
+ }
123
+ catch {
124
+ // Already removed
125
+ }
126
+ }
127
+ }
128
+ return count;
129
+ }
130
+ /**
131
+ * Check if a window is claimed by an active (non-expired) lease.
132
+ * Returns the lease if found, null otherwise.
133
+ */
134
+ isLocked(app, windowId) {
135
+ const lockFile = this.lockFilePath(app, windowId);
136
+ try {
137
+ const data = fs.readFileSync(lockFile, "utf-8");
138
+ const lease = JSON.parse(data);
139
+ // Check if expired
140
+ if (new Date(lease.expiresAt).getTime() <= Date.now()) {
141
+ // Expired — clean up and report as unlocked
142
+ try {
143
+ fs.unlinkSync(lockFile);
144
+ }
145
+ catch {
146
+ // Ignore
147
+ }
148
+ return null;
149
+ }
150
+ return lease;
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ }
156
+ // ── Private helpers ──
157
+ lockFilePath(app, windowId) {
158
+ // Sanitize app name for filesystem safety
159
+ const safeApp = app.replace(/[^a-zA-Z0-9._-]/g, "_");
160
+ return path.join(this.lockDir, `${safeApp}__${windowId}.lock`);
161
+ }
162
+ readAllLeases() {
163
+ const results = [];
164
+ let files;
165
+ try {
166
+ files = fs.readdirSync(this.lockDir);
167
+ }
168
+ catch {
169
+ return results;
170
+ }
171
+ for (const file of files) {
172
+ if (!file.endsWith(".lock"))
173
+ continue;
174
+ const filePath = path.join(this.lockDir, file);
175
+ try {
176
+ const data = fs.readFileSync(filePath, "utf-8");
177
+ const lease = JSON.parse(data);
178
+ results.push({ lease, filePath });
179
+ }
180
+ catch {
181
+ // Corrupt lock file — skip
182
+ }
183
+ }
184
+ return results;
185
+ }
186
+ }
@@ -0,0 +1,403 @@
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
+ * SessionSupervisor — generic, client-agnostic session supervisor.
19
+ *
20
+ * Manages session leases, detects stalls, and coordinates recovery actions
21
+ * via the filesystem. Does NOT perform OCR or interact with the native bridge
22
+ * directly — that responsibility belongs to the daemon layer.
23
+ *
24
+ * State directory: ~/.screenhand/supervisor/
25
+ * Files: state.json, recoveries.json, supervisor.pid, supervisor.log
26
+ */
27
+ import fs from "node:fs";
28
+ import path from "node:path";
29
+ import { writeFileAtomicSync, readJsonWithRecovery } from "../util/atomic-write.js";
30
+ import { LeaseManager } from "./locks.js";
31
+ import { DEFAULT_SUPERVISOR_CONFIG, } from "./types.js";
32
+ /** Known blocker patterns for stall detection (matched against screen content) */
33
+ const BLOCKER_PATTERNS = [
34
+ "captcha",
35
+ "2fa",
36
+ "two-factor",
37
+ "rate limit",
38
+ "timed out",
39
+ "login",
40
+ "permission",
41
+ "approve",
42
+ "blocked",
43
+ ];
44
+ export class SessionSupervisor {
45
+ config;
46
+ stateDir;
47
+ lockDir;
48
+ leaseManager;
49
+ stateFile;
50
+ recoveriesFile;
51
+ pidFile;
52
+ logFile;
53
+ startedAt;
54
+ running = false;
55
+ pollTimer = null;
56
+ // Health counters
57
+ totalSessions = 0;
58
+ expiredLeases = 0;
59
+ stallsDetected = 0;
60
+ recoveriesAttempted = 0;
61
+ consecutiveErrors = 0;
62
+ // In-memory recovery list (also persisted)
63
+ recoveries = [];
64
+ // Last known screen content per session (set externally via stall detection)
65
+ screenContent = new Map();
66
+ logStream = null;
67
+ constructor(config) {
68
+ this.config = { ...DEFAULT_SUPERVISOR_CONFIG, ...config };
69
+ this.startedAt = new Date().toISOString();
70
+ this.stateDir = this.config.stateDir;
71
+ this.lockDir = this.config.lockDir;
72
+ this.stateFile = path.join(this.stateDir, "state.json");
73
+ this.recoveriesFile = path.join(this.stateDir, "recoveries.json");
74
+ this.pidFile = path.join(this.stateDir, "supervisor.pid");
75
+ this.logFile = path.join(this.stateDir, "supervisor.log");
76
+ this.leaseManager = new LeaseManager(this.lockDir, this.config.leaseTimeoutMs);
77
+ // Ensure directories exist
78
+ fs.mkdirSync(this.stateDir, { recursive: true });
79
+ fs.mkdirSync(this.lockDir, { recursive: true });
80
+ // Load persisted recoveries if any
81
+ this.loadRecoveries();
82
+ }
83
+ /**
84
+ * Start the supervisor poll loop (meant to be called when running as daemon).
85
+ */
86
+ /**
87
+ * Check if another supervisor daemon is already running via PID file.
88
+ * Returns the existing PID if alive, null otherwise.
89
+ */
90
+ getExistingDaemonPid() {
91
+ try {
92
+ if (!fs.existsSync(this.pidFile))
93
+ return null;
94
+ const pid = Number(fs.readFileSync(this.pidFile, "utf-8").trim());
95
+ if (isNaN(pid) || pid <= 0)
96
+ return null;
97
+ // Check if process is alive (signal 0 = test existence)
98
+ process.kill(pid, 0);
99
+ return pid;
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ }
105
+ async start() {
106
+ if (this.running)
107
+ return;
108
+ // Enforce single daemon: refuse to start if another is alive
109
+ const existingPid = this.getExistingDaemonPid();
110
+ if (existingPid !== null && existingPid !== process.pid) {
111
+ throw new Error(`Another supervisor daemon is already running (pid=${existingPid}). Stop it first or remove ${this.pidFile}.`);
112
+ }
113
+ this.running = true;
114
+ this.startedAt = new Date().toISOString();
115
+ this.logStream = fs.createWriteStream(this.logFile, { flags: "a" });
116
+ // Write PID file (atomic-ish — we checked above)
117
+ fs.writeFileSync(this.pidFile, String(process.pid));
118
+ this.log(`Supervisor started (pid=${process.pid})`);
119
+ this.writeState();
120
+ // Start poll loop
121
+ this.pollTimer = setInterval(() => {
122
+ this.pollCycle();
123
+ }, this.config.pollMs);
124
+ }
125
+ /**
126
+ * Stop the supervisor.
127
+ */
128
+ async stop() {
129
+ if (!this.running)
130
+ return;
131
+ this.running = false;
132
+ if (this.pollTimer) {
133
+ clearInterval(this.pollTimer);
134
+ this.pollTimer = null;
135
+ }
136
+ this.log("Supervisor stopped");
137
+ this.writeState();
138
+ // Clean up PID file
139
+ try {
140
+ fs.unlinkSync(this.pidFile);
141
+ }
142
+ catch {
143
+ // Ignore
144
+ }
145
+ if (this.logStream) {
146
+ this.logStream.end();
147
+ this.logStream = null;
148
+ }
149
+ }
150
+ /**
151
+ * Get current supervisor state.
152
+ */
153
+ getState() {
154
+ const activeSessions = this.leaseManager.getActive();
155
+ return {
156
+ pid: process.pid,
157
+ startedAt: this.startedAt,
158
+ running: this.running,
159
+ sessions: activeSessions,
160
+ health: this.getHealth(activeSessions),
161
+ config: { ...this.config },
162
+ };
163
+ }
164
+ /**
165
+ * Register a session for monitoring.
166
+ * Claims a window lease for the given client.
167
+ */
168
+ registerSession(client, app, windowId) {
169
+ const lease = this.leaseManager.claim(client, app, windowId);
170
+ if (lease) {
171
+ this.totalSessions++;
172
+ this.log(`Session registered: ${lease.sessionId} (client=${client.id}, type=${client.type}, app=${app}, window=${windowId})`);
173
+ this.writeState();
174
+ }
175
+ return lease;
176
+ }
177
+ /**
178
+ * Heartbeat from a client.
179
+ */
180
+ heartbeat(sessionId) {
181
+ return this.leaseManager.heartbeat(sessionId);
182
+ }
183
+ /**
184
+ * Release a session lease.
185
+ */
186
+ releaseSession(sessionId) {
187
+ const released = this.leaseManager.release(sessionId);
188
+ if (released) {
189
+ this.screenContent.delete(sessionId);
190
+ this.log(`Session released: ${sessionId}`);
191
+ this.writeState();
192
+ }
193
+ return released;
194
+ }
195
+ /**
196
+ * Add a recovery action for a session.
197
+ */
198
+ addRecovery(sessionId, type, instruction) {
199
+ const action = {
200
+ id: "recv_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 8),
201
+ sessionId,
202
+ type,
203
+ instruction,
204
+ status: "pending",
205
+ createdAt: new Date().toISOString(),
206
+ attemptedAt: null,
207
+ result: null,
208
+ };
209
+ this.recoveries.push(action);
210
+ this.saveRecoveries();
211
+ this.log(`Recovery added: ${action.id} (session=${sessionId}, type=${type})`);
212
+ return action;
213
+ }
214
+ /**
215
+ * List recovery actions, optionally filtered by status.
216
+ */
217
+ getRecoveries(status) {
218
+ if (status) {
219
+ return this.recoveries.filter((r) => r.status === status);
220
+ }
221
+ return [...this.recoveries];
222
+ }
223
+ /**
224
+ * Update a recovery's status and result, then persist to disk.
225
+ */
226
+ updateRecovery(id, status, result) {
227
+ const recovery = this.recoveries.find((r) => r.id === id);
228
+ if (recovery) {
229
+ recovery.status = status;
230
+ if (result !== undefined)
231
+ recovery.result = result;
232
+ this.saveRecoveries();
233
+ }
234
+ }
235
+ /**
236
+ * Detect stalls across all active sessions.
237
+ * A session is stalled if its lastHeartbeat is older than stallThresholdMs.
238
+ */
239
+ detectStalls() {
240
+ const now = Date.now();
241
+ const active = this.leaseManager.getActive();
242
+ const stalls = [];
243
+ for (const lease of active) {
244
+ const lastHb = new Date(lease.lastHeartbeat).getTime();
245
+ const elapsed = now - lastHb;
246
+ if (elapsed >= this.config.stallThresholdMs) {
247
+ const content = this.screenContent.get(lease.sessionId) ?? null;
248
+ const matchedBlockers = content
249
+ ? BLOCKER_PATTERNS.filter((p) => content.toLowerCase().includes(p))
250
+ : [];
251
+ stalls.push({
252
+ sessionId: lease.sessionId,
253
+ stalledSince: lease.lastHeartbeat,
254
+ durationMs: elapsed,
255
+ lastScreenContent: content,
256
+ matchedBlockers,
257
+ });
258
+ }
259
+ }
260
+ return stalls;
261
+ }
262
+ /**
263
+ * Set screen content for a session (used by external daemons for blocker matching).
264
+ */
265
+ setScreenContent(sessionId, content) {
266
+ this.screenContent.set(sessionId, content);
267
+ }
268
+ // ── Private methods ──
269
+ pollCycle() {
270
+ try {
271
+ // 1. Expire stale leases
272
+ const expired = this.leaseManager.expireStale();
273
+ if (expired > 0) {
274
+ this.expiredLeases += expired;
275
+ this.log(`Expired ${expired} stale lease(s)`);
276
+ }
277
+ // 2. Detect stalls
278
+ const stalls = this.detectStalls();
279
+ if (stalls.length > 0) {
280
+ this.stallsDetected += stalls.length;
281
+ for (const stall of stalls) {
282
+ this.log(`Stall detected: session=${stall.sessionId}, duration=${stall.durationMs}ms, blockers=[${stall.matchedBlockers.join(", ")}]`);
283
+ }
284
+ }
285
+ // 3. Auto-recover if enabled
286
+ if (this.config.autoRecover) {
287
+ this.attemptAutoRecovery(stalls);
288
+ }
289
+ // 4. Process pending recovery actions
290
+ this.processPendingRecoveries();
291
+ // 5. Write state
292
+ this.writeState();
293
+ this.consecutiveErrors = 0;
294
+ }
295
+ catch (err) {
296
+ this.consecutiveErrors++;
297
+ this.log(`Poll error (${this.consecutiveErrors}/${this.config.maxConsecutiveErrors}): ${err instanceof Error ? err.message : String(err)}`);
298
+ if (this.consecutiveErrors >= this.config.maxConsecutiveErrors) {
299
+ this.log("Max consecutive errors reached — stopping supervisor");
300
+ this.stop().catch(() => { });
301
+ }
302
+ }
303
+ }
304
+ attemptAutoRecovery(stalls) {
305
+ for (const stall of stalls) {
306
+ // Skip if there is already a pending/in-flight recovery, or a recent one (cooldown = stall threshold)
307
+ const cooldownMs = this.config.stallThresholdMs;
308
+ const hasActiveOrRecent = this.recoveries.some((r) => {
309
+ if (r.sessionId !== stall.sessionId)
310
+ return false;
311
+ if (r.status === "pending" || r.status === "attempted")
312
+ return true;
313
+ // Skip if a recovery completed recently (cooldown)
314
+ if (r.attemptedAt) {
315
+ const age = Date.now() - new Date(r.attemptedAt).getTime();
316
+ if (age < cooldownMs)
317
+ return true;
318
+ }
319
+ return false;
320
+ });
321
+ if (hasActiveOrRecent)
322
+ continue;
323
+ // Determine recovery type based on blockers
324
+ let type = "nudge";
325
+ let instruction = "Session appears stalled — send a heartbeat or check status.";
326
+ if (stall.matchedBlockers.length > 0) {
327
+ type = "escalate";
328
+ instruction = `Session blocked by: ${stall.matchedBlockers.join(", ")}. Requires human intervention.`;
329
+ }
330
+ else if (stall.durationMs > this.config.stallThresholdMs * 2) {
331
+ type = "restart";
332
+ instruction = "Session stalled for extended period — consider restarting.";
333
+ }
334
+ this.addRecovery(stall.sessionId, type, instruction);
335
+ }
336
+ }
337
+ processPendingRecoveries() {
338
+ // Re-read from disk to pick up recoveries added by MCP tools
339
+ this.loadRecoveries();
340
+ for (const recovery of this.recoveries) {
341
+ if (recovery.status !== "pending")
342
+ continue;
343
+ // Mark as attempted (actual execution is the daemon's responsibility)
344
+ recovery.status = "attempted";
345
+ recovery.attemptedAt = new Date().toISOString();
346
+ this.recoveriesAttempted++;
347
+ this.log(`Recovery attempted: ${recovery.id} (type=${recovery.type})`);
348
+ }
349
+ this.saveRecoveries();
350
+ }
351
+ getHealth(activeSessions) {
352
+ const uptimeMs = this.running
353
+ ? Date.now() - new Date(this.startedAt).getTime()
354
+ : 0;
355
+ // Derive counters from filesystem state so they survive across MCP/daemon restarts
356
+ // and reflect activity from both MCP tools and the daemon
357
+ const recoveries = this.recoveries;
358
+ const recoveriesAttempted = recoveries.filter((r) => r.status === "attempted" || r.status === "succeeded" || r.status === "failed").length;
359
+ // totalSessions = active + unique sessions that have completed recoveries (proxy for historical)
360
+ const historicalSessionIds = new Set(recoveries.map((r) => r.sessionId));
361
+ const activeSessionIds = new Set(activeSessions.map((s) => s.sessionId));
362
+ // Merge: active sessions + sessions only known from recovery history
363
+ const allKnownSessions = new Set([...historicalSessionIds, ...activeSessionIds]);
364
+ const totalSessions = Math.max(allKnownSessions.size, this.totalSessions);
365
+ return {
366
+ uptimeMs,
367
+ totalSessions,
368
+ activeSessions: activeSessions.length,
369
+ expiredLeases: this.expiredLeases,
370
+ stallsDetected: this.stallsDetected,
371
+ recoveriesAttempted,
372
+ };
373
+ }
374
+ writeState() {
375
+ const state = this.getState();
376
+ try {
377
+ writeFileAtomicSync(this.stateFile, JSON.stringify(state, null, 2));
378
+ }
379
+ catch {
380
+ // Ignore write errors
381
+ }
382
+ }
383
+ loadRecoveries() {
384
+ const loaded = readJsonWithRecovery(this.recoveriesFile);
385
+ this.recoveries = loaded ?? [];
386
+ }
387
+ saveRecoveries() {
388
+ try {
389
+ writeFileAtomicSync(this.recoveriesFile, JSON.stringify(this.recoveries, null, 2));
390
+ }
391
+ catch {
392
+ // Ignore write errors
393
+ }
394
+ }
395
+ log(msg) {
396
+ const line = `[${new Date().toISOString()}] ${msg}`;
397
+ if (this.logStream) {
398
+ this.logStream.write(line + "\n");
399
+ }
400
+ }
401
+ }
402
+ export { DEFAULT_SUPERVISOR_CONFIG } from "./types.js";
403
+ export { LeaseManager } from "./locks.js";
@@ -0,0 +1,30 @@
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
+ * Session Supervisor types — generic, client-agnostic session management
19
+ */
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ export const DEFAULT_SUPERVISOR_CONFIG = {
23
+ pollMs: 5000,
24
+ leaseTimeoutMs: 300000,
25
+ stallThresholdMs: 300000,
26
+ maxConsecutiveErrors: 5,
27
+ autoRecover: true,
28
+ stateDir: path.join(os.homedir(), ".screenhand", "supervisor"),
29
+ lockDir: path.join(os.homedir(), ".screenhand", "locks"),
30
+ };