@rubytech/taskmaster 1.0.49 → 1.0.50

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.
@@ -4,10 +4,15 @@ import { getHeadersWithAuth } from "./cdp.helpers.js";
4
4
  import { rawDataToString } from "../infra/ws.js";
5
5
  const log = createSubsystemLogger("browser").child("screencast-cdp");
6
6
  let sessionCounter = 0;
7
+ /** Interval (ms) for the Page.captureScreenshot polling fallback. */
8
+ const POLL_INTERVAL_MS = 500;
9
+ /** How long to wait for Page.startScreencast frames before falling back. */
10
+ const SCREENCAST_STALL_MS = 3000;
7
11
  /**
8
12
  * Start a persistent CDP screencast session.
9
- * Unlike `withCdpSocket` which opens/closes per call, this keeps
10
- * the WebSocket open for continuous frame delivery.
13
+ * Uses Page.startScreencast for compositor-driven frames, but falls back
14
+ * to Page.captureScreenshot polling when the compositor doesn't produce
15
+ * continuous frames (common on headless / software-rendered / Pi setups).
11
16
  */
12
17
  export async function startScreencast(opts) {
13
18
  const sessionId = ++sessionCounter;
@@ -31,29 +36,80 @@ export async function startScreencast(opts) {
31
36
  pending.set(msgId, { resolve, reject });
32
37
  });
33
38
  };
39
+ let frameCount = 0;
40
+ let pollInterval = null;
41
+ let stallTimeout = null;
42
+ let polling = false;
43
+ const cleanup = () => {
44
+ if (pollInterval) {
45
+ clearInterval(pollInterval);
46
+ pollInterval = null;
47
+ }
48
+ if (stallTimeout) {
49
+ clearTimeout(stallTimeout);
50
+ stallTimeout = null;
51
+ }
52
+ };
34
53
  const session = {
35
54
  id,
36
55
  ws,
37
56
  active: false,
38
57
  stop: () => {
39
58
  session.active = false;
59
+ cleanup();
60
+ // Don't send Page.stopScreencast — it's page-level and can race with
61
+ // a replacement session connecting to the same tab. Just close the socket.
40
62
  try {
41
- send("Page.stopScreencast").catch(() => { });
63
+ ws.close();
42
64
  }
43
65
  catch {
44
- // ignore
66
+ /* ignore */
45
67
  }
46
- setTimeout(() => {
47
- try {
48
- ws.close();
49
- }
50
- catch {
51
- /* ignore */
52
- }
53
- }, 100);
54
68
  },
55
69
  };
56
- let frameCount = 0;
70
+ /** Synthetic metadata for polling-based frames. */
71
+ const syntheticMeta = {
72
+ offsetTop: 0,
73
+ pageScaleFactor: 1,
74
+ deviceWidth: maxWidth,
75
+ deviceHeight: maxHeight,
76
+ scrollOffsetX: 0,
77
+ scrollOffsetY: 0,
78
+ };
79
+ /** Take a single screenshot and deliver it as a frame. */
80
+ const pollOnce = async () => {
81
+ if (!session.active)
82
+ return;
83
+ try {
84
+ const result = (await send("Page.captureScreenshot", {
85
+ format,
86
+ quality,
87
+ }));
88
+ const data = result?.data;
89
+ if (data && session.active) {
90
+ frameCount++;
91
+ if (frameCount <= 3 || frameCount % 100 === 0) {
92
+ log.info(`poll frame #${frameCount}: dataLen=${data.length}`);
93
+ }
94
+ opts.onFrame({ data, metadata: syntheticMeta, sessionId: 0 });
95
+ }
96
+ }
97
+ catch {
98
+ // ignore — socket may be closing
99
+ }
100
+ };
101
+ /** Switch from compositor-driven screencasting to polling. */
102
+ const startPollingFallback = () => {
103
+ if (polling || !session.active)
104
+ return;
105
+ polling = true;
106
+ log.warn(`screencast stalled (${frameCount} frames in ${SCREENCAST_STALL_MS}ms) — falling back to Page.captureScreenshot polling`);
107
+ // Stop the compositor screencast (best-effort)
108
+ send("Page.stopScreencast").catch(() => { });
109
+ pollInterval = setInterval(pollOnce, POLL_INTERVAL_MS);
110
+ // Fire one immediately so the UI gets a frame right away
111
+ pollOnce();
112
+ };
57
113
  ws.on("message", (raw) => {
58
114
  try {
59
115
  const msg = JSON.parse(rawDataToString(raw));
@@ -71,8 +127,8 @@ export async function startScreencast(opts) {
71
127
  }
72
128
  return;
73
129
  }
74
- // Handle screencast frame events
75
- if (msg.method === "Page.screencastFrame") {
130
+ // Handle screencast frame events (compositor-driven)
131
+ if (msg.method === "Page.screencastFrame" && !polling) {
76
132
  frameCount++;
77
133
  const params = msg.params;
78
134
  if (frameCount <= 3 || frameCount % 100 === 0) {
@@ -90,12 +146,18 @@ export async function startScreencast(opts) {
90
146
  });
91
147
  }
92
148
  }
93
- else if (msg.method) {
94
- // Log other CDP events (first few only) for debugging
95
- if (frameCount === 0) {
96
- log.debug(`CDP event: ${msg.method}`);
149
+ else if (msg.method === "Page.screencastVisibilityChanged") {
150
+ const visible = msg.params?.visible;
151
+ log.info(`Page.screencastVisibilityChanged visible=${visible}`);
152
+ // If the page becomes "not visible", the compositor won't produce
153
+ // frames. Switch to polling immediately.
154
+ if (visible === false && !polling && session.active) {
155
+ startPollingFallback();
97
156
  }
98
157
  }
158
+ else if (msg.method && frameCount === 0 && !polling) {
159
+ log.debug(`CDP event: ${msg.method}`);
160
+ }
99
161
  }
100
162
  catch {
101
163
  // ignore parse errors
@@ -104,6 +166,7 @@ export async function startScreencast(opts) {
104
166
  ws.on("close", (code, reason) => {
105
167
  log.info(`CDP socket closed: code=${code} reason=${reason?.toString() ?? ""}`);
106
168
  session.active = false;
169
+ cleanup();
107
170
  for (const [, p] of pending) {
108
171
  p.reject(new Error("CDP socket closed"));
109
172
  }
@@ -120,7 +183,6 @@ export async function startScreencast(opts) {
120
183
  ws.once("error", (err) => reject(err));
121
184
  });
122
185
  log.info("CDP socket connected, enabling Page domain");
123
- // Enable Page domain and start screencast
124
186
  await send("Page.enable");
125
187
  log.info(`starting Page.startScreencast format=${format} quality=${quality}`);
126
188
  await send("Page.startScreencast", {
@@ -131,12 +193,13 @@ export async function startScreencast(opts) {
131
193
  });
132
194
  session.active = true;
133
195
  log.info(`screencast active: ${id}`);
134
- // Warn if no frames arrive within 5 seconds — helps diagnose CDP/compositor issues
135
- setTimeout(() => {
136
- if (session.active && frameCount === 0) {
137
- log.warn(`no frames received after 5s CDP may not support screencast on this platform (headless? no display? software rendering?)`);
196
+ // If the compositor doesn't produce continuous frames within SCREENCAST_STALL_MS,
197
+ // fall back to polling. This handles headless, software-rendered, and Pi setups.
198
+ stallTimeout = setTimeout(() => {
199
+ if (session.active && !polling && frameCount <= 1) {
200
+ startPollingFallback();
138
201
  }
139
- }, 5000);
202
+ }, SCREENCAST_STALL_MS);
140
203
  return session;
141
204
  }
142
205
  /**
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.49",
3
- "commit": "599a958bd2f72fc80d487a91e1db7dc16306e99f",
4
- "builtAt": "2026-02-17T16:28:06.596Z"
2
+ "version": "1.0.50",
3
+ "commit": "881e4b648e2a7bcb69968482fdd8da58891ea37f",
4
+ "builtAt": "2026-02-17T16:36:05.944Z"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/taskmaster",
3
- "version": "1.0.49",
3
+ "version": "1.0.50",
4
4
  "description": "AI-powered business assistant for small businesses",
5
5
  "publishConfig": {
6
6
  "access": "public"