@luckydraw/cumulus 1.0.10 → 1.0.12

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.
@@ -41,6 +41,37 @@ import { isAutomatedSender } from './senders.js';
41
41
  import { validateTranscriptFlush, ingestTranscriptFlush } from './transcript-ingest.js';
42
42
  const threadQueues = new Map();
43
43
  const threadBusy = new Map();
44
+ // ─── Thread activity notification (task 146) ─────────────────────────────────
45
+ // The sidebar shows a thread as working, including threads the viewer has not
46
+ // opened. That needs a push, and `markThreadBusy` is the only place worth
47
+ // hanging it on: task 100 made it the point EVERY transport lands on, so one
48
+ // notification here covers WS turns, agent injects, scheduled triggers, job
49
+ // completions and queue drains without a second notion of "busy" to drift.
50
+ //
51
+ // Registered by the webchat adapter (only it can reach the sockets), same shape
52
+ // as `setUserQueueDelivery` — this module must not import the adapter.
53
+ let threadActivityListener = null;
54
+ export function setThreadActivityListener(fn) {
55
+ threadActivityListener = fn;
56
+ }
57
+ /**
58
+ * Announce a change in whether a thread is running a turn.
59
+ *
60
+ * Transition-only: callers mark busy repeatedly (an inject re-marks a thread
61
+ * that is already working), and a listener that re-broadcast every call would
62
+ * fan out a socket write per no-op. A never-throw wrapper because this is a
63
+ * cosmetic signal — it must never be able to fail a turn.
64
+ */
65
+ function notifyThreadActivity(threadName, busy) {
66
+ if (!threadActivityListener)
67
+ return;
68
+ try {
69
+ threadActivityListener(threadName, busy);
70
+ }
71
+ catch (err) {
72
+ console.error(`[Gateway] thread activity listener failed for "${threadName}": ${err instanceof Error ? err.message : String(err)}`);
73
+ }
74
+ }
44
75
  const userQueues = new Map();
45
76
  let userQueueDelivery;
46
77
  export function setUserQueueDelivery(fn) {
@@ -183,6 +214,21 @@ let federationRouter;
183
214
  * server, so later mutations (e.g. broadcastToThread wiring) are visible here.
184
215
  */
185
216
  let agentPipelineOpts;
217
+ /**
218
+ * THE only writer of `threadBusy`.
219
+ *
220
+ * Every mutation goes through here so the activity notification (task 146)
221
+ * cannot be missed: `markThreadBusy` is the drain point, but the queue-drain and
222
+ * direct-inject paths mark a thread busy by hand on their way into `sendMessage`,
223
+ * and a notification hung only on the exported function would report those turns
224
+ * as never having started.
225
+ */
226
+ function setBusyState(threadName, busy) {
227
+ const was = !!threadBusy.get(threadName);
228
+ threadBusy.set(threadName, busy);
229
+ if (was !== busy)
230
+ notifyThreadActivity(threadName, busy);
231
+ }
186
232
  /**
187
233
  * Mark a thread as busy/idle from any transport (SSE turns, webchat adapter,
188
234
  * agent injects). Busy→idle is THE drain point for queued agent messages —
@@ -191,12 +237,12 @@ let agentPipelineOpts;
191
237
  */
192
238
  export function markThreadBusy(threadName, busy) {
193
239
  if (busy) {
194
- threadBusy.set(threadName, true);
240
+ setBusyState(threadName, true);
195
241
  return;
196
242
  }
197
243
  // Only clear if still marked — another path may have set it busy again
198
244
  if (threadBusy.get(threadName)) {
199
- threadBusy.set(threadName, false);
245
+ setBusyState(threadName, false);
200
246
  }
201
247
  // User-queued messages take priority: their drain pass toggles busy and
202
248
  // lands back here when the user queue is empty.
@@ -279,7 +325,7 @@ async function drainQueue(threadName, opts) {
279
325
  const queue = threadQueues.get(threadName);
280
326
  if (!queue || queue.length === 0)
281
327
  return;
282
- threadBusy.set(threadName, true);
328
+ setBusyState(threadName, true);
283
329
  const item = queue.shift();
284
330
  try {
285
331
  await processMessage(threadName, item, opts);
@@ -365,6 +411,8 @@ async function processMessage(threadName, item, opts) {
365
411
  gatewayAgentsConfig: opts.gatewayAgentsConfig,
366
412
  hfApiKey: opts.hfApiKey,
367
413
  openaiApiKey: opts.openaiApiKey,
414
+ // Custom OpenAI-compatible endpoints + their own credentials (task 147).
415
+ customProviders: opts.customProviders,
368
416
  // Catalog: lets the spawn resolve non-Claude provider + context window (task 118).
369
417
  models: opts.models,
370
418
  // Config-driven Claude sub-model default (task 113). Read per turn, so a live
@@ -422,6 +470,7 @@ async function handleWebhook(type, name, req, res, pipelineOpts, hooksConfig) {
422
470
  broadcastToThread: pipelineOpts.broadcastToThread,
423
471
  hfApiKey: pipelineOpts.hfApiKey,
424
472
  openaiApiKey: pipelineOpts.openaiApiKey,
473
+ customProviders: pipelineOpts.customProviders,
425
474
  models: pipelineOpts.models,
426
475
  claudeModels: pipelineOpts.claudeModels,
427
476
  };
@@ -669,7 +718,7 @@ async function handleAgentInject(req, res, pipelineOpts, scope) {
669
718
  // Ensure thread exists
670
719
  await getOrCreateThread(targetName);
671
720
  // Send as a message through the pipeline (fire-and-forget)
672
- threadBusy.set(targetName, true);
721
+ setBusyState(targetName, true);
673
722
  const broadcast = pipelineOpts.broadcastToThread;
674
723
  sendMessage({
675
724
  threadName: targetName,
@@ -678,6 +727,7 @@ async function handleAgentInject(req, res, pipelineOpts, scope) {
678
727
  claudePath: pipelineOpts.claudePath,
679
728
  hfApiKey: pipelineOpts.hfApiKey,
680
729
  openaiApiKey: pipelineOpts.openaiApiKey,
730
+ customProviders: pipelineOpts.customProviders,
681
731
  models: pipelineOpts.models,
682
732
  sharedMcpPort: pipelineOpts.sharedMcpPort,
683
733
  projectRoot: pipelineOpts.projectRoot,
@@ -865,7 +915,7 @@ function drainAgentQueue(threadName, pipelineOpts) {
865
915
  formatted = formatAgentBatch(queue);
866
916
  }
867
917
  // Fire-and-forget: send the batched message as a new turn
868
- threadBusy.set(threadName, true);
918
+ setBusyState(threadName, true);
869
919
  const broadcast = pipelineOpts.broadcastToThread;
870
920
  sendMessage({
871
921
  threadName,
@@ -878,6 +928,7 @@ function drainAgentQueue(threadName, pipelineOpts) {
878
928
  gatewayAgentsConfig: pipelineOpts.gatewayAgentsConfig,
879
929
  hfApiKey: pipelineOpts.hfApiKey,
880
930
  openaiApiKey: pipelineOpts.openaiApiKey,
931
+ customProviders: pipelineOpts.customProviders,
881
932
  models: pipelineOpts.models,
882
933
  // Config-driven Claude sub-model default (task 113) — the drained turn must
883
934
  // resolve the same model the live turn would have.
@@ -1082,6 +1133,12 @@ async function handleListThreads(res, scopeNs, namespaces) {
1082
1133
  messageCount: lineCount,
1083
1134
  lastActivity: stat.mtime.toISOString(),
1084
1135
  };
1136
+ // Whether a turn is running right now (task 146). Present on BOTH listings
1137
+ // — a tab that opens mid-turn must render the activity indicator without
1138
+ // waiting for the next transition, which may be minutes away.
1139
+ if (threadBusy.get(name)) {
1140
+ entry.busy = true;
1141
+ }
1085
1142
  if (tc.projectDir) {
1086
1143
  entry.projectDir = tc.projectDir;
1087
1144
  }
@@ -2637,7 +2694,16 @@ export async function startGatewayServer(options) {
2637
2694
  if (claudeModels.length > 0 && !claudeModels.some(m => m.default)) {
2638
2695
  claudeModels[0].default = true;
2639
2696
  }
2640
- jsonResponse(res, 200, { models, claudeModels });
2697
+ // Custom providers, for the dropdown's provider label (task 147).
2698
+ // ONLY id + label: this endpoint accepts any valid key, including a
2699
+ // namespace-scoped key that ships in a public app's HTML (097 P7), and
2700
+ // baseUrl is private infrastructure. The admin-only /api/config serves
2701
+ // baseUrl + the masked credential.
2702
+ const customProviders = (pipelineOpts.customProviders || []).map(p => ({
2703
+ id: p.id,
2704
+ label: p.label,
2705
+ }));
2706
+ jsonResponse(res, 200, { models, claudeModels, customProviders });
2641
2707
  return;
2642
2708
  }
2643
2709
  // GET/PUT /api/config — gateway-level settings are ADMIN-ONLY (task 128).
@@ -2660,6 +2726,7 @@ export async function startGatewayServer(options) {
2660
2726
  model: pipelineOpts.model,
2661
2727
  hfApiKey: pipelineOpts.hfApiKey,
2662
2728
  openaiApiKey: pipelineOpts.openaiApiKey,
2729
+ customProviders: pipelineOpts.customProviders,
2663
2730
  licenseKey: storedLicenseKey,
2664
2731
  }),
2665
2732
  license: licenseView(),
@@ -2695,6 +2762,8 @@ export async function startGatewayServer(options) {
2695
2762
  pipelineOpts.hfApiKey = applied.hfApiKey;
2696
2763
  if (patch.openaiApiKey !== undefined)
2697
2764
  pipelineOpts.openaiApiKey = applied.openaiApiKey;
2765
+ if (patch.customProviders !== undefined)
2766
+ pipelineOpts.customProviders = applied.customProviders;
2698
2767
  // Task 129: licence key is persist-ONLY — track the stored value for
2699
2768
  // display, but never rewire enforcement (licenseStatus stays as
2700
2769
  // verified at startup; a reload is what applies it — task 127).
@@ -2709,6 +2778,7 @@ export async function startGatewayServer(options) {
2709
2778
  model: pipelineOpts.model,
2710
2779
  hfApiKey: pipelineOpts.hfApiKey,
2711
2780
  openaiApiKey: pipelineOpts.openaiApiKey,
2781
+ customProviders: pipelineOpts.customProviders,
2712
2782
  licenseKey: storedLicenseKey,
2713
2783
  }),
2714
2784
  license: licenseView(),