fluxy-bot 0.5.61 → 0.5.63

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/README.md CHANGED
@@ -9,10 +9,12 @@ A self-hosted AI agent that runs on the user's machine with a full-stack workspa
9
9
  Fluxy is three separate codebases working together:
10
10
 
11
11
  1. **Fluxy Bot** (this repo) -- runs on the user's machine. Supervisor process, worker API, workspace app, chat UI.
12
- 2. **Fluxy Relay** (separate server at `api.fluxy.bot`) -- cloud service that maps `username.fluxy.bot` to the user's Cloudflare tunnel. Routes HTTP and WebSocket traffic.
13
- 3. **Cloudflare Quick Tunnel** -- ephemeral tunnel created by `cloudflared` binary, exposes `localhost:3000` to the internet via a random `*.trycloudflare.com` URL.
12
+ 2. **Fluxy Relay** (separate server at `api.fluxy.bot`) -- optional cloud service that maps `username.fluxy.bot` to the user's Cloudflare tunnel. Routes HTTP and WebSocket traffic. Only used with Quick Tunnels.
13
+ 3. **Cloudflare Tunnel** -- exposes `localhost:3000` to the internet. Two modes:
14
+ - **Quick Tunnel** -- zero-config, no account needed, random `*.trycloudflare.com` URL that changes on restart. Optionally paired with the relay for a permanent domain.
15
+ - **Named Tunnel** -- persistent URL with the user's own domain. Requires a Cloudflare account and DNS setup. No relay needed.
14
16
 
15
- The relay gives users a permanent domain. The tunnel gives the relay a target. The supervisor ties everything together locally.
17
+ The user chooses their tunnel mode during `fluxy init` via an interactive selector.
16
18
 
17
19
  ---
18
20
 
@@ -30,7 +32,7 @@ Supervisor (supervisor/index.ts) port 3000 HTTP server + WebSocket + r
30
32
  +-- Worker (worker/index.ts) port 3001 Express API, SQLite, auth, conversations
31
33
  +-- Vite Dev Server port 3002 Serves workspace/client with HMR
32
34
  +-- Backend (workspace/backend/) port 3004 User's custom Express server
33
- +-- cloudflared (tunnel) -- Exposes port 3000 to the internet
35
+ +-- cloudflared (tunnel) -- Exposes port 3000 to the internet (quick or named)
34
36
  +-- Scheduler (supervisor/scheduler) -- PULSE + CRON job runner (in-process)
35
37
  ```
36
38
 
@@ -58,8 +60,8 @@ WebSocket upgrades:
58
60
  - Everything else -- proxied to Vite dev server for HMR.
59
61
 
60
62
  The supervisor also:
61
- - Manages the Cloudflare tunnel lifecycle (start, stop, health watchdog every 30s)
62
- - Registers with the relay and maintains heartbeats
63
+ - Manages the Cloudflare tunnel lifecycle (start, stop, health watchdog every 30s) for both quick and named tunnels
64
+ - Registers with the relay and maintains heartbeats (quick tunnel mode only)
63
65
  - Runs the Claude Agent SDK when users send chat messages
64
66
  - Restarts the backend process when Claude edits workspace files
65
67
  - Broadcasts `app:hmr-update` to all connected dashboard clients after file changes
@@ -299,35 +301,68 @@ For non-Anthropic providers (OpenAI, Ollama), the supervisor falls back to `ai.c
299
301
 
300
302
  The user's machine is behind NAT. We need a public URL so they can access their bot from their phone.
301
303
 
302
- ### Solution: Three Tiers
304
+ ### Tunnel Modes
305
+
306
+ The user selects a tunnel mode during `fluxy init`. The mode is stored in `~/.fluxy/config.json` as `tunnel.mode`.
307
+
308
+ #### Quick Tunnel (default)
309
+
310
+ Zero configuration. No Cloudflare account needed.
303
311
 
304
312
  ```
305
313
  Phone browser
306
314
  |
307
- | https://bruno.fluxy.bot
315
+ | https://bruno.fluxy.bot (via relay, optional)
308
316
  v
309
- Fluxy Relay (api.fluxy.bot) Cloud server, maps username -> tunnel URL
317
+ Fluxy Relay (api.fluxy.bot) Cloud server, maps username -> tunnel URL
310
318
  |
311
319
  | https://random-abc.trycloudflare.com
312
320
  v
313
- Cloudflare Quick Tunnel Ephemeral tunnel, changes on restart
321
+ Cloudflare Quick Tunnel Ephemeral tunnel, changes on restart
314
322
  |
315
323
  | http://localhost:3000
316
324
  v
317
- Supervisor User's machine
325
+ Supervisor User's machine
318
326
  ```
319
327
 
320
- ### Cloudflare Tunnel (supervisor/tunnel.ts)
321
-
322
- - Auto-downloads `cloudflared` binary to `~/.fluxy/bin/` on first run (validates minimum 10MB file size)
323
328
  - Spawns: `cloudflared tunnel --url http://localhost:3000 --no-autoupdate`
324
329
  - Extracts tunnel URL from stdout (regex match for `*.trycloudflare.com`)
330
+ - URL changes on every restart -- the relay provides the stable domain layer on top
331
+ - Optionally register with Fluxy Relay for a permanent `my.fluxy.bot/username` or `fluxy.bot/username` URL
332
+
333
+ #### Named Tunnel
334
+
335
+ Persistent URL with the user's own domain. Requires a Cloudflare account + domain.
336
+
337
+ ```
338
+ Phone browser
339
+ |
340
+ | https://bot.mydomain.com
341
+ v
342
+ Cloudflare Named Tunnel Persistent tunnel, URL never changes
343
+ |
344
+ | http://localhost:3000
345
+ v
346
+ Supervisor User's machine
347
+ ```
348
+
349
+ - Setup via `fluxy tunnel setup` (interactive: login, create tunnel, generate config, print CNAME instructions)
350
+ - Spawns: `cloudflared tunnel --config <configPath> run <name>`
351
+ - URL is the user's domain (from config), no stdout parsing needed
352
+ - No relay needed -- the user's domain is already permanent
353
+ - Requires a DNS CNAME record pointing to `<uuid>.cfargotunnel.com`
354
+
355
+ ### Cloudflare Tunnel Binary (supervisor/tunnel.ts)
356
+
357
+ - Auto-downloads `cloudflared` binary to `~/.fluxy/bin/` on first run (validates minimum 10MB file size)
325
358
  - Health check: HEAD request to tunnel URL with 5s timeout
326
359
  - Watchdog runs every 30s, detects sleep/wake gaps (>60s between ticks), auto-restarts dead tunnels
360
+ - Quick tunnel restart: re-extracts new URL from stdout, updates relay if configured
361
+ - Named tunnel restart: restarts the process, URL doesn't change
327
362
 
328
- ### Relay Server (separate codebase)
363
+ ### Relay Server (separate codebase, Quick Tunnel only)
329
364
 
330
- Node.js/Express + http-proxy + MongoDB. Hosted on Railway.
365
+ Node.js/Express + http-proxy + MongoDB. Hosted on Railway. Only used when the user opts into Quick Tunnel mode and registers a handle.
331
366
 
332
367
  **Registration flow:**
333
368
  1. User picks a username during onboarding
@@ -392,13 +427,30 @@ The CLI is the user-facing entry point. Commands:
392
427
 
393
428
  | Command | Description |
394
429
  |---|---|
395
- | `fluxy init` | First-time setup: creates config, installs cloudflared, boots server, optionally installs systemd daemon |
430
+ | `fluxy init` | First-time setup: interactive tunnel mode chooser (Quick or Named), creates config, installs cloudflared, boots server, optionally installs systemd daemon |
396
431
  | `fluxy start` | Boot the supervisor (or detect existing daemon and show status) |
397
- | `fluxy status` | Health check via `/api/health`, shows uptime and relay URL |
432
+ | `fluxy status` | Health check via `/api/health`, shows uptime, tunnel URL, and relay URL |
398
433
  | `fluxy update` | Downloads latest from npm registry, updates code directories, rebuilds UI, restarts daemon |
434
+ | `fluxy tunnel` | Named tunnel management (subcommands below) |
399
435
  | `fluxy daemon` | Linux systemd management: install, start, stop, restart, status, logs, uninstall |
400
436
 
401
- The CLI spawns the supervisor via `node --import tsx/esm supervisor/index.ts` and waits for readiness markers on stdout (`__TUNNEL_URL__`, `__RELAY_URL__`, `__VITE_WARM__`, `__READY__`) with a 45-second timeout.
437
+ **`fluxy tunnel` subcommands:**
438
+ | Subcommand | Description |
439
+ |---|---|
440
+ | `fluxy tunnel setup` | Interactive named tunnel setup: login to Cloudflare, create tunnel, enter domain, generate config YAML, print CNAME instructions |
441
+ | `fluxy tunnel status` | Show current tunnel mode and configuration |
442
+ | `fluxy tunnel reset` | Switch back to quick tunnel mode |
443
+
444
+ **`fluxy init` tunnel chooser:**
445
+
446
+ During init, the user is presented with an interactive arrow-key menu to choose their tunnel mode:
447
+
448
+ - **Quick Tunnel** (Easy and Fast) -- Random CloudFlare tunnel URL on every start/update. Optionally use Fluxy Relay for a permanent `my.fluxy.bot/username` handle (free) or a premium `fluxy.bot/username` handle ($5 one-time).
449
+ - **Named Tunnel** (Advanced) -- Persistent URL with your own domain. Requires a Cloudflare account + domain. Use a subdomain like `bot.yourdomain.com` or the root domain.
450
+
451
+ If Named Tunnel is selected, `fluxy init` immediately runs the named tunnel setup flow inline (same as `fluxy tunnel setup`).
452
+
453
+ The CLI spawns the supervisor via `node --import tsx/esm supervisor/index.ts` and waits for readiness markers on stdout (`__TUNNEL_URL__`, `__RELAY_URL__`, `__VITE_WARM__`, `__READY__`, `__TUNNEL_FAILED__`) with a 45-second timeout.
402
454
 
403
455
  On Linux, `fluxy daemon` generates a systemd unit file that runs the supervisor as a service with auto-restart on failure.
404
456
 
@@ -428,7 +480,8 @@ Windows: `scripts/install.ps1` (PowerShell equivalent).
428
480
 
429
481
  | Path | Contents |
430
482
  |---|---|
431
- | `~/.fluxy/config.json` | Port, username, AI provider, relay token, tunnel URL |
483
+ | `~/.fluxy/config.json` | Port, username, AI provider, tunnel mode/config, relay token, tunnel URL |
484
+ | `~/.fluxy/cloudflared-config.yml` | Named tunnel config (generated by `fluxy tunnel setup`) |
432
485
  | `~/.fluxy/memory.db` | SQLite -- conversations, messages, settings, sessions, push subscriptions |
433
486
  | `~/.fluxy/bin/cloudflared` | Cloudflare tunnel binary |
434
487
  | `~/.fluxy/workspace/` | User's workspace copy (client, backend, memory files, skills, config) |
@@ -448,7 +501,7 @@ supervisor/
448
501
  index.ts HTTP server, request routing, WebSocket handler, process orchestration
449
502
  worker.ts Worker process spawn/stop/restart
450
503
  backend.ts Backend process spawn/stop/restart
451
- tunnel.ts Cloudflare tunnel lifecycle, health watchdog
504
+ tunnel.ts Cloudflare tunnel lifecycle (quick + named), health watchdog
452
505
  vite-dev.ts Vite dev server startup for dashboard HMR
453
506
  fluxy-agent.ts Claude Agent SDK wrapper, session management, memory injection
454
507
  scheduler.ts PULSE + CRON scheduler, 60s tick, push notification dispatch
@@ -531,8 +584,8 @@ The relay couldn't reliably forward POST bodies (now fixed). WebSocket was the w
531
584
  **Why bypassPermissions on the agent?**
532
585
  The whole point is that the user talks to Claude from their phone and Claude does whatever's needed. Confirmation prompts would require a terminal session that doesn't exist. The workspace directory boundary + the system prompt are the safety rails.
533
586
 
534
- **Why Cloudflare Quick Tunnel instead of a persistent tunnel?**
535
- Zero configuration. No Cloudflare account needed. The tradeoff is the URL changes on restart, which is why the relay exists -- it provides the stable domain layer on top.
587
+ **Why two tunnel modes?**
588
+ Quick Tunnel is the default for simplicity -- zero configuration, no Cloudflare account needed. The tradeoff is the URL changes on restart, which is why the relay exists as an optional stable domain layer. Named Tunnel is for advanced users who want full control -- their own domain, no dependency on the relay, permanent URLs. Both modes are offered during `fluxy init`.
536
589
 
537
590
  **Why two Vite configs?**
538
591
  `vite.config.ts` builds the workspace dashboard (user-facing app). `vite.fluxy.config.ts` builds the Fluxy chat SPA. They're separate apps with separate entry points, bundled independently. The chat is pre-built at publish time; the dashboard runs as a dev server with HMR.
package/bin/cli.js CHANGED
@@ -140,11 +140,12 @@ function chooseTunnelMode() {
140
140
  {
141
141
  label: 'Quick Tunnel',
142
142
  mode: 'quick',
143
- tag: 'FREE',
143
+ tag: 'Easy and Fast',
144
144
  tagColor: c.green,
145
145
  desc: [
146
- 'Random CloudFlare tunnel URL on every start',
147
- `Optional: ${c.reset}${c.pink}fluxy.bot/YOURBOT${c.reset}${c.dim} custom handle via Fluxy relay`,
146
+ 'Random CloudFlare tunnel URL on every start/update',
147
+ `Optional: Use Fluxy Relay Server and access your bot at ${c.reset}${c.pink}my.fluxy.bot/YOURBOT${c.reset}${c.dim} (Free)`,
148
+ `Or use a premium handle like ${c.reset}${c.pink}fluxy.bot/YOURBOT${c.reset}${c.dim} ($5 one-time fee)`,
148
149
  ],
149
150
  },
150
151
  {
@@ -155,6 +156,7 @@ function chooseTunnelMode() {
155
156
  desc: [
156
157
  'Persistent URL with your own domain',
157
158
  'Requires a CloudFlare account + domain',
159
+ `Use a subdomain like ${c.reset}${c.white}bot.YOURDOMAIN.COM${c.reset}${c.dim} or the root domain`,
158
160
  ],
159
161
  },
160
162
  ];
@@ -403,6 +405,28 @@ function banner() {
403
405
  ${c.dim}v${pkg.version} · Self-hosted AI agent${c.reset}`);
404
406
  }
405
407
 
408
+ function tunnelFailedMessage(localUrl) {
409
+ console.log(`
410
+ ${c.dim}─────────────────────────────────${c.reset}
411
+
412
+ ${c.yellow}⚠${c.reset} ${c.bold}${c.white}Tunnel failed to connect${c.reset}
413
+
414
+ ${c.dim}CloudFlare quick tunnels are rate-limited.${c.reset}
415
+ ${c.dim}This usually resolves itself after a few minutes.${c.reset}
416
+
417
+ ${c.bold}${c.white}Your dashboard is available locally:${c.reset}
418
+
419
+ ${c.blue}${c.bold}${link(localUrl)}${c.reset}
420
+ ${c.dim}(cmd+click or ctrl+click to open)${c.reset}
421
+
422
+ ${c.bold}${c.white}To retry the tunnel:${c.reset}
423
+ ${c.pink}fluxy start${c.reset}
424
+
425
+ ${c.bold}${c.white}For a persistent tunnel, use a named tunnel:${c.reset}
426
+ ${c.pink}fluxy tunnel setup${c.reset}
427
+ `);
428
+ }
429
+
406
430
  function finalMessage(tunnelUrl, relayUrl) {
407
431
  console.log(`
408
432
  ${c.dim}─────────────────────────────────${c.reset}
@@ -539,6 +563,7 @@ function bootServer({ onTunnelUp, onReady } = {}) {
539
563
  let resolved = false;
540
564
  let stderrBuf = '';
541
565
  let tunnelFired = false;
566
+ let tunnelFailed = false;
542
567
 
543
568
  // Vite warmup tracking
544
569
  let viteWarmResolve;
@@ -554,6 +579,7 @@ function bootServer({ onTunnelUp, onReady } = {}) {
554
579
  child,
555
580
  tunnelUrl: tunnelUrl || `http://localhost:${config.port}`,
556
581
  relayUrl: relayUrl || config.relay?.url || null,
582
+ tunnelFailed,
557
583
  viteWarm,
558
584
  });
559
585
  };
@@ -580,6 +606,7 @@ function bootServer({ onTunnelUp, onReady } = {}) {
580
606
  }
581
607
 
582
608
  if (text.includes('__TUNNEL_FAILED__')) {
609
+ tunnelFailed = true;
583
610
  doResolve();
584
611
  }
585
612
  };
@@ -687,7 +714,7 @@ async function init() {
687
714
  console.error(` ${c.dim}${err.message}${c.reset}\n`);
688
715
  process.exit(1);
689
716
  }
690
- const { child, tunnelUrl, relayUrl, viteWarm } = result;
717
+ const { child, tunnelUrl, relayUrl, tunnelFailed, viteWarm } = result;
691
718
 
692
719
  // Wait for Vite to finish pre-transforming all modules (with timeout)
693
720
  await Promise.race([viteWarm, new Promise(r => setTimeout(r, 30_000))]);
@@ -704,7 +731,11 @@ async function init() {
704
731
  });
705
732
  stepper.advance();
706
733
  stepper.finish();
707
- finalMessage(tunnelUrl, relayUrl);
734
+ if (tunnelFailed) {
735
+ tunnelFailedMessage(tunnelUrl);
736
+ } else {
737
+ finalMessage(tunnelUrl, relayUrl);
738
+ }
708
739
  if (res.status === 0) {
709
740
  console.log(` ${c.blue}✔${c.reset} Daemon installed — Fluxy will auto-start on boot.`);
710
741
  } else {
@@ -715,7 +746,11 @@ async function init() {
715
746
  }
716
747
 
717
748
  stepper.finish();
718
- finalMessage(tunnelUrl, relayUrl);
749
+ if (tunnelFailed) {
750
+ tunnelFailedMessage(tunnelUrl);
751
+ } else {
752
+ finalMessage(tunnelUrl, relayUrl);
753
+ }
719
754
 
720
755
  child.stdout.on('data', (d) => {
721
756
  process.stdout.write(` ${c.dim}${d.toString().trim()}${c.reset}\n`);
@@ -795,7 +830,7 @@ async function start() {
795
830
  console.error(` ${c.dim}${err.message}${c.reset}\n`);
796
831
  process.exit(1);
797
832
  }
798
- const { child, tunnelUrl, relayUrl, viteWarm } = result;
833
+ const { child, tunnelUrl, relayUrl, tunnelFailed, viteWarm } = result;
799
834
 
800
835
  // Wait for Vite to finish pre-transforming all modules (with timeout)
801
836
  await Promise.race([viteWarm, new Promise(r => setTimeout(r, 30_000))]);
@@ -812,7 +847,11 @@ async function start() {
812
847
  });
813
848
  stepper.advance();
814
849
  stepper.finish();
815
- finalMessage(tunnelUrl, relayUrl);
850
+ if (tunnelFailed) {
851
+ tunnelFailedMessage(tunnelUrl);
852
+ } else {
853
+ finalMessage(tunnelUrl, relayUrl);
854
+ }
816
855
  if (res.status === 0) {
817
856
  console.log(` ${c.blue}✔${c.reset} Daemon installed — Fluxy will auto-start on boot.`);
818
857
  } else {
@@ -823,7 +862,11 @@ async function start() {
823
862
  }
824
863
 
825
864
  stepper.finish();
826
- finalMessage(tunnelUrl, relayUrl);
865
+ if (tunnelFailed) {
866
+ tunnelFailedMessage(tunnelUrl);
867
+ } else {
868
+ finalMessage(tunnelUrl, relayUrl);
869
+ }
827
870
 
828
871
  child.stdout.on('data', (d) => {
829
872
  process.stdout.write(` ${c.dim}${d.toString().trim()}${c.reset}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fluxy-bot",
3
- "version": "0.5.61",
3
+ "version": "0.5.63",
4
4
  "releaseNotes": [
5
5
  "Fixed some bugs to iOs ",
6
6
  "2. ",
@@ -127,6 +127,12 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
127
127
  const [registeredUrl, setRegisteredUrl] = useState('');
128
128
  const handleDebounce = useRef<ReturnType<typeof setTimeout> | null>(null);
129
129
 
130
+ // Tunnel mode (step 2 branching)
131
+ const [tunnelMode, setTunnelMode] = useState<'quick' | 'named'>('quick');
132
+ const [tunnelDomain, setTunnelDomain] = useState('');
133
+ const [tunnelUrl, setTunnelUrl] = useState('');
134
+ const [handleChoice, setHandleChoice] = useState<'tunnel' | 'relay'>('relay');
135
+
130
136
  // Existing handle (for re-run / change flow)
131
137
  const [existingHandle, setExistingHandle] = useState<{ username: string; tier: string; url: string } | null>(null);
132
138
  const [showChangeConfirm, setShowChangeConfirm] = useState(false);
@@ -173,6 +179,11 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
173
179
  if (data.provider) setProvider(data.provider);
174
180
  if (data.model) setModel(data.model);
175
181
  if (data.whisperEnabled) { setWhisperEnabled(true); setWhisperKey(data.whisperKey || ''); }
182
+ if (data.tunnelMode) setTunnelMode(data.tunnelMode);
183
+ if (data.tunnelDomain) setTunnelDomain(data.tunnelDomain);
184
+ if (data.tunnelUrl) setTunnelUrl(data.tunnelUrl);
185
+ // If user has existing handle, default to 'relay'; otherwise default to 'tunnel'
186
+ if (!data.handle) setHandleChoice('tunnel');
176
187
  prefillDone.current = true;
177
188
  })
178
189
  .catch(() => { prefillDone.current = true; });
@@ -462,7 +473,11 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
462
473
  switch (step) {
463
474
  case 0: return true;
464
475
  case 1: return userName.trim().length > 0;
465
- case 2: return registered;
476
+ case 2: {
477
+ if (tunnelMode === 'named') return true;
478
+ if (handleChoice === 'tunnel') return true;
479
+ return registered;
480
+ }
466
481
  case 3: return portalCanContinue;
467
482
  case 4: return !!(provider && model && isConnected);
468
483
  case 5: return true;
@@ -608,190 +623,306 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
608
623
  )}
609
624
 
610
625
  {/* ── Step 2: Name your bot + Claim handle ── */}
611
- {step === 2 && (
626
+ {step === 2 && tunnelMode === 'named' && (
627
+ <div>
628
+ <h1 className="text-xl font-bold text-white tracking-tight">
629
+ Your Bot's Domain
630
+ </h1>
631
+ <p className="text-white/40 text-[13px] mt-1.5 leading-relaxed">
632
+ Your named tunnel is configured with a custom domain.
633
+ </p>
634
+
635
+ <div className="mt-5 flex items-center gap-2 bg-white/[0.03] border border-white/[0.06] rounded-xl px-4 py-3">
636
+ <span className="font-mono text-[13px] text-white/70 truncate flex-1 text-left">https://{tunnelDomain}</span>
637
+ <button
638
+ onClick={() => {
639
+ navigator.clipboard.writeText(`https://${tunnelDomain}`);
640
+ setPortalCopied(true);
641
+ setTimeout(() => setPortalCopied(false), 2000);
642
+ }}
643
+ className="shrink-0 text-white/30 hover:text-white/60 transition-colors"
644
+ >
645
+ {portalCopied ? (
646
+ <Check className="h-4 w-4 text-emerald-400" />
647
+ ) : (
648
+ <ClipboardPaste className="h-4 w-4" />
649
+ )}
650
+ </button>
651
+ </div>
652
+
653
+ <button
654
+ onClick={next}
655
+ className="w-full mt-5 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2"
656
+ >
657
+ Continue
658
+ <ArrowRight className="h-4 w-4" />
659
+ </button>
660
+ </div>
661
+ )}
662
+
663
+ {step === 2 && tunnelMode === 'quick' && (
612
664
  <div>
613
665
  <h1 className="text-xl font-bold text-white tracking-tight">
614
666
  Name your bot
615
667
  </h1>
616
668
  <p className="text-white/40 text-[13px] mt-1.5 leading-relaxed">
617
- This is your bot's name and permanent handle — access it from anywhere.
669
+ Choose how you'll access your bot from anywhere.
618
670
  </p>
619
671
 
620
- {/* Existing handle banner */}
621
- {existingHandle && registered && !showChangeConfirm && (
622
- <>
623
- <div className="mt-4 bg-emerald-500/8 border border-emerald-500/15 rounded-xl px-4 py-3">
624
- <div className="flex items-center gap-2">
625
- <Check className="h-4 w-4 text-emerald-400" />
626
- <p className="text-emerald-400/90 text-[13px] font-medium">Current handle</p>
627
- </div>
628
- <p className="text-emerald-400/60 text-[12px] mt-1 font-mono">{registeredUrl}</p>
629
- </div>
630
- <div className="flex gap-2 mt-4">
631
- <button
632
- onClick={next}
633
- className="flex-1 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2"
634
- >
635
- Continue
636
- <ArrowRight className="h-4 w-4" />
637
- </button>
638
- <button
639
- onClick={() => {
640
- setShowChangeConfirm(true);
641
- setRegistered(false);
642
- setBotName('');
643
- setHandleStatus(null);
644
- setTierAvailability({});
645
- }}
646
- className="px-5 py-3 bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] text-white/60 text-[13px] font-medium rounded-full transition-colors"
647
- >
648
- Change
649
- </button>
672
+ {/* Option cards */}
673
+ <div className="space-y-2 mt-5">
674
+ <button
675
+ onClick={() => setHandleChoice('tunnel')}
676
+ className={`w-full text-left px-4 py-3 rounded-xl border transition-all duration-200 ${
677
+ handleChoice === 'tunnel'
678
+ ? 'border-[#04D1FE]/40 bg-[#04D1FE]/[0.06]'
679
+ : 'border-white/[0.06] bg-transparent hover:border-white/10 hover:bg-white/[0.02]'
680
+ }`}
681
+ >
682
+ <div className="flex items-center justify-between">
683
+ <span className="text-[13px] font-medium text-white/80">Random Tunnel URL</span>
684
+ <span className="text-[11px] font-medium px-2.5 py-0.5 rounded-full border bg-emerald-500/10 text-emerald-400 border-emerald-500/20">Free</span>
650
685
  </div>
651
- </>
652
- )}
686
+ <p className="text-white/35 text-[11px] mt-1 leading-relaxed">
687
+ Every start, restart, or update will generate a new random URL like <span className="font-mono">abc-xyz.trycloudflare.com</span>. You can bookmark it or use your tunnel URL directly.
688
+ </p>
689
+ </button>
653
690
 
654
- {/* Change confirmation alert */}
655
- {showChangeConfirm && !registered && (
656
- <div className="mt-4 bg-amber-500/8 border border-amber-500/20 rounded-xl px-4 py-3">
657
- <p className="text-amber-400/90 text-[13px] font-medium">Changing your handle</p>
658
- <p className="text-amber-400/60 text-[12px] mt-1">
659
- Your current handle <span className="font-mono">{existingHandle?.url}</span> will be released and become available for others.
691
+ <button
692
+ onClick={() => setHandleChoice('relay')}
693
+ className={`w-full text-left px-4 py-3 rounded-xl border transition-all duration-200 ${
694
+ handleChoice === 'relay'
695
+ ? 'border-[#AF27E3]/40 bg-[#AF27E3]/[0.06]'
696
+ : 'border-white/[0.06] bg-transparent hover:border-white/10 hover:bg-white/[0.02]'
697
+ }`}
698
+ >
699
+ <div className="flex items-center justify-between">
700
+ <span className="text-[13px] font-medium text-white/80">Custom Domain via Fluxy Relay</span>
701
+ <span className="text-[11px] font-medium px-2.5 py-0.5 rounded-full border bg-[#AF27E3]/15 text-[#AF27E3] border-[#AF27E3]/20">Permanent</span>
702
+ </div>
703
+ <p className="text-white/35 text-[11px] mt-1 leading-relaxed">
704
+ Get a permanent domain that never changes. Choose a free <span className="font-mono">my.fluxy.bot/name</span> or premium <span className="font-mono">fluxy.bot/name</span> handle.
660
705
  </p>
706
+ </button>
707
+ </div>
708
+
709
+ {/* Random Tunnel URL — show current URL when selected */}
710
+ {handleChoice === 'tunnel' && (
711
+ <div className="mt-4">
712
+ {tunnelUrl ? (
713
+ <div className="flex items-center gap-2 bg-white/[0.03] border border-white/[0.06] rounded-xl px-4 py-3">
714
+ <span className="font-mono text-[13px] text-white/70 truncate flex-1 text-left">{tunnelUrl}</span>
715
+ <button
716
+ onClick={() => {
717
+ const url = tunnelUrl.startsWith('http') ? tunnelUrl : `https://${tunnelUrl}`;
718
+ navigator.clipboard.writeText(url);
719
+ setPortalCopied(true);
720
+ setTimeout(() => setPortalCopied(false), 2000);
721
+ }}
722
+ className="shrink-0 text-white/30 hover:text-white/60 transition-colors"
723
+ >
724
+ {portalCopied ? (
725
+ <Check className="h-4 w-4 text-emerald-400" />
726
+ ) : (
727
+ <ClipboardPaste className="h-4 w-4" />
728
+ )}
729
+ </button>
730
+ </div>
731
+ ) : (
732
+ <div className="bg-white/[0.02] border border-white/[0.06] rounded-xl px-4 py-3">
733
+ <p className="text-white/35 text-[12px]">Tunnel URL will appear once the tunnel starts.</p>
734
+ </div>
735
+ )}
736
+ <button
737
+ onClick={next}
738
+ className="w-full mt-4 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2"
739
+ >
740
+ Continue
741
+ <ArrowRight className="h-4 w-4" />
742
+ </button>
661
743
  </div>
662
744
  )}
663
745
 
664
- {/* Inputshown for new claim or change flow */}
665
- {(!existingHandle || showChangeConfirm || !registered) && !(existingHandle && registered && !showChangeConfirm) && (
666
- <>
667
- <div className="relative mt-5">
668
- <input
669
- type="text"
670
- value={botName}
671
- onChange={(e) => onBotNameInput(e.target.value)}
672
- maxLength={30}
673
- placeholder="your-bot-name"
674
- spellCheck={false}
675
- autoCapitalize="none"
676
- autoCorrect="off"
677
- autoFocus
678
- disabled={registered}
679
- className={inputCls + ' pr-10 font-mono' + (registered ? ' opacity-50' : '')}
680
- />
681
- {handleStatus && botName.length > 0 && !registered && (
682
- <div className="absolute right-4 top-1/2 -translate-y-1/2">
683
- {handleStatus === 'checking' && (
684
- <div className="w-5 h-5 border-2 border-white/10 border-t-[#04D1FE] rounded-full animate-spin" />
685
- )}
686
- {handleStatus === 'invalid' && (
687
- <div className="w-6 h-6 rounded-full bg-amber-500/15 flex items-center justify-center">
688
- <svg className="w-3.5 h-3.5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
689
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3m0 4h.01" />
690
- </svg>
691
- </div>
692
- )}
746
+ {/* Relay registration existing UI */}
747
+ {handleChoice === 'relay' && (
748
+ <div className="mt-4">
749
+ {/* Existing handle banner */}
750
+ {existingHandle && registered && !showChangeConfirm && (
751
+ <>
752
+ <div className="bg-emerald-500/8 border border-emerald-500/15 rounded-xl px-4 py-3">
753
+ <div className="flex items-center gap-2">
754
+ <Check className="h-4 w-4 text-emerald-400" />
755
+ <p className="text-emerald-400/90 text-[13px] font-medium">Current handle</p>
756
+ </div>
757
+ <p className="text-emerald-400/60 text-[12px] mt-1 font-mono">{registeredUrl}</p>
693
758
  </div>
694
- )}
695
- </div>
696
-
697
- {/* Status messages */}
698
- {handleStatus === 'invalid' && handleError && (
699
- <p className="text-amber-400 text-[12px] mt-2">{handleError}</p>
759
+ <div className="flex gap-2 mt-4">
760
+ <button
761
+ onClick={next}
762
+ className="flex-1 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2"
763
+ >
764
+ Continue
765
+ <ArrowRight className="h-4 w-4" />
766
+ </button>
767
+ <button
768
+ onClick={() => {
769
+ setShowChangeConfirm(true);
770
+ setRegistered(false);
771
+ setBotName('');
772
+ setHandleStatus(null);
773
+ setTierAvailability({});
774
+ }}
775
+ className="px-5 py-3 bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] text-white/60 text-[13px] font-medium rounded-full transition-colors"
776
+ >
777
+ Change
778
+ </button>
779
+ </div>
780
+ </>
700
781
  )}
701
782
 
702
- {/* Handle tier options — per-tier availability */}
703
- {handleStatus === 'ready' && botName.length > 0 && !registered && (
704
- <div className="space-y-2 mt-4">
705
- {HANDLES.map((h) => {
706
- const available = tierAvailability[h.tier];
707
- const taken = available === false;
708
- return (
709
- <button
710
- key={h.tier}
711
- onClick={() => !taken && setSelectedTier(h.tier)}
712
- disabled={taken}
713
- className={`w-full flex items-center justify-between px-4 py-3 rounded-xl border transition-all duration-200 text-left ${
714
- taken
715
- ? 'border-white/[0.04] bg-transparent opacity-50 cursor-not-allowed'
716
- : selectedTier === h.tier
717
- ? h.highlight
718
- ? 'border-[#AF27E3]/40 bg-[#AF27E3]/[0.06]'
719
- : 'border-[#AF27E3]/30 bg-white/[0.04]'
720
- : 'border-white/[0.06] bg-transparent hover:border-white/10 hover:bg-white/[0.02]'
721
- }`}
722
- >
723
- <span className="font-mono text-[13px] text-white/70">
724
- {h.label(botName)}
725
- </span>
726
- {taken ? (
727
- <span className="text-[11px] font-medium px-2.5 py-0.5 rounded-full border bg-red-500/10 text-red-400 border-red-500/20">
728
- Taken
729
- </span>
730
- ) : (
731
- <span className={`text-[11px] font-medium px-2.5 py-0.5 rounded-full border ${h.badgeCls}`}>
732
- {h.badge}
733
- </span>
734
- )}
735
- </button>
736
- );
737
- })}
783
+ {/* Change confirmation alert */}
784
+ {showChangeConfirm && !registered && (
785
+ <div className="bg-amber-500/8 border border-amber-500/20 rounded-xl px-4 py-3">
786
+ <p className="text-amber-400/90 text-[13px] font-medium">Changing your handle</p>
787
+ <p className="text-amber-400/60 text-[12px] mt-1">
788
+ Your current handle <span className="font-mono">{existingHandle?.url}</span> will be released and become available for others.
789
+ </p>
738
790
  </div>
739
791
  )}
740
792
 
741
- {/* Registered success (after claiming) */}
742
- {registered && (
743
- <div className="mt-4 bg-emerald-500/8 border border-emerald-500/15 rounded-xl px-4 py-3">
744
- <div className="flex items-center gap-2">
745
- <Check className="h-4 w-4 text-emerald-400" />
746
- <p className="text-emerald-400/90 text-[13px] font-medium">Handle claimed!</p>
793
+ {/* Input shown for new claim or change flow */}
794
+ {(!existingHandle || showChangeConfirm || !registered) && !(existingHandle && registered && !showChangeConfirm) && (
795
+ <>
796
+ <div className="relative mt-3">
797
+ <input
798
+ type="text"
799
+ value={botName}
800
+ onChange={(e) => onBotNameInput(e.target.value)}
801
+ maxLength={30}
802
+ placeholder="your-bot-name"
803
+ spellCheck={false}
804
+ autoCapitalize="none"
805
+ autoCorrect="off"
806
+ autoFocus
807
+ disabled={registered}
808
+ className={inputCls + ' pr-10 font-mono' + (registered ? ' opacity-50' : '')}
809
+ />
810
+ {handleStatus && botName.length > 0 && !registered && (
811
+ <div className="absolute right-4 top-1/2 -translate-y-1/2">
812
+ {handleStatus === 'checking' && (
813
+ <div className="w-5 h-5 border-2 border-white/10 border-t-[#04D1FE] rounded-full animate-spin" />
814
+ )}
815
+ {handleStatus === 'invalid' && (
816
+ <div className="w-6 h-6 rounded-full bg-amber-500/15 flex items-center justify-center">
817
+ <svg className="w-3.5 h-3.5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
818
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3m0 4h.01" />
819
+ </svg>
820
+ </div>
821
+ )}
822
+ </div>
823
+ )}
747
824
  </div>
748
- <p className="text-emerald-400/60 text-[12px] mt-1 font-mono">{registeredUrl}</p>
749
- </div>
750
- )}
751
825
 
752
- {/* Claim / Change button */}
753
- {handleStatus === 'ready' && tierAvailability[selectedTier] && botName.length > 0 && !registered && (
754
- <button
755
- onClick={showChangeConfirm ? onChangeHandle : onClaimHandle}
756
- disabled={registering || changingHandle}
757
- className="w-full mt-4 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
758
- >
759
- {(registering || changingHandle) ? (
760
- <><LoaderCircle className="h-4 w-4 animate-spin" />{showChangeConfirm ? 'Changing...' : 'Claiming...'}</>
761
- ) : (
762
- <>{showChangeConfirm ? 'Change Handle' : 'Claim & Continue'}<ArrowRight className="h-4 w-4" /></>
826
+ {/* Status messages */}
827
+ {handleStatus === 'invalid' && handleError && (
828
+ <p className="text-amber-400 text-[12px] mt-2">{handleError}</p>
763
829
  )}
764
- </button>
765
- )}
766
830
 
767
- {/* Continue after claim */}
768
- {registered && (
769
- <button
770
- onClick={next}
771
- className="w-full mt-4 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2"
772
- >
773
- Continue
774
- <ArrowRight className="h-4 w-4" />
775
- </button>
776
- )}
831
+ {/* Handle tier options — per-tier availability */}
832
+ {handleStatus === 'ready' && botName.length > 0 && !registered && (
833
+ <div className="space-y-2 mt-4">
834
+ {HANDLES.map((h) => {
835
+ const available = tierAvailability[h.tier];
836
+ const taken = available === false;
837
+ return (
838
+ <button
839
+ key={h.tier}
840
+ onClick={() => !taken && setSelectedTier(h.tier)}
841
+ disabled={taken}
842
+ className={`w-full flex items-center justify-between px-4 py-3 rounded-xl border transition-all duration-200 text-left ${
843
+ taken
844
+ ? 'border-white/[0.04] bg-transparent opacity-50 cursor-not-allowed'
845
+ : selectedTier === h.tier
846
+ ? h.highlight
847
+ ? 'border-[#AF27E3]/40 bg-[#AF27E3]/[0.06]'
848
+ : 'border-[#AF27E3]/30 bg-white/[0.04]'
849
+ : 'border-white/[0.06] bg-transparent hover:border-white/10 hover:bg-white/[0.02]'
850
+ }`}
851
+ >
852
+ <span className="font-mono text-[13px] text-white/70">
853
+ {h.label(botName)}
854
+ </span>
855
+ {taken ? (
856
+ <span className="text-[11px] font-medium px-2.5 py-0.5 rounded-full border bg-red-500/10 text-red-400 border-red-500/20">
857
+ Taken
858
+ </span>
859
+ ) : (
860
+ <span className={`text-[11px] font-medium px-2.5 py-0.5 rounded-full border ${h.badgeCls}`}>
861
+ {h.badge}
862
+ </span>
863
+ )}
864
+ </button>
865
+ );
866
+ })}
867
+ </div>
868
+ )}
777
869
 
778
- {/* Cancel change */}
779
- {showChangeConfirm && !registered && (
780
- <button
781
- onClick={() => {
782
- setShowChangeConfirm(false);
783
- setBotName(existingHandle!.username);
784
- setRegistered(true);
785
- setRegisteredUrl(existingHandle!.url);
786
- setSelectedTier(existingHandle!.tier);
787
- setHandleStatus(null);
788
- }}
789
- className="w-full mt-2 py-2 text-white/25 hover:text-white/40 text-[12px] transition-colors"
790
- >
791
- Cancel — keep current handle
792
- </button>
870
+ {/* Registered success (after claiming) */}
871
+ {registered && (
872
+ <div className="mt-4 bg-emerald-500/8 border border-emerald-500/15 rounded-xl px-4 py-3">
873
+ <div className="flex items-center gap-2">
874
+ <Check className="h-4 w-4 text-emerald-400" />
875
+ <p className="text-emerald-400/90 text-[13px] font-medium">Handle claimed!</p>
876
+ </div>
877
+ <p className="text-emerald-400/60 text-[12px] mt-1 font-mono">{registeredUrl}</p>
878
+ </div>
879
+ )}
880
+
881
+ {/* Claim / Change button */}
882
+ {handleStatus === 'ready' && tierAvailability[selectedTier] && botName.length > 0 && !registered && (
883
+ <button
884
+ onClick={showChangeConfirm ? onChangeHandle : onClaimHandle}
885
+ disabled={registering || changingHandle}
886
+ className="w-full mt-4 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
887
+ >
888
+ {(registering || changingHandle) ? (
889
+ <><LoaderCircle className="h-4 w-4 animate-spin" />{showChangeConfirm ? 'Changing...' : 'Claiming...'}</>
890
+ ) : (
891
+ <>{showChangeConfirm ? 'Change Handle' : 'Claim & Continue'}<ArrowRight className="h-4 w-4" /></>
892
+ )}
893
+ </button>
894
+ )}
895
+
896
+ {/* Continue after claim */}
897
+ {registered && (
898
+ <button
899
+ onClick={next}
900
+ className="w-full mt-4 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2"
901
+ >
902
+ Continue
903
+ <ArrowRight className="h-4 w-4" />
904
+ </button>
905
+ )}
906
+
907
+ {/* Cancel change */}
908
+ {showChangeConfirm && !registered && (
909
+ <button
910
+ onClick={() => {
911
+ setShowChangeConfirm(false);
912
+ setBotName(existingHandle!.username);
913
+ setRegistered(true);
914
+ setRegisteredUrl(existingHandle!.url);
915
+ setSelectedTier(existingHandle!.tier);
916
+ setHandleStatus(null);
917
+ }}
918
+ className="w-full mt-2 py-2 text-white/25 hover:text-white/40 text-[12px] transition-colors"
919
+ >
920
+ Cancel — keep current handle
921
+ </button>
922
+ )}
923
+ </>
793
924
  )}
794
- </>
925
+ </div>
795
926
  )}
796
927
  </div>
797
928
  )}
@@ -1241,7 +1372,19 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1241
1372
  )}
1242
1373
 
1243
1374
  {/* ── Step 6: All Set (initial onboard only) ── */}
1244
- {step === 6 && isInitialSetup && (
1375
+ {step === 6 && isInitialSetup && (() => {
1376
+ const finalUrl = (() => {
1377
+ if (tunnelMode === 'named') return `https://${tunnelDomain}`;
1378
+ if (handleChoice === 'relay' && registeredUrl) return registeredUrl;
1379
+ return tunnelUrl || `http://localhost:${3000}`;
1380
+ })();
1381
+ const finalUrlFull = finalUrl.startsWith('http') ? finalUrl : `https://${finalUrl}`;
1382
+ const descriptionText = tunnelMode === 'named'
1383
+ ? 'Access your agent at your custom domain.'
1384
+ : handleChoice === 'relay' && registeredUrl
1385
+ ? 'Your agent is live and ready. From now on, access it using your custom URL below.'
1386
+ : 'Your agent is live and ready. Your tunnel URL is shown below. Note: this URL changes on restart.';
1387
+ return (
1245
1388
  <div className="flex flex-col items-center text-center">
1246
1389
  <div className="w-16 h-16 rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center mb-5">
1247
1390
  <Check className="h-8 w-8 text-emerald-400" />
@@ -1250,16 +1393,15 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1250
1393
  All Set!
1251
1394
  </h1>
1252
1395
  <p className="text-white/40 text-[13px] mt-2 leading-relaxed max-w-[340px]">
1253
- Your agent is live and ready. From now on, access it using your custom URL below.
1396
+ {descriptionText}
1254
1397
  </p>
1255
1398
 
1256
- {/* Custom URL */}
1399
+ {/* URL */}
1257
1400
  <div className="w-full mt-6 flex items-center gap-2 bg-white/[0.03] border border-white/[0.06] rounded-xl px-4 py-3">
1258
- <span className="font-mono text-[13px] text-white/70 truncate flex-1 text-left">{registeredUrl}</span>
1401
+ <span className="font-mono text-[13px] text-white/70 truncate flex-1 text-left">{finalUrl}</span>
1259
1402
  <button
1260
1403
  onClick={() => {
1261
- const url = registeredUrl.startsWith('http') ? registeredUrl : `https://${registeredUrl}`;
1262
- navigator.clipboard.writeText(url);
1404
+ navigator.clipboard.writeText(finalUrlFull);
1263
1405
  setPortalCopied(true);
1264
1406
  setTimeout(() => setPortalCopied(false), 2000);
1265
1407
  }}
@@ -1295,9 +1437,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1295
1437
  {/* Redirect button */}
1296
1438
  <button
1297
1439
  onClick={() => {
1298
- const url = registeredUrl.startsWith('http') ? registeredUrl : `https://${registeredUrl}`;
1299
- // Navigate the top-level window (escapes iframe if embedded)
1300
- (window.top || window).location.href = url;
1440
+ (window.top || window).location.href = finalUrlFull;
1301
1441
  }}
1302
1442
  className="w-full mt-6 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2"
1303
1443
  >
@@ -1306,10 +1446,11 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1306
1446
  </button>
1307
1447
 
1308
1448
  <p className="text-white/20 text-[11px] mt-3 leading-relaxed">
1309
- You'll be redirected to your custom URL.
1449
+ You'll be redirected to your {tunnelMode === 'named' ? 'custom domain' : handleChoice === 'relay' ? 'custom URL' : 'tunnel URL'}.
1310
1450
  </p>
1311
1451
  </div>
1312
- )}
1452
+ );
1453
+ })()}
1313
1454
  </motion.div>
1314
1455
  </AnimatePresence>
1315
1456
 
package/worker/index.ts CHANGED
@@ -291,6 +291,9 @@ app.get('/api/onboard/status', (_, res) => {
291
291
  tier: cfg.relay.tier,
292
292
  url: cfg.relay.url,
293
293
  } : null,
294
+ tunnelMode: cfg.tunnel?.mode || 'quick',
295
+ tunnelDomain: cfg.tunnel?.domain || '',
296
+ tunnelUrl: cfg.tunnelUrl || '',
294
297
  });
295
298
  });
296
299
 
@@ -0,0 +1 @@
1
+ [backend] Listening on port 3004