screenhand 0.1.0 → 0.2.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 (86) hide show
  1. package/LICENSE +661 -21
  2. package/README.md +208 -38
  3. package/dist/.audit-log.jsonl +55 -0
  4. package/dist/.screenhand/memory/.lock +1 -0
  5. package/dist/.screenhand/memory/actions.jsonl +85 -0
  6. package/dist/.screenhand/memory/errors.jsonl +5 -0
  7. package/dist/.screenhand/memory/errors.jsonl.bak +4 -0
  8. package/dist/.screenhand/memory/state.json +35 -0
  9. package/dist/.screenhand/memory/state.json.bak +35 -0
  10. package/dist/.screenhand/memory/strategies.jsonl +12 -0
  11. package/dist/agent/cli.js +73 -0
  12. package/dist/agent/loop.js +258 -0
  13. package/dist/index.js +1 -0
  14. package/dist/mcp/mcp-stdio-server.js +164 -0
  15. package/dist/mcp-desktop.js +2731 -0
  16. package/dist/mcp-entry.js +7 -10
  17. package/dist/monitor/codex-monitor.js +377 -0
  18. package/dist/monitor/task-queue.js +84 -0
  19. package/dist/monitor/types.js +49 -0
  20. package/dist/native/bridge-client.js +2 -1
  21. package/dist/npm-publish-helper.js +117 -0
  22. package/dist/npm-token-cdp.js +113 -0
  23. package/dist/npm-token-create.js +135 -0
  24. package/dist/npm-token-finish.js +126 -0
  25. package/dist/playbook/engine.js +193 -0
  26. package/dist/playbook/index.js +4 -0
  27. package/dist/playbook/recorder.js +519 -0
  28. package/dist/playbook/runner.js +392 -0
  29. package/dist/playbook/store.js +166 -0
  30. package/dist/playbook/types.js +4 -0
  31. package/dist/scripts/codex-monitor-daemon.js +335 -0
  32. package/dist/scripts/supervisor-daemon.js +272 -0
  33. package/dist/scripts/worker-daemon.js +228 -0
  34. package/dist/src/agent/cli.js +82 -0
  35. package/dist/src/agent/loop.js +274 -0
  36. package/dist/src/config.js +25 -0
  37. package/dist/src/index.js +72 -0
  38. package/dist/src/jobs/manager.js +237 -0
  39. package/dist/src/jobs/runner.js +683 -0
  40. package/dist/src/jobs/store.js +102 -0
  41. package/dist/src/jobs/types.js +30 -0
  42. package/dist/src/jobs/worker.js +97 -0
  43. package/dist/src/logging/timeline-logger.js +45 -0
  44. package/dist/src/mcp/mcp-stdio-server.js +464 -0
  45. package/dist/src/mcp/server.js +363 -0
  46. package/dist/src/mcp-entry.js +60 -0
  47. package/dist/src/memory/recall.js +170 -0
  48. package/dist/src/memory/research.js +104 -0
  49. package/dist/src/memory/seeds.js +101 -0
  50. package/dist/src/memory/service.js +421 -0
  51. package/dist/src/memory/session.js +169 -0
  52. package/dist/src/memory/store.js +422 -0
  53. package/dist/src/memory/types.js +17 -0
  54. package/dist/src/monitor/codex-monitor.js +382 -0
  55. package/dist/src/monitor/task-queue.js +97 -0
  56. package/dist/src/monitor/types.js +62 -0
  57. package/dist/src/native/bridge-client.js +190 -0
  58. package/dist/src/native/macos-bridge-client.js +21 -0
  59. package/dist/src/playbook/engine.js +201 -0
  60. package/dist/src/playbook/index.js +20 -0
  61. package/dist/src/playbook/recorder.js +535 -0
  62. package/dist/src/playbook/runner.js +408 -0
  63. package/dist/src/playbook/store.js +183 -0
  64. package/dist/src/playbook/types.js +17 -0
  65. package/dist/src/runtime/accessibility-adapter.js +393 -0
  66. package/dist/src/runtime/app-adapter.js +64 -0
  67. package/dist/src/runtime/applescript-adapter.js +299 -0
  68. package/dist/src/runtime/ax-role-map.js +96 -0
  69. package/dist/src/runtime/browser-adapter.js +52 -0
  70. package/dist/src/runtime/cdp-chrome-adapter.js +521 -0
  71. package/dist/src/runtime/composite-adapter.js +221 -0
  72. package/dist/src/runtime/execution-contract.js +159 -0
  73. package/dist/src/runtime/executor.js +266 -0
  74. package/dist/src/runtime/locator-cache.js +28 -0
  75. package/dist/src/runtime/planning-loop.js +63 -0
  76. package/dist/src/runtime/service.js +388 -0
  77. package/dist/src/runtime/session-manager.js +60 -0
  78. package/dist/src/runtime/state-observer.js +121 -0
  79. package/dist/src/runtime/vision-adapter.js +224 -0
  80. package/dist/src/supervisor/locks.js +186 -0
  81. package/dist/src/supervisor/supervisor.js +403 -0
  82. package/dist/src/supervisor/types.js +30 -0
  83. package/dist/src/test-mcp-protocol.js +154 -0
  84. package/dist/src/types.js +17 -0
  85. package/dist/src/util/atomic-write.js +118 -0
  86. package/package.json +12 -9
@@ -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
+ };
@@ -0,0 +1,154 @@
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
+ import { spawn } from "node:child_process";
18
+ import { createInterface } from "node:readline";
19
+ import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
+ const projectRoot = path.resolve(__dirname, "..");
23
+ const tsxBin = path.join(projectRoot, "node_modules", ".bin", "tsx");
24
+ const TIMEOUT_MS = 10_000;
25
+ const proc = spawn(tsxBin, [path.join(projectRoot, "src/mcp-entry.ts")], {
26
+ stdio: ["pipe", "pipe", "pipe"],
27
+ env: { ...process.env, SCREENHAND_ADAPTER: "placeholder" },
28
+ cwd: projectRoot,
29
+ });
30
+ let stderrBuf = "";
31
+ proc.stderr.on("data", (d) => { stderrBuf += d.toString(); });
32
+ // MCP SDK v1.27 uses newline-delimited JSON (NDJSON), not Content-Length framing
33
+ function send(msg) {
34
+ proc.stdin.write(JSON.stringify(msg) + "\n");
35
+ }
36
+ const rl = createInterface({ input: proc.stdout });
37
+ const lineQueue = [];
38
+ let lineWaiter = null;
39
+ rl.on("line", (line) => {
40
+ if (lineWaiter) {
41
+ const w = lineWaiter;
42
+ lineWaiter = null;
43
+ w(line);
44
+ }
45
+ else {
46
+ lineQueue.push(line);
47
+ }
48
+ });
49
+ function readResponse() {
50
+ return new Promise((resolve, reject) => {
51
+ const timer = setTimeout(() => {
52
+ lineWaiter = null;
53
+ reject(new Error(`Timeout. stderr: ${stderrBuf.slice(-300)}`));
54
+ }, TIMEOUT_MS);
55
+ const handle = (line) => {
56
+ clearTimeout(timer);
57
+ resolve(JSON.parse(line));
58
+ };
59
+ const queued = lineQueue.shift();
60
+ if (queued) {
61
+ clearTimeout(timer);
62
+ resolve(JSON.parse(queued));
63
+ }
64
+ else {
65
+ lineWaiter = handle;
66
+ }
67
+ });
68
+ }
69
+ function fail(msg) {
70
+ console.error("FAIL:", msg);
71
+ proc.kill();
72
+ process.exit(1);
73
+ }
74
+ try {
75
+ // Wait for server to start
76
+ await new Promise((r) => setTimeout(r, 2000));
77
+ // 1. Initialize
78
+ console.log("Sending initialize...");
79
+ send({
80
+ jsonrpc: "2.0",
81
+ id: 1,
82
+ method: "initialize",
83
+ params: {
84
+ protocolVersion: "2024-11-05",
85
+ capabilities: {},
86
+ clientInfo: { name: "test-client", version: "1.0" },
87
+ },
88
+ });
89
+ const initResp = await readResponse();
90
+ const initResult = initResp.result;
91
+ if (!initResult)
92
+ fail(`No init result: ${JSON.stringify(initResp)}`);
93
+ console.log("=== Initialize ===");
94
+ console.log(` Protocol: ${initResult.protocolVersion}`);
95
+ console.log(` Server: ${JSON.stringify(initResult.serverInfo)}`);
96
+ // 2. Send initialized notification
97
+ send({ jsonrpc: "2.0", method: "notifications/initialized" });
98
+ await new Promise((r) => setTimeout(r, 300));
99
+ // 3. List tools
100
+ console.log("\nListing tools...");
101
+ send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
102
+ const toolsResp = await readResponse();
103
+ const toolsResult = toolsResp.result;
104
+ if (!toolsResult)
105
+ fail(`No tools result: ${JSON.stringify(toolsResp)}`);
106
+ const tools = toolsResult.tools ?? [];
107
+ console.log("=== Tools ===");
108
+ for (const tool of tools) {
109
+ console.log(` ${tool.name}: ${(tool.description ?? "").slice(0, 70)}`);
110
+ }
111
+ console.log(`\n Total: ${tools.length} tools`);
112
+ if (tools.length < 10)
113
+ fail(`Expected 16 tools, got ${tools.length}`);
114
+ // 4. Test session_start
115
+ console.log("\nCalling session_start...");
116
+ send({
117
+ jsonrpc: "2.0",
118
+ id: 3,
119
+ method: "tools/call",
120
+ params: { name: "session_start", arguments: {} },
121
+ });
122
+ const sessionResp = await readResponse();
123
+ const sessionResult = sessionResp.result;
124
+ if (!sessionResult)
125
+ fail(`No session result: ${JSON.stringify(sessionResp)}`);
126
+ const sessionContent = sessionResult.content;
127
+ const sessionData = JSON.parse(sessionContent?.[0]?.text ?? "{}");
128
+ console.log("=== session_start ===");
129
+ console.log(` Session ID: ${sessionData.sessionId}`);
130
+ console.log(` Profile: ${sessionData.profile}`);
131
+ if (!sessionData.sessionId)
132
+ fail("No sessionId returned");
133
+ // 5. Test app_list (should work with placeholder)
134
+ console.log("\nCalling app_list...");
135
+ send({
136
+ jsonrpc: "2.0",
137
+ id: 4,
138
+ method: "tools/call",
139
+ params: { name: "app_list", arguments: { sessionId: sessionData.sessionId } },
140
+ });
141
+ const appResp = await readResponse();
142
+ const appResult = appResp.result;
143
+ console.log("=== app_list ===");
144
+ const appContent = appResult?.content;
145
+ const isError = appResult?.isError;
146
+ console.log(` isError: ${isError ?? false}`);
147
+ console.log(` Response: ${(appContent?.[0]?.text ?? "").slice(0, 100)}`);
148
+ proc.kill();
149
+ console.log("\nAll tests passed!");
150
+ process.exit(0);
151
+ }
152
+ catch (e) {
153
+ fail(e instanceof Error ? e.message : String(e));
154
+ }
@@ -0,0 +1,17 @@
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 {};