claude-code-session-manager 0.35.11 → 0.35.14

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.
@@ -21,6 +21,11 @@ const crypto = require('crypto');
21
21
  const { WebContentsView } = require('electron');
22
22
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
23
23
  const { readJson, writeJson } = require('./config.cjs');
24
+ const {
25
+ parseKeyCombo,
26
+ computeScrollDelta,
27
+ computeScreenshotScale,
28
+ } = require('./lib/browserAgentActions.cjs');
24
29
 
25
30
  // ── History + zoom persistence (PRD 402) ──────────────────────────────
26
31
  // Both files live under ~/.claude/session-manager/browser/, inside
@@ -70,6 +75,10 @@ const views = new Map();
70
75
  // main-window nav lock without weakening the lock itself.
71
76
  const browserViewContentsIds = new Set();
72
77
  let win = null;
78
+ // Last viewId shown via show() — the renderer's proxy for "which sub-tab is
79
+ // active", since WebContentsView exposes setVisible() but no getter. Used to
80
+ // default viewId-optional browser-agent-server routes to the active tab.
81
+ let lastShownViewId = null;
73
82
 
74
83
  // ── Recorder engine (PRD 408) ─────────────────────────────────────────
75
84
  // viewId <-> webContents.id so the record-event channel (sent from the
@@ -248,6 +257,7 @@ function show({ viewId }) {
248
257
  const view = views.get(viewId);
249
258
  if (!view) return { ok: false };
250
259
  view.setVisible(true);
260
+ lastShownViewId = viewId;
251
261
  return { ok: true };
252
262
  }
253
263
 
@@ -266,6 +276,7 @@ function destroy({ viewId }) {
266
276
  recordingViewIds.delete(viewId);
267
277
  stepCounters.delete(viewId);
268
278
  lastFindText.delete(viewId);
279
+ if (lastShownViewId === viewId) lastShownViewId = null;
269
280
  if (win && !win.isDestroyed()) {
270
281
  try { win.contentView.removeChildView(view); } catch { /* already detached */ }
271
282
  }
@@ -576,6 +587,181 @@ function stop({ viewId }) {
576
587
  return { ok: true };
577
588
  }
578
589
 
590
+ // ── On-demand single-action agent API (PRD 535 foundation) ────────────
591
+ // Modeled on Anthropic's published computer_use tool schema. Reuses the same
592
+ // dispatch primitives the Recorder replay engine already established above
593
+ // (replayPositionedClick for coordinate clicks, normalizeUrl/withTimeout for
594
+ // navigate) instead of forking a second copy — the difference from replay()
595
+ // is that this drives ONE ad-hoc action at a time by raw coordinate, not a
596
+ // recorded `steps` array by selector.
597
+ function listViews() {
598
+ const result = [];
599
+ for (const [viewId, view] of views.entries()) {
600
+ if (view.webContents.isDestroyed()) continue;
601
+ result.push({
602
+ viewId,
603
+ url: view.webContents.getURL(),
604
+ title: view.webContents.getTitle(),
605
+ active: viewId === lastShownViewId,
606
+ });
607
+ }
608
+ return result;
609
+ }
610
+
611
+ function resolveViewId(viewId) {
612
+ return viewId || lastShownViewId;
613
+ }
614
+
615
+ async function dispatchAction(view, params) {
616
+ const wc = view.webContents;
617
+ switch (params.action) {
618
+ case 'left_click': {
619
+ const [x, y] = Array.isArray(params.coordinate) ? params.coordinate : [];
620
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
621
+ throw new Error('left_click requires coordinate: [x, y]');
622
+ }
623
+ await replayPositionedClick(wc, x, y);
624
+ return { ok: true };
625
+ }
626
+ case 'type': {
627
+ if (typeof params.text !== 'string') throw new Error('type requires text');
628
+ await wc.insertText(params.text);
629
+ return { ok: true };
630
+ }
631
+ case 'key': {
632
+ if (typeof params.key !== 'string' || !params.key) throw new Error('key requires key');
633
+ const { keyCode, modifiers } = parseKeyCombo(params.key);
634
+ wc.sendInputEvent({ type: 'keyDown', keyCode, modifiers });
635
+ if (keyCode.length === 1) wc.sendInputEvent({ type: 'char', keyCode, modifiers });
636
+ wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers });
637
+ return { ok: true };
638
+ }
639
+ case 'scroll': {
640
+ const [x, y] = Array.isArray(params.coordinate) ? params.coordinate : [0, 0];
641
+ const { deltaX, deltaY } = computeScrollDelta(params);
642
+ wc.sendInputEvent({ type: 'mouseWheel', x: Number(x) || 0, y: Number(y) || 0, deltaX, deltaY });
643
+ return { ok: true };
644
+ }
645
+ case 'navigate': {
646
+ if (typeof params.url !== 'string' || !params.url) throw new Error('navigate requires url');
647
+ const normalized = normalizeUrl(params.url);
648
+ if (!normalized.ok) throw new Error(normalized.error);
649
+ await withTimeout(wc.loadURL(normalized.url), REPLAY_STEP_TIMEOUT_MS, 'navigate');
650
+ return { ok: true };
651
+ }
652
+ default:
653
+ throw new Error(`unsupported action: ${params.action}`);
654
+ }
655
+ }
656
+
657
+ // Single-action entry point (as opposed to replay()'s recorded-steps-array
658
+ // entry point) — resolves viewId (defaulting to the active tab), dispatches
659
+ // exactly one action, and normalizes errors into the { ok, error } shape the
660
+ // rest of this module already uses.
661
+ async function dispatchViewAction(params) {
662
+ const viewId = resolveViewId(params.viewId);
663
+ if (!viewId) return { ok: false, error: 'no viewId given and no active tab' };
664
+ const view = views.get(viewId);
665
+ if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
666
+ try {
667
+ return await dispatchAction(view, params);
668
+ } catch (e) {
669
+ return { ok: false, error: e && e.message ? e.message : String(e) };
670
+ }
671
+ }
672
+
673
+ // On-demand screenshot for the agent API. Reuses wc.capturePage() (same call
674
+ // captureShot() below already uses) but additionally resizes to a fixed
675
+ // logical resolution — Anthropic's computer-use API silently downscales
676
+ // oversized screenshots server-side, which breaks coordinate mapping unless
677
+ // caller and model agree on one fixed max dimension. `scale` in the response
678
+ // lets a caller map a coordinate clicked on the returned image back to the
679
+ // real page: realX = clickedX / scale.
680
+ async function captureShotForAgent({ viewId } = {}) {
681
+ const id = resolveViewId(viewId);
682
+ if (!id) return { ok: false, error: 'no viewId given and no active tab' };
683
+ const view = views.get(id);
684
+ if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
685
+ try {
686
+ const wc = view.webContents;
687
+ const image = await wc.capturePage();
688
+ const { width, height } = image.getSize();
689
+ const target = computeScreenshotScale(width, height);
690
+ const resized = target.scale < 1 ? image.resize({ width: target.width, height: target.height }) : image;
691
+ return {
692
+ ok: true,
693
+ viewId: id,
694
+ url: wc.getURL(),
695
+ title: wc.getTitle(),
696
+ dataUrl: resized.toDataURL(),
697
+ scale: target.scale,
698
+ width: target.width,
699
+ height: target.height,
700
+ originalWidth: width,
701
+ originalHeight: height,
702
+ };
703
+ } catch (e) {
704
+ return { ok: false, error: e && e.message ? e.message : String(e) };
705
+ }
706
+ }
707
+
708
+ // Cheap text/accessibility-style snapshot for the agent API — an alternative
709
+ // to vision-only driving since a real DOM is available here. Follows the
710
+ // same executeJavaScript sandboxing pattern as captureDom() below (plain
711
+ // IIFE returning a plain object, no new capability granted to the page).
712
+ // Kept cheap on purpose: innerText + a capped list of interactive elements
713
+ // with center-point coordinates, no full HTML serialization or computed
714
+ // style walking.
715
+ const AGENT_DOM_TEXT_MAX = 100_000;
716
+ const AGENT_DOM_ELEMENT_LIMIT = 200;
717
+
718
+ async function captureAgentDom({ viewId } = {}) {
719
+ const id = resolveViewId(viewId);
720
+ if (!id) return { ok: false, error: 'no viewId given and no active tab' };
721
+ const view = views.get(id);
722
+ if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
723
+ try {
724
+ const script = `(() => {
725
+ const SEL = 'button, a[href], input, textarea, select, [role="button"], [onclick]';
726
+ const els = Array.from(document.querySelectorAll(SEL)).slice(0, ${AGENT_DOM_ELEMENT_LIMIT});
727
+ const elements = els.map((el) => {
728
+ const rect = el.getBoundingClientRect();
729
+ const label = (el.innerText || el.value || el.getAttribute('aria-label') || el.getAttribute('placeholder') || '').trim().slice(0, 120);
730
+ return {
731
+ tag: el.tagName.toLowerCase(),
732
+ label,
733
+ x: Math.round(rect.left + rect.width / 2),
734
+ y: Math.round(rect.top + rect.height / 2),
735
+ };
736
+ }).filter((e) => e.x >= 0 && e.y >= 0 && Number.isFinite(e.x) && Number.isFinite(e.y));
737
+ return {
738
+ url: location.href,
739
+ title: document.title,
740
+ text: document.body ? document.body.innerText : '',
741
+ elements,
742
+ };
743
+ })()`;
744
+ const result = await view.webContents.executeJavaScript(script);
745
+ let text = typeof result?.text === 'string' ? result.text : '';
746
+ let truncated = false;
747
+ if (text.length > AGENT_DOM_TEXT_MAX) {
748
+ text = text.slice(0, AGENT_DOM_TEXT_MAX);
749
+ truncated = true;
750
+ }
751
+ return {
752
+ ok: true,
753
+ viewId: id,
754
+ url: result?.url || '',
755
+ title: result?.title || '',
756
+ text,
757
+ truncated,
758
+ elements: Array.isArray(result?.elements) ? result.elements : [],
759
+ };
760
+ } catch (e) {
761
+ return { ok: false, error: e && e.message ? e.message : String(e) };
762
+ }
763
+ }
764
+
579
765
  // Cap returned capture text so a huge DOM can't blow up the IPC channel.
580
766
  const CAPTURE_TEXT_MAX = 500_000;
581
767
 
@@ -655,4 +841,14 @@ function registerBrowserView({ mainWindow, ipcMain }) {
655
841
  }));
656
842
  }
657
843
 
658
- module.exports = { registerBrowserView, attachWindow, views, isBrowserViewContents, getView };
844
+ module.exports = {
845
+ registerBrowserView,
846
+ attachWindow,
847
+ views,
848
+ isBrowserViewContents,
849
+ getView,
850
+ listViews,
851
+ dispatchViewAction,
852
+ captureShotForAgent,
853
+ captureAgentDom,
854
+ };
@@ -26,6 +26,13 @@ const voiceWizard = require('./voiceWizard.cjs');
26
26
  const scheduler = require('./scheduler.cjs');
27
27
  const { createAdminServer } = require('./adminServer.cjs');
28
28
  const adminServer = createAdminServer(scheduler.remote);
29
+ const { createBrowserAgentServer } = require('./browserAgentServer.cjs');
30
+ const browserAgentServer = createBrowserAgentServer({
31
+ listTabs: () => browserView.listViews(),
32
+ screenshot: ({ viewId }) => browserView.captureShotForAgent({ viewId }),
33
+ action: (params) => browserView.dispatchViewAction(params),
34
+ dom: ({ viewId }) => browserView.captureAgentDom({ viewId }),
35
+ });
29
36
  const supervisor = require('./supervisor.cjs');
30
37
  const watchers = require('./watchers.cjs');
31
38
  const teams = require('./teams.cjs');
@@ -1059,6 +1066,9 @@ app.whenReady().then(async () => {
1059
1066
  adminServer.start().catch((e) => {
1060
1067
  logs.writeLine({ scope: 'admin-server', level: 'error', message: 'init failed', meta: { error: e?.message } });
1061
1068
  });
1069
+ browserAgentServer.start().catch((e) => {
1070
+ logs.writeLine({ scope: 'browser-agent-server', level: 'error', message: 'init failed', meta: { error: e?.message } });
1071
+ });
1062
1072
  // First-boot default: install the bundled session-manager-dev plugin (its 10
1063
1073
  // dev skills) from the app's own marketplace. One-shot + idempotent; never
1064
1074
  // throws. SM_SEED_DEV_PLUGIN_DISABLE=1 to opt out.
@@ -1149,6 +1159,7 @@ app.on('before-quit', () => {
1149
1159
  transcripts.closeAll();
1150
1160
  watchers.manager.killAll();
1151
1161
  adminServer.stop().catch(() => {});
1162
+ browserAgentServer.stop().catch(() => {});
1152
1163
  // Best-effort flush of any pending OTEL spans. shutdown() has its own 2s
1153
1164
  // ceiling so a wedged exporter can't hold quit.
1154
1165
  otel.shutdown().catch(() => {});
@@ -0,0 +1,76 @@
1
+ /**
2
+ * browserAgentActions.test.cjs — unit tests for the pure action-mapping
3
+ * logic behind the on-demand browser-agent API (PRD 535).
4
+ *
5
+ * Run: timeout 120 node --test src/main/lib/__tests__/browserAgentActions.test.cjs
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const { test } = require('node:test');
11
+ const assert = require('node:assert/strict');
12
+ const {
13
+ parseKeyCombo,
14
+ computeScrollDelta,
15
+ computeScreenshotScale,
16
+ AGENT_SCREENSHOT_MAX_DIM,
17
+ } = require('../browserAgentActions.cjs');
18
+
19
+ test('parseKeyCombo: plain special key', () => {
20
+ assert.deepEqual(parseKeyCombo('Return'), { keyCode: 'Enter', modifiers: [] });
21
+ });
22
+
23
+ test('parseKeyCombo: single modifier + letter', () => {
24
+ assert.deepEqual(parseKeyCombo('ctrl+s'), { keyCode: 's', modifiers: ['control'] });
25
+ });
26
+
27
+ test('parseKeyCombo: multiple modifiers', () => {
28
+ assert.deepEqual(parseKeyCombo('ctrl+shift+p'), { keyCode: 'p', modifiers: ['control', 'shift'] });
29
+ });
30
+
31
+ test('parseKeyCombo: cmd/meta/option aliases normalize', () => {
32
+ assert.deepEqual(parseKeyCombo('cmd+option+Escape'), { keyCode: 'Escape', modifiers: ['meta', 'alt'] });
33
+ });
34
+
35
+ test('parseKeyCombo: unknown modifier throws', () => {
36
+ assert.throws(() => parseKeyCombo('foo+a'), /unknown modifier/);
37
+ });
38
+
39
+ test('parseKeyCombo: empty string throws', () => {
40
+ assert.throws(() => parseKeyCombo(''), /empty key/);
41
+ });
42
+
43
+ test('computeScrollDelta: up/down/left/right map to signed deltas', () => {
44
+ assert.deepEqual(computeScrollDelta({ scroll_direction: 'up', scroll_amount: 2 }), { deltaX: 0, deltaY: 200 });
45
+ assert.deepEqual(computeScrollDelta({ scroll_direction: 'down', scroll_amount: 2 }), { deltaX: 0, deltaY: -200 });
46
+ assert.deepEqual(computeScrollDelta({ scroll_direction: 'left', scroll_amount: 1 }), { deltaX: 100, deltaY: 0 });
47
+ assert.deepEqual(computeScrollDelta({ scroll_direction: 'right', scroll_amount: 1 }), { deltaX: -100, deltaY: 0 });
48
+ });
49
+
50
+ test('computeScrollDelta: defaults scroll_amount to 1 when not finite', () => {
51
+ assert.deepEqual(computeScrollDelta({ scroll_direction: 'up' }), { deltaX: 0, deltaY: 100 });
52
+ });
53
+
54
+ test('computeScrollDelta: unsupported direction throws', () => {
55
+ assert.throws(() => computeScrollDelta({ scroll_direction: 'diagonal' }), /unsupported scroll_direction/);
56
+ });
57
+
58
+ test('computeScreenshotScale: leaves small images untouched', () => {
59
+ const result = computeScreenshotScale(800, 600);
60
+ assert.equal(result.scale, 1);
61
+ assert.equal(result.width, 800);
62
+ assert.equal(result.height, 600);
63
+ });
64
+
65
+ test('computeScreenshotScale: downscales long edge to the fixed max', () => {
66
+ const result = computeScreenshotScale(2560, 1440);
67
+ assert.equal(result.width, AGENT_SCREENSHOT_MAX_DIM);
68
+ assert.ok(result.scale < 1);
69
+ assert.equal(result.height, Math.round(1440 * result.scale));
70
+ });
71
+
72
+ test('computeScreenshotScale: portrait image scales on height', () => {
73
+ const result = computeScreenshotScale(900, 3000);
74
+ assert.equal(result.height, AGENT_SCREENSHOT_MAX_DIM);
75
+ assert.ok(result.width < 900);
76
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * browserAgentActions.cjs — pure mapping logic for the on-demand single-
3
+ * action browser-agent API (PRD 535). Extracted out of browserView.cjs so
4
+ * the action -> input-event/scale mapping can be unit-tested without
5
+ * booting Electron — mirrors how lib/schedulerBatch.cjs was pulled out of
6
+ * scheduler.cjs for the same reason. Anything that actually touches a live
7
+ * WebContentsView (sendInputEvent, capturePage, executeJavaScript) stays in
8
+ * browserView.cjs; this module only computes the values passed to those
9
+ * calls.
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ // Anthropic's computer_use tool scrolls in "clicks"; one click here maps to
15
+ // this many CSS pixels of wheel delta.
16
+ const SCROLL_UNIT_PX = 100;
17
+
18
+ // Anthropic-tool-use-style key names -> Electron's sendInputEvent keyCode
19
+ // strings. Anything not in this table is passed through as-is (covers plain
20
+ // single characters like "a" or "s").
21
+ const SPECIAL_KEY_MAP = {
22
+ return: 'Enter',
23
+ enter: 'Enter',
24
+ tab: 'Tab',
25
+ escape: 'Escape',
26
+ esc: 'Escape',
27
+ backspace: 'Backspace',
28
+ delete: 'Delete',
29
+ space: ' ',
30
+ up: 'Up',
31
+ down: 'Down',
32
+ left: 'Left',
33
+ right: 'Right',
34
+ page_up: 'PageUp',
35
+ pageup: 'PageUp',
36
+ page_down: 'PageDown',
37
+ pagedown: 'PageDown',
38
+ home: 'Home',
39
+ end: 'End',
40
+ };
41
+
42
+ const MODIFIER_MAP = {
43
+ ctrl: 'control',
44
+ control: 'control',
45
+ shift: 'shift',
46
+ alt: 'alt',
47
+ option: 'alt',
48
+ cmd: 'meta',
49
+ meta: 'meta',
50
+ super: 'meta',
51
+ };
52
+
53
+ // Parses a computer-use-style key combo ("ctrl+s", "Return", "a") into the
54
+ // { keyCode, modifiers } shape Electron's webContents.sendInputEvent wants.
55
+ function parseKeyCombo(keyStr) {
56
+ const parts = String(keyStr || '')
57
+ .split('+')
58
+ .map((p) => p.trim())
59
+ .filter(Boolean);
60
+ if (!parts.length) throw new Error('empty key');
61
+ const last = parts.pop();
62
+ const modifiers = parts.map((p) => {
63
+ const mapped = MODIFIER_MAP[p.toLowerCase()];
64
+ if (!mapped) throw new Error(`unknown modifier: ${p}`);
65
+ return mapped;
66
+ });
67
+ const lowerLast = last.toLowerCase();
68
+ const keyCode = SPECIAL_KEY_MAP[lowerLast] || last;
69
+ return { keyCode, modifiers };
70
+ }
71
+
72
+ // Maps a computer-use-style scroll action to a mouseWheel delta.
73
+ function computeScrollDelta({ scroll_direction, scroll_amount }) {
74
+ const amount = Number.isFinite(scroll_amount) ? scroll_amount : 1;
75
+ switch (scroll_direction) {
76
+ case 'up':
77
+ return { deltaX: 0, deltaY: amount * SCROLL_UNIT_PX };
78
+ case 'down':
79
+ return { deltaX: 0, deltaY: -amount * SCROLL_UNIT_PX };
80
+ case 'left':
81
+ return { deltaX: amount * SCROLL_UNIT_PX, deltaY: 0 };
82
+ case 'right':
83
+ return { deltaX: -amount * SCROLL_UNIT_PX, deltaY: 0 };
84
+ default:
85
+ throw new Error(`unsupported scroll_direction: ${scroll_direction}`);
86
+ }
87
+ }
88
+
89
+ // Fixed max long-edge a screenshot is resized to before being returned.
90
+ // Anthropic's computer-use API silently downscales oversized images
91
+ // server-side, which breaks coordinate mapping unless caller and model agree
92
+ // on one fixed logical resolution up front — this is that fixed point.
93
+ const AGENT_SCREENSHOT_MAX_DIM = 1280;
94
+
95
+ // Given a captured screenshot's real pixel size, returns the target size to
96
+ // resize to plus the scale factor a caller must divide a clicked coordinate
97
+ // on the resized image by to recover the real-page coordinate.
98
+ function computeScreenshotScale(width, height) {
99
+ const longEdge = Math.max(width, height);
100
+ const scale = longEdge > AGENT_SCREENSHOT_MAX_DIM ? AGENT_SCREENSHOT_MAX_DIM / longEdge : 1;
101
+ return {
102
+ scale,
103
+ width: Math.max(1, Math.round(width * scale)),
104
+ height: Math.max(1, Math.round(height * scale)),
105
+ };
106
+ }
107
+
108
+ module.exports = {
109
+ parseKeyCombo,
110
+ computeScrollDelta,
111
+ computeScreenshotScale,
112
+ AGENT_SCREENSHOT_MAX_DIM,
113
+ SCROLL_UNIT_PX,
114
+ };
@@ -33,13 +33,15 @@ const DEFAULT_PROJECT_CWD = path.join(os.homedir(), 'Projects', 'session-manager
33
33
  * runningSet that belong to this project.
34
34
  * @param {number} slots - Maximum jobs to return (global remaining slots;
35
35
  * caller enforces the global cap across projects).
36
- * @returns {object[]} Jobs to spawn for this project this tick.
36
+ * @returns {{ batch: object[], reason: string | null }} Jobs to spawn for this
37
+ * project this tick, plus (when batch is empty because a gate held it) the
38
+ * human-readable reason text that would otherwise only reach console.log.
37
39
  */
38
40
  function pickForProject(projectJobs, runningSlugsInProject, slots) {
39
41
  const pending = projectJobs.filter(
40
42
  (j) => j.status === 'pending' && !runningSlugsInProject.has(j.slug),
41
43
  );
42
- if (pending.length === 0) return [];
44
+ if (pending.length === 0) return { batch: [], reason: null };
43
45
 
44
46
  const projectCwd = (projectJobs.find((j) => j.cwd) || {}).cwd || DEFAULT_PROJECT_CWD;
45
47
 
@@ -57,12 +59,11 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
57
59
  );
58
60
  if (blockingFailures.length > 0) {
59
61
  const slugs = blockingFailures.map((j) => j.slug).join(', ');
60
- console.log(
61
- `[scheduler] failure-gate [${projectCwd}]: holding g${lowestPendingGroup} — ` +
62
+ const reason = `[scheduler] failure-gate [${projectCwd}]: holding g${lowestPendingGroup} — ` +
62
63
  `${blockingFailures.length} failed job(s) in earlier groups [${slugs}]. ` +
63
- `Reset to pending or archive to unblock.`,
64
- );
65
- return [];
64
+ `Reset to pending or archive to unblock.`;
65
+ console.log(reason);
66
+ return { batch: [], reason };
66
67
  }
67
68
 
68
69
  // Groups with at least one job in flight: either tracked in runningSlugsInProject
@@ -84,20 +85,18 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
84
85
  const lowestActive = Math.min(...activeGroups);
85
86
  if (lowestPendingGroup > lowestActive) {
86
87
  // Earlier group still running — wait for it to drain before advancing.
87
- console.log(
88
- `[scheduler] concurrency [${projectCwd}]: g${lowestActive} in flight, holding g${lowestPendingGroup}`,
89
- );
90
- return [];
88
+ const reason = `[scheduler] concurrency [${projectCwd}]: g${lowestActive} in flight, holding g${lowestPendingGroup}`;
89
+ console.log(reason);
90
+ return { batch: [], reason };
91
91
  }
92
92
  if (lowestPendingGroup < lowestActive) {
93
93
  // Late-arrival: a lower-numbered (higher-priority) PRD reconciled AFTER
94
94
  // a higher-numbered group was already picked. Fire it now in parallel
95
95
  // with the active group rather than starving it until drain.
96
96
  if (slots <= 0) {
97
- console.log(
98
- `[scheduler] concurrency [${projectCwd}]: no slots for late-arrival g${lowestPendingGroup}`,
99
- );
100
- return [];
97
+ const reason = `[scheduler] concurrency [${projectCwd}]: no slots for late-arrival g${lowestPendingGroup}`;
98
+ console.log(reason);
99
+ return { batch: [], reason };
101
100
  }
102
101
  const batch = pending
103
102
  .filter((j) => (j.parallelGroup ?? 99) === lowestPendingGroup)
@@ -106,12 +105,13 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
106
105
  `[scheduler] concurrency [${projectCwd}]: firing late-arrival g${lowestPendingGroup} ` +
107
106
  `(${batch.length} job(s)) alongside active g${lowestActive}`,
108
107
  );
109
- return batch;
108
+ return { batch, reason: null };
110
109
  }
111
110
  // Backfill slots remaining in the current group.
112
111
  if (slots <= 0) {
113
- console.log(`[scheduler] concurrency [${projectCwd}]: cap reached, no slots`);
114
- return [];
112
+ const reason = `[scheduler] concurrency [${projectCwd}]: cap reached, no slots`;
113
+ console.log(reason);
114
+ return { batch: [], reason };
115
115
  }
116
116
  const batch = pending
117
117
  .filter((j) => (j.parallelGroup ?? 99) === lowestActive)
@@ -121,13 +121,14 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
121
121
  `[scheduler] concurrency [${projectCwd}]: backfilling ${batch.length} into g${lowestActive}`,
122
122
  );
123
123
  }
124
- return batch;
124
+ return { batch, reason: null };
125
125
  }
126
126
 
127
127
  // No active group — start the next group fresh.
128
128
  if (slots <= 0) {
129
- console.log(`[scheduler] concurrency [${projectCwd}]: cap reached, no slots`);
130
- return [];
129
+ const reason = `[scheduler] concurrency [${projectCwd}]: cap reached, no slots`;
130
+ console.log(reason);
131
+ return { batch: [], reason };
131
132
  }
132
133
  const batch = pending
133
134
  .filter((j) => (j.parallelGroup ?? 99) === lowestPendingGroup)
@@ -135,7 +136,7 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
135
136
  console.log(
136
137
  `[scheduler] concurrency [${projectCwd}]: starting g${lowestPendingGroup} with ${batch.length} job(s)`,
137
138
  );
138
- return batch;
139
+ return { batch, reason: null };
139
140
  }
140
141
 
141
142
  /**
@@ -150,10 +151,14 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
150
151
  * @param {object[]} allJobs - Full queue.json job list.
151
152
  * @param {Set<string>} running - In-process running slugs (runningSet).
152
153
  * @param {number} cap - concurrencyCap.
153
- * @returns {object[]} Jobs to spawn this tick.
154
+ * @returns {{ batch: object[], reason: string | null }} Jobs to spawn this
155
+ * tick, plus (when batch is empty because a gate held it) the human-readable
156
+ * hold reason that would otherwise only reach console.log.
154
157
  */
155
158
  function pickNextBatch(allJobs, running, cap) {
156
- if (!allJobs.some((j) => j.status === 'pending' && !running.has(j.slug))) return [];
159
+ if (!allJobs.some((j) => j.status === 'pending' && !running.has(j.slug))) {
160
+ return { batch: [], reason: null };
161
+ }
157
162
 
158
163
  // Global slot accounting: take the higher of in-process running count and
159
164
  // queue.json running count (handles orphaned running entries from a previous
@@ -162,10 +167,9 @@ function pickNextBatch(allJobs, running, cap) {
162
167
  const effectiveRunning = Math.max(running.size, queueRunningCount);
163
168
  let slots = cap - effectiveRunning;
164
169
  if (slots <= 0) {
165
- console.log(
166
- `[scheduler] concurrency: cap ${cap} reached (${effectiveRunning} running), no slots`,
167
- );
168
- return [];
170
+ const reason = `[scheduler] concurrency: cap ${cap} reached (${effectiveRunning} running), no slots`;
171
+ console.log(reason);
172
+ return { batch: [], reason };
169
173
  }
170
174
 
171
175
  // Group all jobs by project cwd.
@@ -198,15 +202,18 @@ function pickNextBatch(allJobs, running, cap) {
198
202
  // slot allocation ties across projects.
199
203
  projectCandidates.sort((a, b) => a.lowestPendingForProject - b.lowestPendingForProject);
200
204
 
201
- // Aggregate batch across projects, consuming global slots as we go.
205
+ // Aggregate batch across projects, consuming global slots as we go. Track
206
+ // the first hold reason seen so callers can explain an empty overall batch.
202
207
  const batch = [];
208
+ let heldReason = null;
203
209
  for (const { projectJobs, runningSlugsInProject } of projectCandidates) {
204
210
  if (slots <= 0) break;
205
- const projectBatch = pickForProject(projectJobs, runningSlugsInProject, slots);
206
- batch.push(...projectBatch);
207
- slots -= projectBatch.length;
211
+ const projectResult = pickForProject(projectJobs, runningSlugsInProject, slots);
212
+ batch.push(...projectResult.batch);
213
+ slots -= projectResult.batch.length;
214
+ if (heldReason === null && projectResult.reason) heldReason = projectResult.reason;
208
215
  }
209
- return batch;
216
+ return { batch, reason: batch.length === 0 ? heldReason : null };
210
217
  }
211
218
 
212
219
  module.exports = { pickForProject, pickNextBatch, DEFAULT_PROJECT_CWD };