claude-code-session-manager 0.20.0 → 0.20.1

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 (32) hide show
  1. package/dist/assets/{TiptapBody-COZHDXvn.js → TiptapBody-Db7_uXrI.js} +1 -1
  2. package/dist/assets/{cssMode-BGlgF50F.js → cssMode-DFKJhhi6.js} +1 -1
  3. package/dist/assets/{freemarker2-CwlJczaA.js → freemarker2-DUat8x8o.js} +1 -1
  4. package/dist/assets/{handlebars-C7ChleGP.js → handlebars-B2C1qhAI.js} +1 -1
  5. package/dist/assets/{html-C0XyedAq.js → html-khtg0DVs.js} +1 -1
  6. package/dist/assets/{htmlMode-DTJsOfuO.js → htmlMode-Jmhs-vfl.js} +1 -1
  7. package/dist/assets/{index-6poesY86.css → index-BkkBX1z7.css} +1 -1
  8. package/dist/assets/{index-C4joLNKY.js → index-pqnuXM14.js} +588 -578
  9. package/dist/assets/{javascript-CPRB5GUm.js → javascript-i1CXbgg4.js} +1 -1
  10. package/dist/assets/{jsonMode-DKBN0s8-.js → jsonMode-DXZaj-kR.js} +1 -1
  11. package/dist/assets/{liquid-CJmNIgnK.js → liquid-Ds7jUF53.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-CIIba3v8.js → lspLanguageFeatures-B_15vO6X.js} +1 -1
  13. package/dist/assets/{mdx-BOiNk1a1.js → mdx-DgrrLgTE.js} +1 -1
  14. package/dist/assets/{python-5AV3HPYJ.js → python-Cff3tPw3.js} +1 -1
  15. package/dist/assets/{razor-6iMJA6dH.js → razor-DlyG7FmM.js} +1 -1
  16. package/dist/assets/{tsMode-WJISqg3-.js → tsMode-DRmmmttS.js} +1 -1
  17. package/dist/assets/{typescript-CnA0yZf9.js → typescript-DQFL2T1p.js} +1 -1
  18. package/dist/assets/{xml-BLkNwYO2.js → xml-CwsJEzdU.js} +1 -1
  19. package/dist/assets/{yaml-D6anZ1nO.js → yaml-BDsDjf-y.js} +1 -1
  20. package/dist/index.html +2 -2
  21. package/package.json +3 -1
  22. package/src/main/historyAggregator.cjs +15 -9
  23. package/src/main/index.cjs +7 -2
  24. package/src/main/ipcSchemas.cjs +43 -0
  25. package/src/main/kg.cjs +27 -17
  26. package/src/main/lib/reaperHelpers.cjs +67 -0
  27. package/src/main/lib/schedulerBatch.cjs +212 -0
  28. package/src/main/scheduler.cjs +173 -125
  29. package/src/main/webRemote.cjs +916 -0
  30. package/src/preload/api.d.ts +50 -9
  31. package/src/preload/index.cjs +34 -5
  32. package/src/main/projectSkills.cjs +0 -124
@@ -35,11 +35,11 @@ const { registerDocEditorHandlers } = require('./docEditor.cjs');
35
35
  const git = require('./git.cjs');
36
36
  const superagent = require('./superagent.cjs');
37
37
  const kg = require('./kg.cjs');
38
- const { registerProjectSkillsHandlers } = require('./projectSkills.cjs');
39
38
  const filesIpc = require('./files.cjs');
40
39
  const searchIpc = require('./search.cjs');
41
40
  const repoAnalyzer = require('./repoAnalyzer.cjs');
42
41
  const hivesIpc = require('./hives.cjs');
42
+ const webRemote = require('./webRemote.cjs');
43
43
  const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
44
44
  const { checkInsideHome, assertInsideHome } = require('./lib/insideHome.cjs');
45
45
  const { openInEditor, openFileInEditor, openInFinder, openInTerminal } = require('./lib/openExternalApp.cjs');
@@ -626,11 +626,11 @@ agentMemory.registerAgentMemoryHandlers();
626
626
  registerDocEditorHandlers();
627
627
  git.register(ipcMain);
628
628
  superagent.registerSuperAgentHandlers();
629
- registerProjectSkillsHandlers();
630
629
  filesIpc.registerFilesHandlers();
631
630
  searchIpc.registerSearchHandlers();
632
631
  repoAnalyzer.register(ipcMain);
633
632
  hivesIpc.registerHiveHandlers();
633
+ webRemote.registerRemoteHandlers();
634
634
 
635
635
  // OTEL telemetry export (opt-in via ~/.config/session-manager/otel.json).
636
636
  ipcMain.handle('otel:get-config', async () => otelSettings.load());
@@ -918,9 +918,13 @@ app.whenReady().then(async () => {
918
918
  pluginInstall.attachWindow(mainWindow);
919
919
  superagent.attachWindow(mainWindow);
920
920
  kg.attachWindow(mainWindow);
921
+ webRemote.attachWindow(mainWindow);
921
922
  scheduler.init().catch((e) => {
922
923
  logs.writeLine({ scope: 'scheduler', level: 'error', message: 'init failed', meta: { error: e?.message } });
923
924
  });
925
+ webRemote.init().catch((e) => {
926
+ logs.writeLine({ scope: 'webRemote', level: 'error', message: 'init failed', meta: { error: e?.message } });
927
+ });
924
928
  // Knowledge Graph: watch the prompt log + register kg:* IPC. Best-effort.
925
929
  try { kg.init({ logger: logs }); } catch (e) {
926
930
  logs.writeLine({ scope: 'kg', level: 'error', message: 'init failed', meta: { error: e?.message } });
@@ -964,6 +968,7 @@ let teardownDone = false;
964
968
  function runShutdownCleanup() {
965
969
  if (teardownDone) return; // idempotent — will-quit may still fire after an app.exit path
966
970
  teardownDone = true;
971
+ webRemote.destroy();
967
972
  // Mark a clean exit so the next boot can distinguish a graceful quit from an
968
973
  // OOM-kill / native crash (which leaves the sentinel `open`).
969
974
  crashDiagnostics.markCleanShutdown();
@@ -242,6 +242,23 @@ const agentMemoryDelete = z.object({
242
242
  entryId: z.string().regex(AGENT_MEMORY_ID_RE),
243
243
  }).strict();
244
244
 
245
+ // ──────────────────────────────────────────── Web Remote
246
+ // OTP is 8 uppercase alphanumeric chars (case-insensitive entry, normalised to upper in handler).
247
+ const WEB_REMOTE_OTP_RE = /^[A-Z0-9]{8}$/i;
248
+ const DEVICE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
249
+
250
+ const webRemotePair = z.object({
251
+ otp: z.string().regex(WEB_REMOTE_OTP_RE),
252
+ }).strict();
253
+
254
+ const webRemoteRevokeDevice = z.object({
255
+ deviceId: z.string().regex(DEVICE_ID_RE),
256
+ }).strict();
257
+
258
+ const webRemoteAuditTail = z.object({
259
+ lines: z.number().int().min(1).max(500).optional(),
260
+ }).strict();
261
+
245
262
  // ──────────────────────────────────────────── History
246
263
  const DATE_YYYY_MM_DD = /^\d{4}-\d{2}-\d{2}$/;
247
264
 
@@ -365,12 +382,38 @@ function validated(schema, handler) {
365
382
  };
366
383
  }
367
384
 
385
+ // ──────────────────────────────────────────── Web Remote command allowlist
386
+ // Single source of truth — imported by webRemote.cjs and by the unit test.
387
+ // Only these type strings will ever reach a handler; all others are silently
388
+ // dropped without leaking error details back to the relay (ADR §6.2).
389
+ const ALLOWED_COMMANDS = new Set([
390
+ 'cmd:sessions:load',
391
+ 'cmd:sessions:save',
392
+ 'cmd:pty:spawn',
393
+ 'cmd:pty:write',
394
+ 'cmd:pty:resize',
395
+ 'cmd:pty:kill',
396
+ 'cmd:schedule:state',
397
+ 'cmd:schedule:read-prd',
398
+ 'cmd:schedule:read-log',
399
+ 'cmd:schedule:write-prd',
400
+ 'cmd:schedule:reset-job',
401
+ 'cmd:schedule:run-now',
402
+ 'cmd:schedule:set-config',
403
+ 'cmd:history:aggregate',
404
+ 'cmd:app:version',
405
+ ]);
406
+
368
407
  module.exports = {
369
408
  // Centralized slug regex — used by scheduler.cjs and queueOps.cjs for
370
409
  // direct test()/match() containment checks alongside the zod parses.
371
410
  SCHEDULE_SLUG_RE,
372
411
  SCHEDULE_RUN_ID_RE,
412
+ ALLOWED_COMMANDS,
373
413
  schemas: {
414
+ webRemotePair,
415
+ webRemoteRevokeDevice,
416
+ webRemoteAuditTail,
374
417
  ptySpawn,
375
418
  ptyTabId,
376
419
  ptyWrite,
package/src/main/kg.cjs CHANGED
@@ -312,7 +312,7 @@ async function ingest() {
312
312
  const units = planUnits(buf.toString('utf8'));
313
313
  if (!units) { broadcast('kg:ingest-progress', { phase: 'done', ingesting: false, added: 0 }); return { ok: true, added: 0 }; }
314
314
 
315
- const graphs = new Map(); // encodedCwd -> graph (lazy-loaded, saved once at end)
315
+ const graphs = new Map(); // encodedCwd -> graph (lazy-loaded; persisted per batch)
316
316
  async function graphFor(cwd) {
317
317
  const enc = encodeCwd(cwd);
318
318
  if (!graphs.has(enc)) graphs.set(enc, await loadGraphFor(cwd));
@@ -320,15 +320,23 @@ async function ingest() {
320
320
  }
321
321
 
322
322
  const totalBatches = units.filter((u) => u.type === 'batch').length;
323
- let committedBytes = 0;
324
323
  let committedPrompts = 0;
325
324
  let added = 0;
326
- let lastTs = st.lastTs;
327
325
  let batchNo = 0;
328
326
  let failed = false;
327
+ const touched = new Set(); // encodedCwds whose graph changed this run
329
328
 
329
+ // Each iteration COMMITS before moving on: persist the touched graph, then
330
+ // advance the global byte-watermark past exactly this unit. Because units
331
+ // are processed in log order, the watermark stays a correct contiguous
332
+ // boundary — a crash, quit, or rate-limit mid-run loses at most the batch
333
+ // in flight, and the graph grows live as each batch lands.
330
334
  for (const u of units) {
331
- if (u.type === 'skip') { committedBytes += u.bytes; continue; }
335
+ if (u.type === 'skip') {
336
+ st.lastOffset += u.bytes;
337
+ await saveIngestState(st);
338
+ continue;
339
+ }
332
340
  batchNo++;
333
341
  broadcast('kg:ingest-progress', { phase: 'extract', ingesting: true, batch: batchNo, totalBatches });
334
342
 
@@ -342,28 +350,30 @@ async function ingest() {
342
350
  const parsed = extractJson(r.out);
343
351
  if (!parsed) { logger.writeLine({ scope: 'kg', level: 'warn', message: 'extraction unparseable; stopping (resumable)', meta: { cwd: u.cwd } }); failed = true; break; }
344
352
 
345
- const batchTs = u.entries[u.entries.length - 1].ts || lastTs || new Date().toISOString();
353
+ const batchTs = u.entries[u.entries.length - 1].ts || st.lastTs || new Date().toISOString();
346
354
  for (const ent of (parsed.entities || [])) { if (upsertNode(byKey, g, ent, batchTs)) added++; }
347
355
  for (const rel of (parsed.relations || [])) { upsertEdge(byEdge, g, canonicalize(rel.src), canonicalize(rel.dst), rel.relation, batchTs); }
348
356
  g.promptCount += u.entries.length;
349
357
  g.updatedAt = new Date().toISOString();
350
358
 
351
- committedBytes += u.bytes;
359
+ // Commit this batch: graph first (so a crash can't advance the watermark
360
+ // past unsaved work), then the watermark.
361
+ await saveGraph(g);
362
+ st.lastOffset += u.bytes;
363
+ st.promptCount += u.entries.length;
364
+ st.lastTs = batchTs;
365
+ st.updatedAt = new Date().toISOString();
366
+ await saveIngestState(st);
367
+
352
368
  committedPrompts += u.entries.length;
353
- lastTs = batchTs;
369
+ touched.add(encodeCwd(u.cwd));
370
+ // Tell the renderer this batch landed so it can refresh the graph live.
371
+ broadcast('kg:ingest-progress', { phase: 'batch', ingesting: true, batch: batchNo, totalBatches, cwd: u.cwd, added });
354
372
  }
355
373
 
356
- // Persist every touched graph, then advance the watermark past committed bytes only.
357
- for (const g of graphs.values()) await saveGraph(g);
358
- st.lastOffset += committedBytes;
359
- st.promptCount += committedPrompts;
360
- st.lastTs = lastTs;
361
- st.updatedAt = new Date().toISOString();
362
- await saveIngestState(st);
363
-
364
- logger.writeLine({ scope: 'kg', level: 'info', message: 'ingest complete', meta: { committedPrompts, projects: graphs.size, stopped: failed } });
374
+ logger.writeLine({ scope: 'kg', level: 'info', message: 'ingest complete', meta: { committedPrompts, projects: touched.size, stopped: failed } });
365
375
  broadcast('kg:ingest-progress', { phase: 'done', ingesting: false, added: committedPrompts });
366
- return { ok: true, added: committedPrompts, projects: graphs.size, stopped: failed };
376
+ return { ok: true, added: committedPrompts, projects: touched.size, stopped: failed };
367
377
  } catch (e) {
368
378
  logger.writeLine({ scope: 'kg', level: 'error', message: 'ingest error', meta: { error: e?.message } });
369
379
  broadcast('kg:ingest-progress', { phase: 'error', ingesting: false, error: e?.message });
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * reaperHelpers.cjs — pure helpers for the dead-process reaper in scheduler.cjs.
5
+ *
6
+ * Kept in a separate lib file so they can be unit-tested without importing
7
+ * scheduler.cjs (which requires electron/ipcMain).
8
+ */
9
+
10
+ const fs = require('node:fs');
11
+ const { readTail } = require('./fileTail.cjs');
12
+
13
+ /**
14
+ * Return true if pid is alive AND its cmdline looks like a claude process.
15
+ *
16
+ * Guards against PID recycling: on Linux we read /proc/<pid>/cmdline and
17
+ * require /\bclaude\b/ in the command. On macOS (no /proc) we can't read
18
+ * cmdline, so we conservatively return true — never false-reap a live PID
19
+ * just because we can't verify its identity.
20
+ *
21
+ * Conservative by design: a false negative (live process treated as dead) is
22
+ * far worse than a late reap.
23
+ */
24
+ function claudePidAlive(pid) {
25
+ if (!pid || typeof pid !== 'number' || pid <= 1) return false;
26
+ try { process.kill(pid, 0); } catch { return false; }
27
+ try {
28
+ const cmd = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
29
+ return /\bclaude\b/.test(cmd);
30
+ } catch {
31
+ // Can't read cmdline (macOS, permission denied) → assume alive.
32
+ return true;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Classify the terminal outcome of a completed run by reading the last 64 KB
38
+ * of its log file and scanning for the LAST `{"type":"result"}` JSONL event.
39
+ *
40
+ * Returns:
41
+ * 'success' — last result event has subtype=success and is_error !== true
42
+ * 'failed' — last result event exists but indicates an error
43
+ * 'no_result' — no result event found in the tail (process may have been killed
44
+ * before emitting one, or the log is absent/empty)
45
+ * 'unknown' — unexpected error reading/parsing (outer catch)
46
+ */
47
+ function classifyRunOutcome(logPath) {
48
+ try {
49
+ const text = readTail(logPath, 65536);
50
+ let lastResult = null;
51
+ for (const line of text.split('\n')) {
52
+ const t = line.trim();
53
+ if (!t.startsWith('{')) continue;
54
+ try {
55
+ const obj = JSON.parse(t);
56
+ if (obj && obj.type === 'result') lastResult = obj;
57
+ } catch { /* partial line at tail boundary or non-JSON scheduler log line */ }
58
+ }
59
+ if (!lastResult) return 'no_result';
60
+ if (lastResult.subtype === 'success' && lastResult.is_error !== true) return 'success';
61
+ return 'failed';
62
+ } catch {
63
+ return 'unknown';
64
+ }
65
+ }
66
+
67
+ module.exports = { claudePidAlive, classifyRunOutcome };
@@ -0,0 +1,212 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * schedulerBatch.cjs — pure batch-picking logic for the scheduler.
5
+ *
6
+ * Extracted from scheduler.cjs so the functions can be unit-tested without
7
+ * loading the full scheduler (which requires electron + heavy I/O).
8
+ *
9
+ * Group-ordering gates (failure-gate, running-gate) are evaluated
10
+ * PER PROJECT (keyed by cwd). Jobs in different projects do not serialize
11
+ * each other. Within a single project, the sequential-group semantics are
12
+ * fully preserved.
13
+ */
14
+
15
+ const path = require('node:path');
16
+ const os = require('node:os');
17
+
18
+ const DEFAULT_PROJECT_CWD = path.join(os.homedir(), 'Projects', 'session-manager');
19
+
20
+ /**
21
+ * Per-project batch picker. Applies group-ordering rules scoped to a single
22
+ * project (all jobs sharing one cwd).
23
+ *
24
+ * Rules (same as original global pickNextBatch, but scoped):
25
+ * 1. Find the lowest parallelGroup with pending jobs not already running.
26
+ * 2. Failure gate: if an earlier group has failed jobs, hold this project.
27
+ * 3. If that group has jobs in flight (backfill), fire more from SAME group.
28
+ * 4. If a lower-numbered group arrives late (late-arrival), fire it now.
29
+ * 5. If no group is in flight, start the lowest pending group fresh.
30
+ *
31
+ * @param {object[]} projectJobs - All jobs for this project (all statuses).
32
+ * @param {Set<string>} runningSlugsInProject - Slugs from the global
33
+ * runningSet that belong to this project.
34
+ * @param {number} slots - Maximum jobs to return (global remaining slots;
35
+ * caller enforces the global cap across projects).
36
+ * @returns {object[]} Jobs to spawn for this project this tick.
37
+ */
38
+ function pickForProject(projectJobs, runningSlugsInProject, slots) {
39
+ const pending = projectJobs.filter(
40
+ (j) => j.status === 'pending' && !runningSlugsInProject.has(j.slug),
41
+ );
42
+ if (pending.length === 0) return [];
43
+
44
+ const projectCwd = (projectJobs.find((j) => j.cwd) || {}).cwd || DEFAULT_PROJECT_CWD;
45
+
46
+ // Lowest pending group (computed up-front for the failure-gate check).
47
+ const lowestPendingGroup = pending.reduce(
48
+ (min, j) => Math.min(min, j.parallelGroup ?? 99),
49
+ Infinity,
50
+ );
51
+
52
+ // Cross-group failure gate: refuse to advance past a group with failed jobs.
53
+ // A failed foundation PRD should not allow later groups to run and
54
+ // silently corrupt project state. needs_review is NOT a blocker.
55
+ const blockingFailures = projectJobs.filter(
56
+ (j) => j.status === 'failed' && (j.parallelGroup ?? 99) < lowestPendingGroup,
57
+ );
58
+ if (blockingFailures.length > 0) {
59
+ const slugs = blockingFailures.map((j) => j.slug).join(', ');
60
+ console.log(
61
+ `[scheduler] failure-gate [${projectCwd}]: holding g${lowestPendingGroup} — ` +
62
+ `${blockingFailures.length} failed job(s) in earlier groups [${slugs}]. ` +
63
+ `Reset to pending or archive to unblock.`,
64
+ );
65
+ return [];
66
+ }
67
+
68
+ // Groups with at least one job in flight: either tracked in runningSlugsInProject
69
+ // (this process spawned it) or still marked 'running' in queue.json
70
+ // (persisted from a previous session that hasn't been orphan-reset yet).
71
+ const jobBySlug = new Map(projectJobs.map((j) => [j.slug, j]));
72
+ const activeGroups = new Set();
73
+ for (const slug of runningSlugsInProject) {
74
+ const job = jobBySlug.get(slug);
75
+ if (job) activeGroups.add(job.parallelGroup ?? 99);
76
+ }
77
+ for (const j of projectJobs) {
78
+ if (j.status === 'running' && !runningSlugsInProject.has(j.slug)) {
79
+ activeGroups.add(j.parallelGroup ?? 99);
80
+ }
81
+ }
82
+
83
+ if (activeGroups.size > 0) {
84
+ const lowestActive = Math.min(...activeGroups);
85
+ if (lowestPendingGroup > lowestActive) {
86
+ // 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 [];
91
+ }
92
+ if (lowestPendingGroup < lowestActive) {
93
+ // Late-arrival: a lower-numbered (higher-priority) PRD reconciled AFTER
94
+ // a higher-numbered group was already picked. Fire it now in parallel
95
+ // with the active group rather than starving it until drain.
96
+ if (slots <= 0) {
97
+ console.log(
98
+ `[scheduler] concurrency [${projectCwd}]: no slots for late-arrival g${lowestPendingGroup}`,
99
+ );
100
+ return [];
101
+ }
102
+ const batch = pending
103
+ .filter((j) => (j.parallelGroup ?? 99) === lowestPendingGroup)
104
+ .slice(0, slots);
105
+ console.log(
106
+ `[scheduler] concurrency [${projectCwd}]: firing late-arrival g${lowestPendingGroup} ` +
107
+ `(${batch.length} job(s)) alongside active g${lowestActive}`,
108
+ );
109
+ return batch;
110
+ }
111
+ // Backfill slots remaining in the current group.
112
+ if (slots <= 0) {
113
+ console.log(`[scheduler] concurrency [${projectCwd}]: cap reached, no slots`);
114
+ return [];
115
+ }
116
+ const batch = pending
117
+ .filter((j) => (j.parallelGroup ?? 99) === lowestActive)
118
+ .slice(0, slots);
119
+ if (batch.length > 0) {
120
+ console.log(
121
+ `[scheduler] concurrency [${projectCwd}]: backfilling ${batch.length} into g${lowestActive}`,
122
+ );
123
+ }
124
+ return batch;
125
+ }
126
+
127
+ // No active group — start the next group fresh.
128
+ if (slots <= 0) {
129
+ console.log(`[scheduler] concurrency [${projectCwd}]: cap reached, no slots`);
130
+ return [];
131
+ }
132
+ const batch = pending
133
+ .filter((j) => (j.parallelGroup ?? 99) === lowestPendingGroup)
134
+ .slice(0, slots);
135
+ console.log(
136
+ `[scheduler] concurrency [${projectCwd}]: starting g${lowestPendingGroup} with ${batch.length} job(s)`,
137
+ );
138
+ return batch;
139
+ }
140
+
141
+ /**
142
+ * Pick the next batch of jobs to spawn this tick.
143
+ *
144
+ * Group-ordering gates are evaluated PER PROJECT (keyed by cwd), so jobs in
145
+ * different projects are not serialized by each other's groups. Within a
146
+ * single project, the existing sequential-group semantics are fully preserved.
147
+ *
148
+ * O(N) where N = allJobs.length.
149
+ *
150
+ * @param {object[]} allJobs - Full queue.json job list.
151
+ * @param {Set<string>} running - In-process running slugs (runningSet).
152
+ * @param {number} cap - concurrencyCap.
153
+ * @returns {object[]} Jobs to spawn this tick.
154
+ */
155
+ function pickNextBatch(allJobs, running, cap) {
156
+ if (!allJobs.some((j) => j.status === 'pending' && !running.has(j.slug))) return [];
157
+
158
+ // Global slot accounting: take the higher of in-process running count and
159
+ // queue.json running count (handles orphaned running entries from a previous
160
+ // session not yet reaped).
161
+ const queueRunningCount = allJobs.filter((j) => j.status === 'running').length;
162
+ const effectiveRunning = Math.max(running.size, queueRunningCount);
163
+ let slots = cap - effectiveRunning;
164
+ if (slots <= 0) {
165
+ console.log(
166
+ `[scheduler] concurrency: cap ${cap} reached (${effectiveRunning} running), no slots`,
167
+ );
168
+ return [];
169
+ }
170
+
171
+ // Group all jobs by project cwd.
172
+ const projectMap = new Map();
173
+ for (const job of allJobs) {
174
+ const key = job.cwd || DEFAULT_PROJECT_CWD;
175
+ if (!projectMap.has(key)) projectMap.set(key, []);
176
+ projectMap.get(key).push(job);
177
+ }
178
+
179
+ // Build per-project candidate list (only projects that have pending jobs).
180
+ const projectCandidates = [];
181
+ for (const [, projectJobs] of projectMap) {
182
+ const hasPending = projectJobs.some(
183
+ (j) => j.status === 'pending' && !running.has(j.slug),
184
+ );
185
+ if (!hasPending) continue;
186
+
187
+ const runningSlugsInProject = new Set(
188
+ projectJobs.filter((j) => running.has(j.slug)).map((j) => j.slug),
189
+ );
190
+ const lowestPendingForProject = projectJobs
191
+ .filter((j) => j.status === 'pending' && !running.has(j.slug))
192
+ .reduce((min, j) => Math.min(min, j.parallelGroup ?? 99), Infinity);
193
+
194
+ projectCandidates.push({ projectJobs, runningSlugsInProject, lowestPendingForProject });
195
+ }
196
+
197
+ // Sort by lowest pending group so earlier (higher-priority) groups win
198
+ // slot allocation ties across projects.
199
+ projectCandidates.sort((a, b) => a.lowestPendingForProject - b.lowestPendingForProject);
200
+
201
+ // Aggregate batch across projects, consuming global slots as we go.
202
+ const batch = [];
203
+ for (const { projectJobs, runningSlugsInProject } of projectCandidates) {
204
+ if (slots <= 0) break;
205
+ const projectBatch = pickForProject(projectJobs, runningSlugsInProject, slots);
206
+ batch.push(...projectBatch);
207
+ slots -= projectBatch.length;
208
+ }
209
+ return batch;
210
+ }
211
+
212
+ module.exports = { pickForProject, pickNextBatch, DEFAULT_PROJECT_CWD };