vg-coder-cli 2.0.67 → 2.0.68

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vg-coder-cli",
3
- "version": "2.0.67",
3
+ "version": "2.0.68",
4
4
  "description": "🚀 CLI tool to analyze projects, concatenate source files, count tokens, and export HTML with syntax highlighting and copy functionality",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -99,7 +99,12 @@ class ApiServer {
99
99
 
100
100
  // Task worker registration & lifecycle
101
101
  socket.on('worker:register', (meta) => { taskQueue.setWorker(socket, meta || {}); });
102
- socket.on('worker:heartbeat', () => { /* keep-alive only */ });
102
+ socket.on('worker:heartbeat', () => {
103
+ // Bump lastSeen so the stale-worker reaper doesn't drop a live
104
+ // socket that's just been quiet (no task in flight).
105
+ const w = taskQueue.workers.get(socket.id);
106
+ if (w) w.lastSeen = Date.now();
107
+ });
103
108
  socket.on('task:complete', (payload) => {
104
109
  if (payload?.taskId) taskQueue.onWorkerComplete(payload.taskId, payload, socket.id);
105
110
  });
@@ -799,6 +804,31 @@ class ApiServer {
799
804
  } catch (e) { res.status(launcherErr(e)).json({ error: e.message }); }
800
805
  });
801
806
 
807
+ // Block until the pinned launcher is ready for tasks. Replaces the common
808
+ // open-tab → poll-launchers → poll-workers pattern with a single sync
809
+ // call. Body: { chromeId, timeoutMs?: 15000, requireEmail?: true,
810
+ // requireWorker?: true }
811
+ // - 200 { ok: true, launcher, worker, elapsedMs } when ready
812
+ // - 404 launcher_not_found if chromeId never matched a launcher
813
+ // - 504 wait_ready_timeout if the readiness condition didn't hold in time
814
+ this.app.post('/api/launcher/wait-ready', async (req, res) => {
815
+ try {
816
+ const body = req.body || {};
817
+ const chromeId = (body.chromeId || '').toString().trim();
818
+ if (!chromeId) return res.status(400).json({ error: 'chromeId required' });
819
+ const result = await taskQueue.waitReady(chromeId, {
820
+ timeoutMs: body.timeoutMs,
821
+ requireEmail: body.requireEmail,
822
+ requireWorker: body.requireWorker,
823
+ });
824
+ res.json(result);
825
+ } catch (e) {
826
+ if (e.code === 'launcher_not_found') return res.status(404).json({ error: 'launcher_not_found' });
827
+ if (e.code === 'wait_ready_timeout') return res.status(504).json({ error: 'wait_ready_timeout', details: e.details });
828
+ res.status(500).json({ error: e.message });
829
+ }
830
+ });
831
+
802
832
  // Run a predefined chrome.* command inside the launcher SW. Manifest V3
803
833
  // CSP forbids new Function() in service workers, so eval was replaced by
804
834
  // a fixed command map. Body:
@@ -9,6 +9,13 @@ const AUTO_LAUNCH_DEBOUNCE_MS = 30_000;
9
9
  const INTER_TASK_GAP_MS = 2_500;
10
10
  const RATE_LIMIT_COOLDOWN_MS = 30 * 60_000;
11
11
  const COOLDOWN_TICK_MS = 60_000;
12
+ // Reap workers whose socket went silent: no heartbeat AND no task activity
13
+ // past this window. Heartbeat fires every 5s from task-worker.js, so 120s
14
+ // is ~24 missed beats — comfortably past any legitimate network hiccup but
15
+ // short enough that zombie sockets (Chrome tab closed without disconnect
16
+ // event reaching server, content-script paused by power-saver, etc.) don't
17
+ // linger forever and bloat _drain's idleWorkers snapshot.
18
+ const STALE_WORKER_TTL_MS = 120_000;
12
19
  const MAX_TASK_ATTEMPTS = 3;
13
20
  const RL_CODES = new Set(['rate_limit_exceeded', 'quota_exceeded', 'generation_failed']);
14
21
 
@@ -269,6 +276,62 @@ class TaskQueue {
269
276
  }
270
277
  }
271
278
 
279
+ /**
280
+ * Poll until a launcher's profile is "ready" for tasks: launcher connected,
281
+ * email scraped (if requireEmail), and ≥1 idle worker registered for the
282
+ * chromeId (if requireWorker). Saves callers from running their own
283
+ * GET /api/launchers + /api/workers poll loop after open-tab.
284
+ *
285
+ * Returns a snapshot on success; throws on timeout or immediate-miss.
286
+ */
287
+ async waitReady(chromeId, opts = {}) {
288
+ const timeoutMs = Math.min(Math.max(opts.timeoutMs | 0 || 15_000, 1_000), 60_000);
289
+ const requireEmail = opts.requireEmail !== false;
290
+ const requireWorker = opts.requireWorker !== false;
291
+ const pollMs = 250;
292
+ const deadline = Date.now() + timeoutMs;
293
+
294
+ // Immediate-miss: chromeId never matched a launcher → 404 contract
295
+ // (vs. timeout). Caller can map differently.
296
+ const hasLauncher = () => [...this.launchers.values()].some(l => l.chromeId === chromeId);
297
+ if (!hasLauncher()) {
298
+ const err = new Error('launcher_not_found');
299
+ err.code = 'launcher_not_found';
300
+ throw err;
301
+ }
302
+
303
+ while (Date.now() < deadline) {
304
+ const launcher = [...this.launchers.values()].find(l => l.chromeId === chromeId);
305
+ if (launcher) {
306
+ const emailOk = !requireEmail || (launcher.email && !launcher.email.startsWith('unknown:'));
307
+ const worker = !requireWorker ? null : [...this.workers.values()].find(w =>
308
+ w.chromeId === chromeId
309
+ && w.status === 'idle'
310
+ && w.email && !w.email.startsWith('unknown:')
311
+ );
312
+ const workerOk = !requireWorker || !!worker;
313
+ if (emailOk && workerOk) {
314
+ return {
315
+ ok: true,
316
+ launcher: { id: launcher.id, chromeId: launcher.chromeId, email: launcher.email },
317
+ worker: worker ? { id: worker.id, email: worker.email, status: worker.status } : null,
318
+ elapsedMs: timeoutMs - (deadline - Date.now()),
319
+ };
320
+ }
321
+ } else {
322
+ // Launcher disconnected during the wait — surface as not_found, not timeout.
323
+ const err = new Error('launcher_not_found');
324
+ err.code = 'launcher_not_found';
325
+ throw err;
326
+ }
327
+ await new Promise(r => setTimeout(r, pollMs));
328
+ }
329
+ const err = new Error('wait_ready_timeout');
330
+ err.code = 'wait_ready_timeout';
331
+ err.details = { chromeId, requireEmail, requireWorker, timeoutMs };
332
+ throw err;
333
+ }
334
+
272
335
  // Reverse lookup: find chromeId bound to email. Returns the first match —
273
336
  // if two launchers login the same email (test vs prod), this is ambiguous
274
337
  // and the caller should prefer explicit chromeId routing instead.
@@ -873,9 +936,33 @@ class TaskQueue {
873
936
  console.log(chalk.green(`[TaskQueue] Worker ${w.email} cooldown ended`));
874
937
  }
875
938
  }
939
+ this._reapStaleWorkers(now);
876
940
  if (revived) setImmediate(() => this._drain());
877
941
  }
878
942
 
943
+ // Drop workers whose socket has been silent past STALE_WORKER_TTL_MS. Skips
944
+ // busy/recycling workers — those are mid-task or mid-recycle and may
945
+ // legitimately not heartbeat until they emit task:complete. Reaping a
946
+ // zombie worker also triggers _drain because the removal frees a slot
947
+ // that the legitimate launcher can refill when it reconnects.
948
+ _reapStaleWorkers(now = Date.now()) {
949
+ let reaped = 0;
950
+ for (const w of [...this.workers.values()]) {
951
+ if (w.status === 'busy' || w.status === 'recycling') continue;
952
+ const idleFor = now - (w.lastSeen || 0);
953
+ if (idleFor <= STALE_WORKER_TTL_MS) continue;
954
+ console.log(chalk.yellow(
955
+ `[TaskQueue] Reaping stale worker ${w.id} (${w.email || 'unknown'}, idle ${Math.round(idleFor / 1000)}s)`
956
+ ));
957
+ // Drop from registry. Don't try to disconnect the socket — if it's
958
+ // genuinely alive, _drain will skip dispatching to it, and the next
959
+ // worker:heartbeat or worker:register from that socket re-adds it.
960
+ this.workers.delete(w.id);
961
+ reaped++;
962
+ }
963
+ if (reaped) setImmediate(() => this._drain());
964
+ }
965
+
879
966
  _autoLaunchBrowser(task = null) {
880
967
  const now = Date.now();
881
968
  if (now - this.launchedAt < AUTO_LAUNCH_DEBOUNCE_MS) return;