@worca/ui 0.8.1-rc.1 → 0.9.0-rc.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.
package/app/styles.css CHANGED
@@ -24,6 +24,7 @@
24
24
  --status-failed: #ef4444;
25
25
  --status-resuming: #3b82f6;
26
26
  --status-skipped: #94a3b8;
27
+ --status-interrupted: #f59e0b;
27
28
  /* legacy */
28
29
  --status-in-progress: #3b82f6;
29
30
  --status-error: #ef4444;
@@ -730,8 +731,7 @@ h1, h2, h3, h4, h5, h6 {
730
731
  }
731
732
 
732
733
  .stage-node.status-interrupted .stage-icon {
733
- border-color: var(--status-in-progress);
734
- opacity: 0.6;
734
+ border-color: var(--status-interrupted);
735
735
  }
736
736
 
737
737
  .stage-label {
@@ -1110,8 +1110,7 @@ sl-details.log-history-panel::part(content) {
1110
1110
  }
1111
1111
 
1112
1112
  .status-interrupted {
1113
- color: var(--status-in-progress);
1114
- opacity: 0.6;
1113
+ color: var(--status-interrupted);
1115
1114
  }
1116
1115
 
1117
1116
  .status-running {
@@ -4318,3 +4317,205 @@ sl-tooltip.bead-tooltip::part(body) {
4318
4317
  width: 100%;
4319
4318
  }
4320
4319
 
4320
+ /* ── Integrations (card catalog) ─────────────────────────────────────── */
4321
+
4322
+ .ig-page {
4323
+ max-width: 720px;
4324
+ }
4325
+
4326
+ .ig-subtitle {
4327
+ margin: 0 0 1.25rem;
4328
+ font-size: 0.85rem;
4329
+ color: var(--sl-color-neutral-500);
4330
+ }
4331
+
4332
+ .ig-cards {
4333
+ display: flex;
4334
+ flex-direction: column;
4335
+ gap: 0.75rem;
4336
+ }
4337
+
4338
+ .ig-card {
4339
+ border: 1px solid var(--sl-color-neutral-200);
4340
+ border-radius: var(--sl-border-radius-medium);
4341
+ padding: 1rem 1.25rem;
4342
+ background: var(--sl-color-neutral-0);
4343
+ }
4344
+
4345
+ .ig-card--connected {
4346
+ border-left: 3px solid var(--sl-color-success-500);
4347
+ }
4348
+
4349
+ .ig-card--disabled {
4350
+ border-left: 3px solid var(--sl-color-neutral-300);
4351
+ opacity: 0.7;
4352
+ }
4353
+
4354
+ .ig-card-footer {
4355
+ display: flex;
4356
+ justify-content: space-between;
4357
+ align-items: center;
4358
+ margin-top: 0.75rem;
4359
+ padding-left: 2.75rem;
4360
+ }
4361
+
4362
+ .ig-card--disconnected {
4363
+ opacity: 0.85;
4364
+ }
4365
+
4366
+ .ig-card-header {
4367
+ display: flex;
4368
+ align-items: center;
4369
+ gap: 0.75rem;
4370
+ }
4371
+
4372
+ .ig-card-icon {
4373
+ font-size: 1.5rem;
4374
+ line-height: 1;
4375
+ }
4376
+
4377
+ .ig-card-title {
4378
+ flex: 1;
4379
+ min-width: 0;
4380
+ }
4381
+
4382
+ .ig-card-name {
4383
+ display: block;
4384
+ font-weight: 600;
4385
+ font-size: 0.95rem;
4386
+ }
4387
+
4388
+ .ig-card-desc {
4389
+ display: block;
4390
+ font-size: 0.8rem;
4391
+ color: var(--sl-color-neutral-500);
4392
+ }
4393
+
4394
+ .ig-badges {
4395
+ display: flex;
4396
+ flex-direction: column;
4397
+ align-items: flex-end;
4398
+ gap: 0.25rem;
4399
+ }
4400
+
4401
+ .ig-card-stats {
4402
+ display: flex;
4403
+ gap: 1rem;
4404
+ margin-top: 0.5rem;
4405
+ padding-left: 2.75rem;
4406
+ font-size: 0.8rem;
4407
+ color: var(--sl-color-neutral-500);
4408
+ }
4409
+
4410
+ .ig-card-stats .stat-warn {
4411
+ color: var(--sl-color-warning-600);
4412
+ }
4413
+
4414
+ .ig-card-chats {
4415
+ display: flex;
4416
+ gap: 0.5rem;
4417
+ flex-wrap: wrap;
4418
+ margin-top: 0.4rem;
4419
+ padding-left: 2.75rem;
4420
+ }
4421
+
4422
+ .ig-chat-badge {
4423
+ display: inline-flex;
4424
+ align-items: center;
4425
+ gap: 0.35rem;
4426
+ font-size: 0.8rem;
4427
+ }
4428
+
4429
+ .ig-chat-id {
4430
+ font-family: var(--sl-font-mono);
4431
+ color: var(--sl-color-neutral-600);
4432
+ font-size: 0.8rem;
4433
+ }
4434
+
4435
+ .ig-card-actions {
4436
+ display: flex;
4437
+ gap: 0.5rem;
4438
+ margin-top: 0.75rem;
4439
+ padding-left: 2.75rem;
4440
+ }
4441
+
4442
+ /* Inline form */
4443
+
4444
+ .ig-form {
4445
+ margin-top: 1rem;
4446
+ padding: 1rem;
4447
+ border-top: 1px solid var(--sl-color-neutral-200);
4448
+ }
4449
+
4450
+ .ig-env-hint {
4451
+ font-size: 0.8rem;
4452
+ color: var(--sl-color-neutral-600);
4453
+ background: var(--sl-color-neutral-50);
4454
+ border: 1px solid var(--sl-color-neutral-200);
4455
+ border-radius: var(--sl-border-radius-small);
4456
+ padding: 0.5rem 0.75rem;
4457
+ margin-bottom: 0.75rem;
4458
+ font-family: var(--sl-font-mono);
4459
+ }
4460
+
4461
+ .ig-form-row {
4462
+ display: grid;
4463
+ grid-template-columns: 1fr 1fr;
4464
+ gap: 0.75rem;
4465
+ }
4466
+
4467
+ @media (max-width: 600px) {
4468
+ .ig-form-row {
4469
+ grid-template-columns: 1fr;
4470
+ }
4471
+ }
4472
+
4473
+ .ig-form-field {
4474
+ margin-bottom: 0.75rem;
4475
+ }
4476
+
4477
+ .ig-form-field label {
4478
+ display: block;
4479
+ font-size: 0.8rem;
4480
+ font-weight: 600;
4481
+ margin-bottom: 0.2rem;
4482
+ color: var(--sl-color-neutral-700);
4483
+ }
4484
+
4485
+ .ig-form-field .form-hint {
4486
+ display: block;
4487
+ font-size: 0.7rem;
4488
+ color: var(--sl-color-neutral-500);
4489
+ margin-top: 0.15rem;
4490
+ }
4491
+
4492
+ .ig-detect-row {
4493
+ display: flex;
4494
+ gap: 0.5rem;
4495
+ align-items: center;
4496
+ }
4497
+
4498
+ .ig-detect-row sl-input {
4499
+ flex: 1;
4500
+ }
4501
+
4502
+ .ig-event-grid {
4503
+ display: grid;
4504
+ grid-template-columns: 1fr 1fr;
4505
+ gap: 0.2rem 0.5rem;
4506
+ }
4507
+
4508
+ .ig-form-actions {
4509
+ display: flex;
4510
+ gap: 0.5rem;
4511
+ margin-top: 0.75rem;
4512
+ }
4513
+
4514
+ .ig-loading {
4515
+ display: flex;
4516
+ align-items: center;
4517
+ gap: 0.5rem;
4518
+ color: var(--sl-color-neutral-500);
4519
+ padding: 2rem;
4520
+ }
4521
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.8.1-rc.1",
3
+ "version": "0.9.0-rc.1",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
package/server/app.js CHANGED
@@ -2,13 +2,15 @@
2
2
 
3
3
  import { execFileSync } from 'node:child_process';
4
4
  import { createHmac, randomUUID } from 'node:crypto';
5
- import { existsSync } from 'node:fs';
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
6
  import { homedir } from 'node:os';
7
7
  import { basename, dirname, isAbsolute, join } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import express from 'express';
10
10
 
11
11
  import { dbExists, getIssue, listIssues } from './beads-reader.js';
12
+ import { RAW_BODY } from './integrations/index.js';
13
+ import { verify } from './integrations/verify.js';
12
14
  import { ProcessManager } from './process-manager.js';
13
15
  import { scanDirectory } from './project-registry.js';
14
16
  import {
@@ -16,6 +18,7 @@ import {
16
18
  createProjectScopedRoutes,
17
19
  projectResolver,
18
20
  } from './project-routes.js';
21
+ import { validateIntegrationsConfig } from './settings-validator.js';
19
22
  import { discoverSubagents } from './subagents-discovery.js';
20
23
  import { getVersionInfo } from './versions.js';
21
24
  import { createInbox } from './webhook-inbox.js';
@@ -23,12 +26,25 @@ import { createInbox } from './webhook-inbox.js';
23
26
  export function createApp(options = {}) {
24
27
  const app = express();
25
28
  const appDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'app');
26
- const { settingsPath, worcaDir, projectRoot, prefsDir } = options;
29
+ const {
30
+ settingsPath,
31
+ worcaDir,
32
+ projectRoot,
33
+ prefsDir,
34
+ serverHost,
35
+ serverPort,
36
+ } = options;
27
37
  // subagentDirs is a test-injection seam; production calls omit it and we
28
38
  // resolve from homedir() + projectRoot.
29
39
  const subagentDirs = options.subagentDirs || null;
30
40
 
31
- app.use(express.json());
41
+ app.use(
42
+ express.json({
43
+ verify: (req, _res, buf) => {
44
+ req.rawBody = buf;
45
+ },
46
+ }),
47
+ );
32
48
 
33
49
  // ─── Security headers ──────────────────────────────────────────────────
34
50
  app.use((_req, res, next) => {
@@ -94,7 +110,7 @@ export function createApp(options = {}) {
94
110
  };
95
111
  next();
96
112
  },
97
- createProjectScopedRoutes(),
113
+ createProjectScopedRoutes({ serverHost, serverPort }),
98
114
  );
99
115
 
100
116
  // ─── Unique routes (not in project-scoped router) ──────────────────────
@@ -282,6 +298,16 @@ export function createApp(options = {}) {
282
298
 
283
299
  // POST /api/webhooks/inbox — receive webhook events
284
300
  app.post('/api/webhooks/inbox', (req, res) => {
301
+ const integrations = app.locals.integrations;
302
+ if (integrations?.strictInboxVerification) {
303
+ const ok = verify(
304
+ req.rawBody || Buffer.alloc(0),
305
+ req.headers['x-worca-signature'],
306
+ integrations.secrets || [],
307
+ );
308
+ if (!ok)
309
+ return res.status(401).json({ ok: false, error: 'invalid signature' });
310
+ }
285
311
  const headers = {
286
312
  'x-worca-event': req.headers['x-worca-event'] || '',
287
313
  'x-worca-delivery': req.headers['x-worca-delivery'] || '',
@@ -298,9 +324,11 @@ export function createApp(options = {}) {
298
324
  envelope: req.body || {},
299
325
  projectId,
300
326
  });
327
+ if (req.rawBody) stored[RAW_BODY] = req.rawBody;
301
328
  if (app.locals.broadcast) {
302
329
  app.locals.broadcast('webhook-inbox-event', stored);
303
330
  }
331
+ app.locals.integrations?.onEvent(stored);
304
332
  res.json({ control: { action: webhookInbox.getControlAction() } });
305
333
  });
306
334
 
@@ -332,6 +360,16 @@ export function createApp(options = {}) {
332
360
 
333
361
  // PUT /api/webhooks/inbox/control — set control action
334
362
  app.put('/api/webhooks/inbox/control', (req, res) => {
363
+ const integrations = app.locals.integrations;
364
+ if (integrations?.strictInboxVerification) {
365
+ const ok = verify(
366
+ req.rawBody || Buffer.alloc(0),
367
+ req.headers['x-worca-signature'],
368
+ integrations.secrets || [],
369
+ );
370
+ if (!ok)
371
+ return res.status(401).json({ ok: false, error: 'invalid signature' });
372
+ }
335
373
  const { action } = req.body || {};
336
374
  if (!['continue', 'pause', 'abort'].includes(action)) {
337
375
  return res.status(400).json({
@@ -475,14 +513,255 @@ export function createApp(options = {}) {
475
513
 
476
514
  // ─── Multi-project routes ──────────────────────────────────────────────
477
515
  if (prefsDir) {
478
- app.use('/api/projects', createProjectRoutes({ prefsDir, projectRoot }));
516
+ app.use(
517
+ '/api/projects',
518
+ createProjectRoutes({ prefsDir, projectRoot, serverHost, serverPort }),
519
+ );
479
520
  app.use(
480
521
  '/api/projects/:projectId',
481
522
  projectResolver({ prefsDir, projectRoot }),
482
- createProjectScopedRoutes({ prefsDir }),
523
+ createProjectScopedRoutes({ prefsDir, serverHost, serverPort }),
483
524
  );
484
525
  }
485
526
 
527
+ // POST /api/integrations/telegram/detect — find chat IDs from recent messages.
528
+ // If the Telegram adapter is running, temporarily pauses its poll loop so
529
+ // getUpdates returns results instead of being consumed by the long-poller.
530
+ app.post('/api/integrations/telegram/detect', async (req, res) => {
531
+ let token = req.body?.token;
532
+ if (!token) {
533
+ try {
534
+ const cfgRaw = readFileSync(
535
+ join(prefsDir, 'integrations', 'config.json'),
536
+ 'utf8',
537
+ );
538
+ token = JSON.parse(cfgRaw).telegram?.bot_token;
539
+ } catch {
540
+ /* no config */
541
+ }
542
+ }
543
+ if (!token) token = process.env.TELEGRAM_BOT_TOKEN;
544
+ if (!token) {
545
+ return res.status(400).json({ error: 'No bot token provided' });
546
+ }
547
+
548
+ // Pause the running adapter so getUpdates isn't consumed by the poll loop
549
+ const integrations = app.locals.integrations;
550
+ const adapterEntry = integrations?._getAdapter?.('telegram');
551
+ let wasStopped = false;
552
+ if (adapterEntry) {
553
+ try {
554
+ await adapterEntry.adapter.stop();
555
+ wasStopped = true;
556
+ // Brief delay to let the in-flight long-poll request complete
557
+ await new Promise((r) => setTimeout(r, 200));
558
+ } catch {
559
+ /* ignore */
560
+ }
561
+ }
562
+
563
+ try {
564
+ const meRes = await fetch(`https://api.telegram.org/bot${token}/getMe`);
565
+ const me = await meRes.json();
566
+ const botUsername = me.ok ? me.result.username : null;
567
+
568
+ const updRes = await fetch(
569
+ `https://api.telegram.org/bot${token}/getUpdates?timeout=0&limit=20`,
570
+ );
571
+ const upd = await updRes.json();
572
+
573
+ const chats = [];
574
+ if (upd.ok) {
575
+ for (const u of upd.result) {
576
+ const msg = u.message;
577
+ if (msg?.chat?.id) {
578
+ const existing = chats.find((c) => c.id === msg.chat.id);
579
+ if (!existing) {
580
+ chats.push({
581
+ id: msg.chat.id,
582
+ type: msg.chat.type,
583
+ title:
584
+ msg.chat.title || msg.chat.first_name || String(msg.chat.id),
585
+ });
586
+ }
587
+ }
588
+ }
589
+ }
590
+
591
+ res.json({ ok: true, botUsername, chats });
592
+ } catch (err) {
593
+ res.status(500).json({ error: err.message });
594
+ } finally {
595
+ // Restart the adapter if we paused it
596
+ if (wasStopped && adapterEntry) {
597
+ adapterEntry.adapter.start().catch(() => {});
598
+ }
599
+ }
600
+ });
601
+
602
+ // GET /api/integrations/status — adapter states, chat states, counters
603
+ app.get('/api/integrations/status', (_req, res) => {
604
+ const integrations = app.locals.integrations;
605
+ if (!integrations) return res.json({ enabled: false });
606
+ res.json(integrations.status());
607
+ });
608
+
609
+ // GET /api/integrations/config — return saved config (secrets redacted)
610
+ app.get('/api/integrations/config', (_req, res) => {
611
+ const configPath = join(prefsDir, 'integrations', 'config.json');
612
+ let cfg;
613
+ try {
614
+ cfg = JSON.parse(readFileSync(configPath, 'utf8'));
615
+ } catch {
616
+ return res.json({});
617
+ }
618
+ res.json(cfg);
619
+ });
620
+
621
+ // DELETE /api/integrations/config/:adapter — remove an adapter
622
+ // PATCH /api/integrations/config/:adapter/enabled — toggle adapter on/off
623
+ app.patch('/api/integrations/config/:adapter/enabled', async (req, res) => {
624
+ const { adapter } = req.params;
625
+ const { enabled } = req.body;
626
+ if (typeof enabled !== 'boolean') {
627
+ return res.status(400).json({ error: 'enabled must be a boolean' });
628
+ }
629
+ const adapterKeys = ['telegram', 'discord', 'slack'];
630
+ if (!adapterKeys.includes(adapter)) {
631
+ return res.status(400).json({ error: `Invalid adapter: ${adapter}` });
632
+ }
633
+ const configPath = join(prefsDir, 'integrations', 'config.json');
634
+ let cfg;
635
+ try {
636
+ cfg = JSON.parse(readFileSync(configPath, 'utf8'));
637
+ } catch {
638
+ return res.status(404).json({ error: 'No integrations config' });
639
+ }
640
+ if (!cfg[adapter]) {
641
+ return res
642
+ .status(404)
643
+ .json({ error: `Adapter ${adapter} not configured` });
644
+ }
645
+ cfg[adapter].enabled = enabled;
646
+ writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
647
+
648
+ // Hot-reload: if disabling, remove the adapter; if enabling, reload it
649
+ if (app.locals.ensureIntegrations) app.locals.ensureIntegrations();
650
+ if (enabled) {
651
+ if (app.locals.integrations?.reloadAdapter) {
652
+ await app.locals.integrations.reloadAdapter(adapter);
653
+ }
654
+ } else {
655
+ if (app.locals.integrations?.removeAdapter) {
656
+ await app.locals.integrations.removeAdapter(adapter);
657
+ }
658
+ }
659
+ res.json({ ok: true, enabled });
660
+ });
661
+
662
+ app.delete('/api/integrations/config/:adapter', async (req, res) => {
663
+ const { adapter } = req.params;
664
+ const adapterKeys = ['telegram', 'discord', 'slack'];
665
+ if (!adapterKeys.includes(adapter)) {
666
+ return res.status(400).json({ error: `Invalid adapter: ${adapter}` });
667
+ }
668
+ const configDir = join(prefsDir, 'integrations');
669
+ const configPath = join(configDir, 'config.json');
670
+ let cfg;
671
+ try {
672
+ cfg = JSON.parse(readFileSync(configPath, 'utf8'));
673
+ } catch {
674
+ return res.json({ ok: true });
675
+ }
676
+ delete cfg[adapter];
677
+ const hasAdapters = adapterKeys.some((k) => cfg[k]?.enabled);
678
+ if (!hasAdapters) cfg.enabled = false;
679
+ try {
680
+ writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
681
+ } catch (err) {
682
+ return res
683
+ .status(500)
684
+ .json({ error: `Failed to write config: ${err.message}` });
685
+ }
686
+ if (app.locals.integrations?.removeAdapter) {
687
+ await app.locals.integrations.removeAdapter(adapter);
688
+ }
689
+ res.json({ ok: true });
690
+ });
691
+
692
+ // POST /api/integrations/config — save adapter config
693
+ const ADAPTER_SCHEMA = {
694
+ telegram: { tokenKey: 'bot_token', idKey: 'chat_id' },
695
+ discord: { tokenKey: 'bot_token', idKey: 'channel_id' },
696
+ slack: { tokenKey: 'webhook_url', idKey: 'chat_id' },
697
+ };
698
+
699
+ app.post('/api/integrations/config', async (req, res) => {
700
+ const { adapter, token, chatId, events } = req.body;
701
+ if (
702
+ !adapter ||
703
+ !token ||
704
+ !chatId ||
705
+ !Array.isArray(events) ||
706
+ events.length === 0
707
+ ) {
708
+ return res.status(400).json({
709
+ error: 'Missing required fields: adapter, token, chatId, events',
710
+ });
711
+ }
712
+ const schema = ADAPTER_SCHEMA[adapter];
713
+ if (!schema) {
714
+ return res.status(400).json({
715
+ error: `Invalid adapter: ${adapter}. Must be one of: ${Object.keys(ADAPTER_SCHEMA).join(', ')}`,
716
+ });
717
+ }
718
+
719
+ const configDir = join(prefsDir, 'integrations');
720
+ const configPath = join(configDir, 'config.json');
721
+
722
+ // Load existing config or start fresh
723
+ let cfg = { schema_version: 1, enabled: true };
724
+ try {
725
+ const raw = readFileSync(configPath, 'utf8');
726
+ cfg = JSON.parse(raw);
727
+ } catch {
728
+ /* start fresh */
729
+ }
730
+
731
+ // Build adapter block — store token directly in config
732
+ const adapterBlock = { enabled: true, events };
733
+ adapterBlock[schema.tokenKey] = token;
734
+ adapterBlock[schema.idKey] = chatId;
735
+
736
+ cfg[adapter] = adapterBlock;
737
+ cfg.enabled = true;
738
+ if (!cfg.schema_version) cfg.schema_version = 1;
739
+
740
+ const result = validateIntegrationsConfig(cfg);
741
+ if (!result.valid) {
742
+ return res
743
+ .status(400)
744
+ .json({ error: `Validation failed: ${result.details.join('; ')}` });
745
+ }
746
+
747
+ try {
748
+ mkdirSync(configDir, { recursive: true });
749
+ writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
750
+ } catch (err) {
751
+ return res
752
+ .status(500)
753
+ .json({ error: `Failed to write config: ${err.message}` });
754
+ }
755
+
756
+ // Hot-reload just this adapter (no full restart)
757
+ if (app.locals.ensureIntegrations) app.locals.ensureIntegrations();
758
+ if (app.locals.integrations?.reloadAdapter) {
759
+ await app.locals.integrations.reloadAdapter(adapter);
760
+ }
761
+
762
+ res.json({ ok: true, path: configPath });
763
+ });
764
+
486
765
  // ─── Dynamic favicon ──────────────────────────────────────────────────
487
766
  // Serve mode-specific favicon before express.static so it takes precedence.
488
767
  app.get('/favicon.svg', (_req, res) => {
@@ -0,0 +1,66 @@
1
+ // ensure-webhook.js — auto-configure a webhook pointing to this worca-ui instance
2
+ // in a project's settings.local.json so the pipeline sends events to the UI.
3
+
4
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { localPathFor } from './settings-merge.js';
7
+
8
+ /**
9
+ * Ensure a webhook entry exists in the project's settings.local.json
10
+ * pointing to the worca-ui inbox at the given host:port.
11
+ *
12
+ * Skips if a webhook for this host:port already exists.
13
+ * Creates settings.local.json if it doesn't exist.
14
+ *
15
+ * @param {string} projectPath — absolute path to the project root
16
+ * @param {{ host: string, port: number }} server — worca-ui server address
17
+ */
18
+ export function ensureWebhookForUi(projectPath, { host, port }) {
19
+ const settingsPath = join(projectPath, '.claude', 'settings.json');
20
+ const localPath = localPathFor(settingsPath);
21
+ // Use localhost instead of 127.0.0.1 — the pipeline validator only allows
22
+ // https:// or http://localhost for security.
23
+ const displayHost =
24
+ host === '127.0.0.1' || host === '::1' ? 'localhost' : host;
25
+ const inboxUrl = `http://${displayHost}:${port}/api/webhooks/inbox`;
26
+
27
+ // Read existing local settings (or start fresh)
28
+ let local = {};
29
+ if (existsSync(localPath)) {
30
+ try {
31
+ local = JSON.parse(readFileSync(localPath, 'utf8'));
32
+ } catch {
33
+ local = {};
34
+ }
35
+ }
36
+
37
+ if (!local.worca) local.worca = {};
38
+ if (!Array.isArray(local.worca.webhooks)) local.worca.webhooks = [];
39
+
40
+ // Check if a webhook for this URL already exists
41
+ const exists = local.worca.webhooks.some((wh) => wh.url === inboxUrl);
42
+ if (exists) return false;
43
+
44
+ // Also check base settings.json (in case it was manually configured there)
45
+ try {
46
+ const base = JSON.parse(readFileSync(settingsPath, 'utf8'));
47
+ const baseWebhooks = base?.worca?.webhooks || [];
48
+ if (baseWebhooks.some((wh) => wh.url === inboxUrl)) return false;
49
+ } catch {
50
+ // no base settings — proceed
51
+ }
52
+
53
+ local.worca.webhooks.push({
54
+ url: inboxUrl,
55
+ events: ['pipeline.*'],
56
+ });
57
+
58
+ // Ensure events are enabled
59
+ if (!local.worca.events) local.worca.events = {};
60
+ if (local.worca.events.enabled === undefined) {
61
+ local.worca.events.enabled = true;
62
+ }
63
+
64
+ writeFileSync(localPath, `${JSON.stringify(local, null, 2)}\n`, 'utf8');
65
+ return true;
66
+ }
package/server/index.js CHANGED
@@ -4,6 +4,7 @@ import { createServer } from 'node:http';
4
4
  import { homedir, platform } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { createApp } from './app.js';
7
+ import { createIntegrations } from './integrations/index.js';
7
8
  import { attachWsServer } from './ws.js';
8
9
 
9
10
  // Parse argv
@@ -57,6 +58,8 @@ const app = createApp({
57
58
  projectRoot,
58
59
  webhookInbox,
59
60
  prefsDir,
61
+ serverHost: host,
62
+ serverPort: port,
60
63
  });
61
64
  const server = createServer(app);
62
65
 
@@ -104,6 +107,25 @@ app.locals.broadcast = broadcast;
104
107
  app.locals.scheduleRefresh = scheduleRefresh;
105
108
  app.locals.resolveRunProject = resolveRunProject;
106
109
 
110
+ // Boot chat integrations only in global mode — project-scoped instances skip
111
+ // integrations to avoid duplicate Telegram long-poll connections on the same bot.
112
+ if (isGlobal) {
113
+ const integrationsOpts = {
114
+ port,
115
+ host,
116
+ prefsDir,
117
+ configPath: join(prefsDir, 'integrations', 'config.json'),
118
+ };
119
+ app.locals.integrations = createIntegrations(integrationsOpts);
120
+ app.locals.ensureIntegrations = () => {
121
+ if (!app.locals.integrations?.reloadAdapter) {
122
+ app.locals.integrations = createIntegrations(integrationsOpts);
123
+ }
124
+ };
125
+ } else {
126
+ console.log('[integrations] Skipped — integrations only run in global mode');
127
+ }
128
+
107
129
  // ─── worca-cc version check (non-blocking) ─────────────────────────────
108
130
  checkWorcaVersion().then((result) => {
109
131
  app.locals.worcaVersion = result;