fluxy-bot 0.5.57 → 0.5.59

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
@@ -18,7 +18,7 @@ The relay gives users a permanent domain. The tunnel gives the relay a target. T
18
18
 
19
19
  ## Process Architecture
20
20
 
21
- When `fluxy start` runs, the CLI spawns a single supervisor process. The supervisor then spawns three child processes and manages their lifecycle:
21
+ When `fluxy start` runs, the CLI spawns a single supervisor process. The supervisor then spawns child processes and manages their lifecycle:
22
22
 
23
23
  ```
24
24
  CLI (bin/cli.js)
@@ -31,11 +31,12 @@ Supervisor (supervisor/index.ts) port 3000 HTTP server + WebSocket + r
31
31
  +-- Vite Dev Server port 3002 Serves workspace/client with HMR
32
32
  +-- Backend (workspace/backend/) port 3004 User's custom Express server
33
33
  +-- cloudflared (tunnel) -- Exposes port 3000 to the internet
34
+ +-- Scheduler (supervisor/scheduler) -- PULSE + CRON job runner (in-process)
34
35
  ```
35
36
 
36
37
  Port allocation: base port (default 3000), worker = base+1, Vite = base+2, backend = base+4.
37
38
 
38
- All child processes auto-restart up to 3 times on crash. The supervisor catches SIGINT/SIGTERM and tears everything down gracefully.
39
+ All child processes auto-restart up to 3 times on crash (reset counter if alive >30s). The supervisor catches SIGINT/SIGTERM and tears everything down gracefully.
39
40
 
40
41
  ---
41
42
 
@@ -45,10 +46,11 @@ The supervisor is a raw `http.createServer` (no Express) that routes every incom
45
46
 
46
47
  | Path | Target | Notes |
47
48
  |---|---|---|
49
+ | `/fluxy/widget.js` | Direct file serve | Chat bubble script, no-cache |
50
+ | `/sw.js`, `/fluxy/sw.js` | Embedded service worker | PWA + push notification support |
48
51
  | `/app/api/*` | Backend (port 3004) | Strips `/app/api` prefix before forwarding |
49
52
  | `/api/*` | Worker (port 3001) | Auth middleware checks Bearer token on mutations |
50
- | `/fluxy/*` | Static files from `dist-fluxy/` | Pre-built chat SPA, cached aggressively |
51
- | `/fluxy/widget.js` | Direct file serve | Chat bubble script injected into dashboard |
53
+ | `/fluxy/*` | Static files from `dist-fluxy/` | Pre-built chat SPA. HTML: no-cache. Hashed assets: immutable, 1yr max-age |
52
54
  | Everything else | Vite dev server (port 3002) | Dashboard + HMR |
53
55
 
54
56
  WebSocket upgrades:
@@ -61,10 +63,14 @@ The supervisor also:
61
63
  - Runs the Claude Agent SDK when users send chat messages
62
64
  - Restarts the backend process when Claude edits workspace files
63
65
  - Broadcasts `app:hmr-update` to all connected dashboard clients after file changes
66
+ - Watches `workspace/backend/` for `.ts/.js/.json` changes and auto-restarts the backend
67
+ - Watches workspace root for `.env` changes (backend restart), `.restart` trigger, and `.update` trigger (deferred fluxy update)
68
+ - Runs the PULSE/CRON scheduler
69
+ - Serves an embedded service worker for PWA + push notifications
64
70
 
65
71
  ### Auth Middleware
66
72
 
67
- The supervisor validates Bearer tokens on `/api/*` POST/PUT/DELETE requests by calling the worker's `/api/portal/validate-token` endpoint. Token results are cached for 60 seconds. Auth-exempt routes (login, onboard, health) skip this check.
73
+ The supervisor validates Bearer tokens on `/api/*` POST/PUT/DELETE requests by calling the worker's `/api/portal/validate-token` endpoint. Token results are cached for 60 seconds. Auth-exempt routes (login, onboard, health, push, auth endpoints) skip this check.
68
74
 
69
75
  The `/app/api/*` route has no auth -- the user's workspace backend handles its own authentication.
70
76
 
@@ -72,26 +78,35 @@ The `/app/api/*` route has no auth -- the user's workspace backend handles its o
72
78
 
73
79
  ## Worker (worker/index.ts)
74
80
 
75
- Express server on port 3001. Owns the database and all platform API logic. The supervisor never touches SQLite directly -- everything goes through HTTP.
81
+ Express server on port 3001. Owns the database and all platform API logic. The supervisor never touches SQLite directly -- everything goes through HTTP. All API responses set `Cache-Control: no-store, no-cache, must-revalidate` to prevent stale responses through the relay/CDN.
76
82
 
77
83
  **Database:** `~/.fluxy/memory.db` (SQLite via better-sqlite3, WAL mode)
78
84
 
79
85
  Tables:
80
86
  - `conversations` -- chat sessions (id, title, model, session_id, timestamps)
81
- - `messages` -- individual messages (role, content, tokens, audio, attachments as JSON)
87
+ - `messages` -- individual messages (role, content, tokens_in, tokens_out, model, audio_data, attachments as JSON)
82
88
  - `settings` -- key-value store (onboard config, provider, model, portal credentials, etc.)
83
89
  - `sessions` -- auth tokens with 7-day expiry
90
+ - `push_subscriptions` -- Web Push endpoints with VAPID keys (endpoint, keys_p256dh, keys_auth)
91
+
92
+ Auto-migrations add missing columns on startup (session_id, audio_data, attachments).
84
93
 
85
94
  Key endpoints:
86
- - `/api/conversations` -- CRUD for conversations and messages
95
+ - `/api/conversations` -- CRUD for conversations and messages (paginated with `before` cursor)
87
96
  - `/api/settings` -- key-value read/write
88
- - `/api/onboard` -- saves wizard configuration (provider, model, portal password, whisper key)
89
- - `/api/onboard/status` -- returns current setup state
97
+ - `/api/onboard` -- saves wizard configuration (provider, model, portal password, whisper key, names)
98
+ - `/api/onboard/status` -- returns current setup state (names, portal, whisper, provider, handle)
90
99
  - `/api/portal/login` -- password auth (POST with JSON body or GET with Basic Auth header)
91
- - `/api/portal/validate-token` -- session token validation
92
- - `/api/whisper/transcribe` -- audio-to-text via OpenAI Whisper API
93
- - `/api/handle/*` -- register, change, release relay handles
94
- - `/api/context/current` and `/api/context/set` -- tracks which conversation is active
100
+ - `/api/portal/validate-token` -- session token validation (POST or GET)
101
+ - `/api/portal/verify-password` -- check password without creating session
102
+ - `/api/whisper/transcribe` -- audio-to-text via OpenAI Whisper API (10MB limit)
103
+ - `/api/handle/*` -- check availability, register, change relay handles
104
+ - `/api/context/current`, `/api/context/set`, `/api/context/clear` -- tracks which conversation is active
105
+ - `/api/auth/claude/*` -- Claude OAuth start, exchange, status
106
+ - `/api/auth/codex/*` -- OpenAI OAuth start, cancel, status
107
+ - `/api/push/*` -- VAPID public key, subscribe, unsubscribe, send notifications, status
108
+ - `/api/files/*` -- static file serving from attachment storage
109
+ - `/api/health` -- health check
95
110
 
96
111
  Portal passwords are hashed with scrypt (random 16-byte salt, stored as `salt:hash`).
97
112
 
@@ -103,14 +118,22 @@ The workspace is a full-stack app template that Claude can freely modify. It liv
103
118
 
104
119
  ```
105
120
  workspace/
106
- client/ React + Vite + Tailwind dashboard
107
- src/App.tsx Main app entry (error boundary, rebuild overlay, onboard iframe)
108
- index.html PWA manifest, service worker registration, widget script
121
+ client/ React + Vite + Tailwind dashboard
122
+ src/App.tsx Main app entry (error boundary, rebuild overlay, onboard iframe)
123
+ index.html PWA manifest, service worker registration, widget script
109
124
  backend/
110
- index.ts Express server template (reads .env, opens app.db)
111
- .env Environment variables for the backend
112
- app.db SQLite database for workspace data
113
- files/ Attachment storage (audio, images, documents)
125
+ index.ts Express server template (reads .env, opens app.db)
126
+ .env Environment variables for the backend
127
+ app.db SQLite database for workspace data
128
+ MYSELF.md Agent identity and personality
129
+ MYHUMAN.md Everything the agent knows about the user
130
+ MEMORY.md Long-term curated knowledge
131
+ PULSE.json Periodic wake-up config (interval, quiet hours)
132
+ CRONS.json Scheduled tasks with cron expressions
133
+ memory/ Daily notes (YYYY-MM-DD.md files, append-only)
134
+ skills/ Plugin directories with .claude-plugin/plugin.json
135
+ MCP.json MCP server configuration (optional)
136
+ files/ Attachment storage (audio, images, documents)
114
137
  ```
115
138
 
116
139
  The backend runs on port 3004, accessed at `/app/api/*` through the supervisor proxy. The `/app/api` prefix is stripped before reaching the backend, so routes are defined as `/health` not `/app/api/health`.
@@ -121,6 +144,69 @@ The workspace is the only directory Claude is allowed to modify. The system prom
121
144
 
122
145
  ---
123
146
 
147
+ ## Agent Memory System
148
+
149
+ The agent has no persistent memory between sessions except through files in the workspace:
150
+
151
+ | File | Purpose |
152
+ |---|---|
153
+ | `MYSELF.md` | Agent identity, personality, operating manual. The agent's self-authored description of who it is. |
154
+ | `MYHUMAN.md` | Profile of the user -- preferences, context, everything the agent has learned about them. |
155
+ | `MEMORY.md` | Long-term curated knowledge. Distilled from daily notes into durable insights. |
156
+ | `memory/YYYY-MM-DD.md` | Daily notes. Raw, append-only log of events and observations for that day. |
157
+
158
+ All four are injected into the system prompt at query time by `fluxy-agent.ts`. The agent reads and writes these files itself -- there's no external process managing them.
159
+
160
+ ---
161
+
162
+ ## Scheduler (supervisor/scheduler.ts)
163
+
164
+ The scheduler runs in-process within the supervisor, checking every 60 seconds.
165
+
166
+ ### PULSE
167
+
168
+ Periodic wake-ups configured in `workspace/PULSE.json`:
169
+
170
+ ```json
171
+ { "enabled": true, "intervalMinutes": 30, "quietHours": { "start": "23:00", "end": "07:00" } }
172
+ ```
173
+
174
+ When a pulse fires, the scheduler triggers the agent with a system-generated prompt. The agent can check in, review notes, or take proactive action. Quiet hours suppress pulses.
175
+
176
+ ### CRONS
177
+
178
+ Scheduled tasks configured in `workspace/CRONS.json`:
179
+
180
+ ```json
181
+ [{ "id": "...", "schedule": "0 9 * * *", "task": "Check the weather", "enabled": true, "oneShot": false }]
182
+ ```
183
+
184
+ Uses `cron-parser` to match cron expressions against the current minute. One-shot crons are auto-removed after firing.
185
+
186
+ When a cron or pulse fires:
187
+ 1. The scheduler calls `startFluxyAgentQuery` with the task
188
+ 2. It extracts `<Message>` blocks from the agent's response
189
+ 3. It sends push notifications for those messages
190
+ 4. If the agent used file tools (Write/Edit), the backend is restarted
191
+
192
+ ---
193
+
194
+ ## Skills & Plugins
195
+
196
+ The agent auto-discovers skills in `workspace/skills/`. Each skill is a folder containing `.claude-plugin/plugin.json`. These are loaded as tool plugins when the agent starts a query.
197
+
198
+ MCP servers can be configured in `workspace/MCP.json`. The agent loads them at query time and logs which servers are active.
199
+
200
+ ---
201
+
202
+ ## Web Push Notifications
203
+
204
+ The worker generates VAPID keys on first boot and stores them in settings. The chat SPA requests notification permission, subscribes via the Push API, and sends the subscription endpoint + keys to the worker.
205
+
206
+ When the scheduler (or any server-side event) needs to notify the user, it calls `POST /api/push/send` which fans out `web-push` notifications to all stored subscriptions. Expired subscriptions are auto-cleaned.
207
+
208
+ ---
209
+
124
210
  ## Fluxy Chat
125
211
 
126
212
  The chat UI is a standalone React SPA built separately (`vite.fluxy.config.ts` -> `dist-fluxy/`). It runs inside an iframe injected by `widget.js` on the dashboard.
@@ -132,15 +218,22 @@ If Claude introduces a bug that crashes the dashboard, the chat stays alive. The
132
218
  ### Widget (supervisor/widget.js)
133
219
 
134
220
  Vanilla JS that injects:
135
- - A floating video bubble (60px, bottom-right corner)
221
+ - A floating video bubble (60px, bottom-right corner, with Safari fallback image)
136
222
  - A slide-out panel (480px wide, full screen on mobile) containing the iframe at `/fluxy/`
137
223
  - A backdrop for dismissal
224
+ - Keyboard dismiss (Escape key)
138
225
 
139
226
  Communicates with the iframe via `postMessage`:
140
227
  - `fluxy:close` -- iframe requests panel close
141
228
  - `fluxy:install-app` -- iframe requests PWA install prompt
142
- - `fluxy:onboard-complete` -- iframe notifies onboarding finished
143
- - `fluxy:hmr-update` -- supervisor notifies dashboard of file changes (forwarded to parent)
229
+ - `fluxy:show-ios-install` -- iframe requests iOS-specific install modal
230
+ - `fluxy:onboard-complete` -- iframe notifies onboarding finished, reloads bubble
231
+ - `fluxy:rebuilding` -- agent started modifying files, show rebuild overlay
232
+ - `fluxy:rebuilt` -- rebuild complete, dashboard should reload
233
+ - `fluxy:build-error` -- build failed, show error overlay
234
+ - `fluxy:hmr-update` -- supervisor notifies dashboard of file changes
235
+
236
+ Panel state persisted in localStorage (`fluxy_widget_open`) to survive HMR reloads. Bubble hidden during onboarding.
144
237
 
145
238
  ### Chat Protocol (WebSocket)
146
239
 
@@ -157,8 +250,10 @@ Server -> Client:
157
250
  - `bot:tool` -- tool invocation (name, status)
158
251
  - `bot:response` -- final complete response
159
252
  - `bot:error` -- error message
253
+ - `bot:done` -- query complete, includes `usedFileTools` flag
160
254
  - `chat:conversation-created` -- new conversation ID assigned
161
255
  - `chat:sync` -- message from another connected client
256
+ - `chat:state` -- stream state on reconnect (catches up missed tokens)
162
257
  - `chat:cleared` -- context was cleared
163
258
  - `app:hmr-update` -- file changes detected, dashboard should reload
164
259
 
@@ -166,6 +261,14 @@ Server -> Client:
166
261
 
167
262
  Auto-reconnects with exponential backoff (1s -> 8s cap). Queues messages during disconnection. Sends heartbeat pings every 25 seconds. Auth token passed as query parameter on connect.
168
263
 
264
+ ### PWA Support
265
+
266
+ The chat SPA handles:
267
+ - `beforeinstallprompt` for Android PWA install
268
+ - iOS-specific install modal with step-by-step instructions
269
+ - Standalone mode detection
270
+ - Push notification subscription on first load
271
+
169
272
  ---
170
273
 
171
274
  ## Claude Agent SDK Integration (supervisor/fluxy-agent.ts)
@@ -177,6 +280,10 @@ When the configured provider is Anthropic, chat messages are routed through the
177
280
  - Max 50 turns per query
178
281
  - System prompt: Claude Code preset + custom addendum from `worker/prompts/fluxy-system-prompt.txt`
179
282
  - Sessions tracked in-memory (conversationId -> sessionId) for multi-turn context
283
+ - Memory files (MYSELF.md, MYHUMAN.md, MEMORY.md, PULSE.json, CRONS.json) injected into system prompt
284
+ - Skills auto-discovered from `workspace/skills/`
285
+ - MCP servers loaded from `workspace/MCP.json`
286
+ - File attachments encoded as base64 documents/images in the SDK prompt
180
287
 
181
288
  The agent has access to all Claude Code tools (Read, Write, Edit, Bash, Grep, Glob, etc.). After a query completes, the supervisor checks if Write or Edit tools were used. If so, it restarts the backend and broadcasts an HMR update.
182
289
 
@@ -212,7 +319,7 @@ Supervisor User's machine
212
319
 
213
320
  ### Cloudflare Tunnel (supervisor/tunnel.ts)
214
321
 
215
- - Auto-downloads `cloudflared` binary to `~/.fluxy/bin/` on first run
322
+ - Auto-downloads `cloudflared` binary to `~/.fluxy/bin/` on first run (validates minimum 10MB file size)
216
323
  - Spawns: `cloudflared tunnel --url http://localhost:3000 --no-autoupdate`
217
324
  - Extracts tunnel URL from stdout (regex match for `*.trycloudflare.com`)
218
325
  - Health check: HEAD request to tunnel URL with 5s timeout
@@ -269,7 +376,7 @@ Multi-step wizard shown on first launch (inside the Fluxy chat iframe):
269
376
  1. **Welcome** -- intro screen
270
377
  2. **Provider** -- choose Claude (Anthropic) or OpenAI Codex
271
378
  3. **Model** -- select specific model (Opus, Sonnet, Haiku / GPT-5.x variants)
272
- 4. **Auth** -- OAuth PKCE flow for Claude, or manual API key entry
379
+ 4. **Auth** -- OAuth PKCE flow for Claude or OpenAI, or manual API key entry
273
380
  5. **Handle** -- register a public username with the relay (checks availability in real time)
274
381
  6. **Portal** -- set username/password for remote access
275
382
  7. **Whisper** -- optional voice transcription setup with OpenAI key
@@ -279,14 +386,53 @@ Settings are saved via WebSocket (`settings:save` message) to bypass relay POST
279
386
 
280
387
  ---
281
388
 
389
+ ## CLI (bin/cli.js)
390
+
391
+ The CLI is the user-facing entry point. Commands:
392
+
393
+ | Command | Description |
394
+ |---|---|
395
+ | `fluxy init` | First-time setup: creates config, installs cloudflared, boots server, optionally installs systemd daemon |
396
+ | `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 |
398
+ | `fluxy update` | Downloads latest from npm registry, updates code directories, rebuilds UI, restarts daemon |
399
+ | `fluxy daemon` | Linux systemd management: install, start, stop, restart, status, logs, uninstall |
400
+
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.
402
+
403
+ On Linux, `fluxy daemon` generates a systemd unit file that runs the supervisor as a service with auto-restart on failure.
404
+
405
+ ---
406
+
407
+ ## Installation
408
+
409
+ Two installation paths:
410
+
411
+ **Via curl (production):**
412
+ ```
413
+ curl -fsSL https://fluxy.bot/install | sh
414
+ ```
415
+ The install script (`scripts/install.sh`) detects OS/arch, checks for Node.js >= 18 (or bundles Node 22.14.0), downloads the npm package, extracts to `~/.fluxy/`, and adds `fluxy` to PATH.
416
+
417
+ **Via npm (development):**
418
+ ```
419
+ npm install fluxy-bot
420
+ ```
421
+ The `postinstall` script (`scripts/postinstall.js`) copies code directories to `~/.fluxy/`, runs `npm install --omit=dev` there, builds the chat UI if missing, and creates a `fluxy` symlink.
422
+
423
+ Windows: `scripts/install.ps1` (PowerShell equivalent).
424
+
425
+ ---
426
+
282
427
  ## Data Locations
283
428
 
284
429
  | Path | Contents |
285
430
  |---|---|
286
431
  | `~/.fluxy/config.json` | Port, username, AI provider, relay token, tunnel URL |
287
- | `~/.fluxy/memory.db` | SQLite -- conversations, messages, settings, sessions |
432
+ | `~/.fluxy/memory.db` | SQLite -- conversations, messages, settings, sessions, push subscriptions |
288
433
  | `~/.fluxy/bin/cloudflared` | Cloudflare tunnel binary |
289
- | `~/.fluxy/workspace/` | User's workspace copy (client, backend, .env, app.db, files/) |
434
+ | `~/.fluxy/workspace/` | User's workspace copy (client, backend, memory files, skills, config) |
435
+ | `~/.codex/codedeck-auth.json` | OpenAI OAuth tokens |
290
436
  | `~/.claude/.credentials.json` | Claude OAuth tokens (Linux/Windows) |
291
437
  | macOS Keychain `Claude Code-credentials` | Claude OAuth tokens (macOS, source of truth) |
292
438
 
@@ -297,39 +443,52 @@ Settings are saved via WebSocket (`settings:save` message) to bypass relay POST
297
443
  ### Sacred (never modified by the agent)
298
444
 
299
445
  ```
300
- bin/cli.js CLI entry point, startup sequence, update logic
446
+ bin/cli.js CLI entry point, startup sequence, update logic, daemon management
301
447
  supervisor/
302
448
  index.ts HTTP server, request routing, WebSocket handler, process orchestration
303
449
  worker.ts Worker process spawn/stop/restart
304
450
  backend.ts Backend process spawn/stop/restart
305
451
  tunnel.ts Cloudflare tunnel lifecycle, health watchdog
306
452
  vite-dev.ts Vite dev server startup for dashboard HMR
307
- fluxy-agent.ts Claude Agent SDK wrapper, session management
453
+ fluxy-agent.ts Claude Agent SDK wrapper, session management, memory injection
454
+ scheduler.ts PULSE + CRON scheduler, 60s tick, push notification dispatch
308
455
  file-saver.ts Attachment storage (audio, images, documents)
309
456
  widget.js Chat bubble + panel injected into dashboard
310
457
  chat/
311
- fluxy-main.tsx Chat SPA entry -- auth, WS connection, message routing
458
+ fluxy-main.tsx Chat SPA entry -- auth, WS connection, push subscription, PWA
312
459
  onboard-main.tsx Onboard SPA entry
313
460
  OnboardWizard.tsx Multi-step setup wizard
314
461
  ARCHITECTURE.md Network topology and relay workaround docs
315
462
  src/
316
- hooks/useFluxyChat.ts Chat state management, message protocol, streaming
463
+ hooks/useChat.ts Base chat state management
464
+ hooks/useFluxyChat.ts Fluxy-specific chat: DB persistence, sync, pagination, streaming
317
465
  lib/ws-client.ts WebSocket client with reconnect + queue
318
466
  lib/auth.ts Token storage and auth fetch wrapper
319
- components/Chat/ InputBar, MessageBubble, MessageList
467
+ components/Chat/
468
+ ChatView.tsx Main chat container
469
+ InputBar.tsx Text input, file/camera attachments, voice recording
470
+ MessageBubble.tsx Markdown rendering, syntax highlighting, attachments
471
+ MessageList.tsx Paginated message history with infinite scroll
472
+ AudioBubble.tsx Audio player for voice messages
473
+ ImageLightbox.tsx Image viewer modal
474
+ TypingIndicator.tsx "Bot is typing..." animation
320
475
  components/LoginScreen.tsx Portal login UI
321
476
  worker/
322
477
  index.ts Express API server -- all platform endpoints
323
- db.ts SQLite schema, CRUD operations
478
+ db.ts SQLite schema, CRUD operations, migrations
324
479
  claude-auth.ts Claude OAuth PKCE flow, token refresh, Keychain integration
325
- codex-auth.ts OpenAI OAuth flow
480
+ codex-auth.ts OpenAI OAuth PKCE flow, local callback server on port 1455
326
481
  prompts/fluxy-system-prompt.txt System prompt that constrains the agent
327
482
  shared/
328
483
  config.ts Load/save ~/.fluxy/config.json
329
484
  paths.ts All path constants (PKG_DIR, DATA_DIR, WORKSPACE_DIR)
330
485
  relay.ts Relay API client (register, heartbeat, disconnect, tunnel update)
331
- ai.ts AI provider abstraction (Anthropic, OpenAI, Ollama)
332
- logger.ts Colored console logging
486
+ ai.ts AI provider abstraction (Anthropic, OpenAI, Ollama) with streaming
487
+ logger.ts Colored console logging with timestamps
488
+ scripts/
489
+ install.sh User-facing install script (curl-piped), Node bundling
490
+ install.ps1 Windows PowerShell installer
491
+ postinstall.js npm postinstall: copies files to ~/.fluxy/, builds UI, creates symlink
333
492
  ```
334
493
 
335
494
  ### Workspace (agent-modifiable)
@@ -345,7 +504,15 @@ workspace/
345
504
  index.ts Express server template with .env loading and SQLite
346
505
  .env Environment variables
347
506
  app.db Workspace SQLite database
348
- files/ Uploaded file storage
507
+ MYSELF.md Agent identity and personality
508
+ MYHUMAN.md User profile (agent-maintained)
509
+ MEMORY.md Long-term curated knowledge
510
+ PULSE.json Periodic wake-up configuration
511
+ CRONS.json Scheduled task definitions
512
+ memory/ Daily notes (YYYY-MM-DD.md)
513
+ skills/ Plugin directories (.claude-plugin/plugin.json)
514
+ MCP.json MCP server configuration (optional)
515
+ files/ Uploaded file storage (audio/, images/, documents/)
349
516
  ```
350
517
 
351
518
  ---
@@ -369,3 +536,6 @@ Zero configuration. No Cloudflare account needed. The tradeoff is the URL change
369
536
 
370
537
  **Why two Vite configs?**
371
538
  `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.
539
+
540
+ **Why memory files instead of a database for agent memory?**
541
+ Files are the natural interface for the Claude Agent SDK -- it can read and write them with its built-in tools. No custom tool needed, no API integration. The agent manages its own memory with the same tools it uses to edit code.
package/bin/cli.js CHANGED
@@ -4,6 +4,7 @@ import { spawn, execSync, spawnSync } from 'child_process';
4
4
  import fs from 'fs';
5
5
  import path from 'path';
6
6
  import os from 'os';
7
+ import readline from 'readline';
7
8
  import { fileURLToPath } from 'url';
8
9
 
9
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -133,6 +134,180 @@ function link(url) {
133
134
  return `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`;
134
135
  }
135
136
 
137
+ function chooseTunnelMode() {
138
+ return new Promise((resolve) => {
139
+ const options = [
140
+ {
141
+ label: 'Quick Tunnel',
142
+ tag: 'FREE',
143
+ tagColor: c.green,
144
+ desc: [
145
+ 'Random CloudFlare tunnel URL on every start',
146
+ `Optional: ${c.reset}${c.pink}fluxy.bot/YOURBOT${c.reset}${c.dim} custom handle via Fluxy relay`,
147
+ ],
148
+ },
149
+ {
150
+ label: 'Named Tunnel',
151
+ tag: 'Advanced',
152
+ tagColor: c.yellow,
153
+ desc: [
154
+ 'Persistent URL with your own domain',
155
+ 'Requires a CloudFlare account + domain',
156
+ ],
157
+ },
158
+ {
159
+ label: 'Offline',
160
+ tag: 'Local only',
161
+ tagColor: c.dim,
162
+ desc: [
163
+ 'No internet access — localhost only',
164
+ `Accessible at ${c.reset}${c.white}http://localhost:3000${c.reset}${c.dim}`,
165
+ ],
166
+ },
167
+ ];
168
+
169
+ let selected = 0;
170
+
171
+ function render() {
172
+ // Move cursor up to clear previous render (skip on first render)
173
+ const totalLines = options.reduce((sum, o) => sum + 2 + o.desc.length, 0) + 2;
174
+ if (render._rendered) {
175
+ process.stdout.write(`\x1b[${totalLines}A`);
176
+ }
177
+ render._rendered = true;
178
+
179
+ console.log(` ${c.bold}${c.white}How do you want to connect your bot?${c.reset}\n`);
180
+
181
+ for (let i = 0; i < options.length; i++) {
182
+ const opt = options[i];
183
+ const isSelected = i === selected;
184
+ const bullet = isSelected ? `${c.pink}❯` : `${c.dim} `;
185
+ const label = isSelected ? `${c.bold}${c.white}${opt.label}` : `${c.dim}${opt.label}`;
186
+ const tag = `${opt.tagColor}[${opt.tag}]${c.reset}`;
187
+
188
+ console.log(` ${bullet} ${label}${c.reset} ${tag}`);
189
+ for (const line of opt.desc) {
190
+ console.log(` ${c.dim}${line}${c.reset}`);
191
+ }
192
+ if (i < options.length - 1) console.log('');
193
+ }
194
+ }
195
+
196
+ render();
197
+
198
+ // Enable raw mode for arrow key input
199
+ process.stdin.setRawMode(true);
200
+ process.stdin.resume();
201
+ process.stdin.setEncoding('utf-8');
202
+
203
+ const onKey = (key) => {
204
+ if (key === '\x1b[A' || key === 'k') { // Up
205
+ selected = (selected - 1 + options.length) % options.length;
206
+ render();
207
+ } else if (key === '\x1b[B' || key === 'j') { // Down
208
+ selected = (selected + 1) % options.length;
209
+ render();
210
+ } else if (key === '\r' || key === '\n') { // Enter
211
+ process.stdin.setRawMode(false);
212
+ process.stdin.pause();
213
+ process.stdin.removeListener('data', onKey);
214
+ const modes = ['quick', 'named', 'off'];
215
+ resolve(modes[selected]);
216
+ } else if (key === '\x03') { // Ctrl+C
217
+ process.stdout.write('\n');
218
+ process.exit(0);
219
+ }
220
+ };
221
+
222
+ process.stdin.on('data', onKey);
223
+ });
224
+ }
225
+
226
+ async function runNamedTunnelSetup() {
227
+ // Ensure cloudflared is installed
228
+ console.log(`\n ${c.blue}⠋${c.reset} Checking cloudflared...`);
229
+ await installCloudflared();
230
+ console.log(` ${c.blue}✔${c.reset} cloudflared ready\n`);
231
+
232
+ // Login to Cloudflare
233
+ console.log(` ${c.bold}${c.white}Step 1:${c.reset} Log in to Cloudflare\n`);
234
+ console.log(` ${c.dim}This will open a browser window. Authorize the domain you want to use.${c.reset}\n`);
235
+ try {
236
+ spawnSync('cloudflared', ['tunnel', 'login'], { stdio: 'inherit' });
237
+ } catch {
238
+ if (fs.existsSync(CF_PATH)) {
239
+ spawnSync(CF_PATH, ['tunnel', 'login'], { stdio: 'inherit' });
240
+ } else {
241
+ console.log(`\n ${c.red}✗${c.reset} cloudflared login failed.\n`);
242
+ process.exit(1);
243
+ }
244
+ }
245
+ console.log('');
246
+
247
+ // Ask for tunnel name
248
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
249
+ const askQ = (q) => new Promise((r) => rl.question(q, (a) => r(a.trim())));
250
+
251
+ const tunnelName = (await askQ(` ${c.bold}Tunnel name${c.reset} ${c.dim}(default: fluxy)${c.reset}: `)) || 'fluxy';
252
+
253
+ // Create tunnel
254
+ console.log(`\n ${c.blue}⠋${c.reset} Creating tunnel "${tunnelName}"...`);
255
+ let createOutput;
256
+ try {
257
+ createOutput = execSync(`cloudflared tunnel create ${tunnelName}`, { encoding: 'utf-8' });
258
+ } catch {
259
+ try {
260
+ createOutput = execSync(`${CF_PATH} tunnel create ${tunnelName}`, { encoding: 'utf-8' });
261
+ } catch {
262
+ console.log(`\n ${c.red}✗${c.reset} Failed to create tunnel. It may already exist.`);
263
+ console.log(` ${c.dim}Try: cloudflared tunnel list${c.reset}\n`);
264
+ process.exit(1);
265
+ }
266
+ }
267
+
268
+ const uuidMatch = createOutput.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
269
+ if (!uuidMatch) {
270
+ console.log(`\n ${c.red}✗${c.reset} Could not parse tunnel UUID from output.`);
271
+ console.log(` ${c.dim}${createOutput}${c.reset}\n`);
272
+ process.exit(1);
273
+ }
274
+ const tunnelUuid = uuidMatch[1];
275
+ console.log(` ${c.blue}✔${c.reset} Tunnel created: ${c.dim}${tunnelUuid}${c.reset}\n`);
276
+
277
+ // Ask for domain
278
+ const domain = await askQ(` ${c.bold}Your domain${c.reset} ${c.dim}(e.g. bot.mydomain.com)${c.reset}: `);
279
+ rl.close();
280
+
281
+ if (!domain) {
282
+ console.log(`\n ${c.red}✗${c.reset} Domain is required for named tunnels.\n`);
283
+ process.exit(1);
284
+ }
285
+
286
+ // Generate cloudflared config
287
+ const config = fs.existsSync(CONFIG_PATH) ? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) : {};
288
+ const port = config.port || 3000;
289
+ const cfHome = path.join(os.homedir(), '.cloudflared');
290
+ const cfConfigPath = path.join(DATA_DIR, 'cloudflared-config.yml');
291
+
292
+ const yamlContent = `tunnel: ${tunnelUuid}
293
+ credentials-file: ${path.join(cfHome, `${tunnelUuid}.json`)}
294
+ ingress:
295
+ - service: http://localhost:${port}
296
+ `;
297
+ fs.mkdirSync(DATA_DIR, { recursive: true });
298
+ fs.writeFileSync(cfConfigPath, yamlContent);
299
+ console.log(`\n ${c.blue}✔${c.reset} Config written to ${c.dim}${cfConfigPath}${c.reset}`);
300
+
301
+ // Print DNS instructions
302
+ console.log(`\n ${c.dim}─────────────────────────────────${c.reset}\n`);
303
+ console.log(` ${c.bold}${c.white}Tunnel created!${c.reset}`);
304
+ console.log(` ${c.white}Add a CNAME record pointing to:${c.reset}\n`);
305
+ console.log(` ${c.pink}${c.bold}${tunnelUuid}.cfargotunnel.com${c.reset}\n`);
306
+ console.log(` ${c.dim}Or run: cloudflared tunnel route dns ${tunnelName} ${domain}${c.reset}\n`);
307
+
308
+ return { tunnelName, domain, cfConfigPath };
309
+ }
310
+
136
311
  class Stepper {
137
312
  constructor(steps) {
138
313
  this.steps = steps;
@@ -239,7 +414,7 @@ function finalMessage(tunnelUrl, relayUrl) {
239
414
  ${c.blue}${c.bold}${link(tunnelUrl)}${c.reset}
240
415
  ${c.dim}(cmd+click or ctrl+click to open)${c.reset}`);
241
416
 
242
- if (relayUrl) {
417
+ if (relayUrl && relayUrl !== tunnelUrl) {
243
418
  console.log(`
244
419
  ${c.bold}${c.white}Your permanent URL:${c.reset}
245
420
 
@@ -283,7 +458,7 @@ function createConfig() {
283
458
  port: 3000,
284
459
  username: '',
285
460
  ai: { provider: '', model: '', apiKey: '' },
286
- tunnel: { enabled: true },
461
+ tunnel: { mode: 'quick' },
287
462
  relay: { token: '', tier: '', url: '' },
288
463
  };
289
464
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
@@ -441,15 +616,37 @@ async function init() {
441
616
  createConfig();
442
617
  writeVersionFile(pkg.version);
443
618
 
619
+ // Interactive tunnel mode selection
620
+ console.log('');
621
+ const tunnelMode = await chooseTunnelMode();
622
+ console.log('');
623
+
624
+ // Update config with chosen mode
625
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
626
+
627
+ // Handle named tunnel setup before starting
628
+ if (tunnelMode === 'named') {
629
+ const setup = await runNamedTunnelSetup();
630
+ config.tunnel = {
631
+ mode: 'named',
632
+ name: setup.tunnelName,
633
+ domain: setup.domain,
634
+ configPath: setup.cfConfigPath,
635
+ };
636
+ } else {
637
+ config.tunnel = { mode: tunnelMode };
638
+ }
639
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
640
+
444
641
  const isLinux = os.platform() === 'linux';
445
642
  const hasSystemd = isLinux && (() => { try { execSync('systemctl --version', { stdio: 'ignore' }); return true; } catch { return false; } })();
643
+ const hasTunnel = tunnelMode !== 'off';
446
644
 
447
645
  const steps = [
448
646
  'Creating config',
449
- 'Installing cloudflared',
647
+ ...(hasTunnel ? ['Installing cloudflared'] : []),
450
648
  'Starting server',
451
- 'Connecting tunnel',
452
- 'Verifying connection',
649
+ ...(hasTunnel ? ['Connecting tunnel', 'Verifying connection'] : []),
453
650
  'Preparing dashboard',
454
651
  ...(hasSystemd ? ['Setting up auto-start daemon'] : []),
455
652
  ];
@@ -460,17 +657,21 @@ async function init() {
460
657
  // Config already created
461
658
  stepper.advance();
462
659
 
463
- // Cloudflared
464
- await installCloudflared();
465
- stepper.advance();
660
+ // Cloudflared (skip for named — already installed during setup)
661
+ if (hasTunnel && tunnelMode !== 'named') {
662
+ await installCloudflared();
663
+ stepper.advance();
664
+ } else if (hasTunnel) {
665
+ stepper.advance(); // named: already installed
666
+ }
466
667
 
467
668
  // Server + Tunnel
468
669
  stepper.advance();
469
- const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
470
670
  let result;
471
671
  try {
472
672
  result = await bootServer({
473
673
  onTunnelUp: (url) => {
674
+ if (!hasTunnel) return;
474
675
  stepper.advance(); // Connecting tunnel done
475
676
  // Show the direct URL while waiting for the custom domain to become reachable
476
677
  if (config.relay?.url) {
@@ -481,6 +682,7 @@ async function init() {
481
682
  }
482
683
  },
483
684
  onReady: () => {
685
+ if (!hasTunnel) return;
484
686
  stepper.setInfo([]);
485
687
  stepper.advance(); // Verifying connection done
486
688
  },
@@ -557,11 +759,14 @@ async function start() {
557
759
 
558
760
  banner();
559
761
 
762
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
763
+ const tunnelMode = config.tunnel?.mode ?? (config.tunnel?.enabled === false ? 'off' : 'quick');
764
+ const hasTunnel = tunnelMode !== 'off';
765
+
560
766
  const steps = [
561
767
  'Loading config',
562
768
  'Starting server',
563
- 'Connecting tunnel',
564
- 'Verifying connection',
769
+ ...(hasTunnel ? ['Connecting tunnel', 'Verifying connection'] : []),
565
770
  'Preparing dashboard',
566
771
  ...(needsDaemon ? ['Setting up auto-start daemon'] : []),
567
772
  ];
@@ -571,11 +776,11 @@ async function start() {
571
776
  stepper.advance(); // config exists
572
777
  stepper.advance(); // starting
573
778
 
574
- const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
575
779
  let result;
576
780
  try {
577
781
  result = await bootServer({
578
782
  onTunnelUp: (url) => {
783
+ if (!hasTunnel) return;
579
784
  stepper.advance(); // Connecting tunnel done
580
785
  if (config.relay?.url) {
581
786
  stepper.setInfo([
@@ -585,6 +790,7 @@ async function start() {
585
790
  }
586
791
  },
587
792
  onReady: () => {
793
+ if (!hasTunnel) return;
588
794
  stepper.setInfo([]);
589
795
  stepper.advance(); // Verifying connection done
590
796
  },
@@ -654,8 +860,11 @@ async function status() {
654
860
  if (healthOk) {
655
861
  console.log(`\n ${c.blue}●${c.reset} Fluxy is running${daemonRunning ? ` ${c.dim}(daemon)${c.reset}` : ''}`);
656
862
  if (uptime != null) console.log(` ${c.dim}Uptime: ${uptime}s${c.reset}`);
863
+ if (config?.tunnelUrl) {
864
+ console.log(` ${c.dim}Tunnel: ${c.reset}${c.blue}${link(config.tunnelUrl)}${c.reset}`);
865
+ }
657
866
  if (config?.relay?.url) {
658
- console.log(` ${c.dim}URL: ${c.reset}${c.pink}${link(config.relay.url)}${c.reset}`);
867
+ console.log(` ${c.dim}Relay: ${c.reset}${c.pink}${link(config.relay.url)}${c.reset}`);
659
868
  }
660
869
  console.log(` ${c.dim}Config: ${CONFIG_PATH}${c.reset}\n`);
661
870
  } else if (daemonRunning) {
@@ -975,6 +1184,98 @@ async function daemon(sub) {
975
1184
  }
976
1185
  }
977
1186
 
1187
+ // ── Tunnel management ──
1188
+
1189
+ function ask(question) {
1190
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1191
+ return new Promise((resolve) => rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); }));
1192
+ }
1193
+
1194
+ async function tunnel(sub) {
1195
+ const action = sub || 'status';
1196
+
1197
+ switch (action) {
1198
+ case 'setup': {
1199
+ banner();
1200
+ console.log(`\n ${c.bold}${c.white}Named Tunnel Setup${c.reset}`);
1201
+
1202
+ const setup = await runNamedTunnelSetup();
1203
+
1204
+ // Update fluxy config
1205
+ const config = fs.existsSync(CONFIG_PATH) ? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) : {};
1206
+ config.tunnel = {
1207
+ mode: 'named',
1208
+ name: setup.tunnelName,
1209
+ domain: setup.domain,
1210
+ configPath: setup.cfConfigPath,
1211
+ };
1212
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
1213
+ console.log(` ${c.blue}✔${c.reset} Fluxy config updated\n`);
1214
+
1215
+ // Offer restart if daemon is running
1216
+ if (os.platform() === 'linux' && isServiceInstalled() && isServiceActive()) {
1217
+ const restart = await ask(` ${c.bold}Restart daemon now?${c.reset} ${c.dim}(Y/n)${c.reset}: `);
1218
+ if (!restart || restart.toLowerCase() === 'y') {
1219
+ try {
1220
+ const cmd = needsSudo() ? `sudo systemctl restart ${SERVICE_NAME}` : `systemctl restart ${SERVICE_NAME}`;
1221
+ execSync(cmd, { stdio: 'ignore' });
1222
+ console.log(`\n ${c.blue}✔${c.reset} Daemon restarted.\n`);
1223
+ } catch {
1224
+ console.log(`\n ${c.yellow}⚠${c.reset} Restart failed. Try ${c.pink}fluxy daemon restart${c.reset}\n`);
1225
+ }
1226
+ }
1227
+ } else {
1228
+ console.log(` ${c.dim}Run ${c.reset}${c.pink}fluxy start${c.reset}${c.dim} to launch with the named tunnel.${c.reset}\n`);
1229
+ }
1230
+ break;
1231
+ }
1232
+
1233
+ case 'status': {
1234
+ if (!fs.existsSync(CONFIG_PATH)) {
1235
+ console.log(`\n ${c.dim}No config found. Run ${c.reset}${c.pink}fluxy init${c.reset}${c.dim} first.${c.reset}\n`);
1236
+ return;
1237
+ }
1238
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
1239
+ const mode = config.tunnel?.mode ?? (config.tunnel?.enabled === false ? 'off' : 'quick');
1240
+
1241
+ console.log(`\n ${c.bold}${c.white}Tunnel Configuration${c.reset}\n`);
1242
+ console.log(` ${c.dim}Mode:${c.reset} ${c.bold}${mode}${c.reset}`);
1243
+ if (mode === 'named') {
1244
+ if (config.tunnel?.name) console.log(` ${c.dim}Name:${c.reset} ${config.tunnel.name}`);
1245
+ if (config.tunnel?.domain) console.log(` ${c.dim}Domain:${c.reset} ${c.pink}${config.tunnel.domain}${c.reset}`);
1246
+ if (config.tunnel?.configPath) console.log(` ${c.dim}Config:${c.reset} ${config.tunnel.configPath}`);
1247
+ }
1248
+ if (config.tunnelUrl) {
1249
+ console.log(` ${c.dim}URL:${c.reset} ${c.blue}${link(config.tunnelUrl)}${c.reset}`);
1250
+ }
1251
+ console.log('');
1252
+ break;
1253
+ }
1254
+
1255
+ case 'reset': {
1256
+ if (!fs.existsSync(CONFIG_PATH)) {
1257
+ console.log(`\n ${c.dim}No config found. Run ${c.reset}${c.pink}fluxy init${c.reset}${c.dim} first.${c.reset}\n`);
1258
+ return;
1259
+ }
1260
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
1261
+ config.tunnel = { mode: 'quick' };
1262
+ delete config.tunnelUrl;
1263
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
1264
+ console.log(`\n ${c.blue}✔${c.reset} Tunnel mode reset to ${c.bold}quick${c.reset} (random trycloudflare.com URL).\n`);
1265
+
1266
+ if (os.platform() === 'linux' && isServiceInstalled() && isServiceActive()) {
1267
+ console.log(` ${c.dim}Restart the daemon to apply: ${c.reset}${c.pink}fluxy daemon restart${c.reset}\n`);
1268
+ }
1269
+ break;
1270
+ }
1271
+
1272
+ default:
1273
+ console.log(`\n ${c.red}✗${c.reset} Unknown tunnel command: ${action}`);
1274
+ console.log(` ${c.dim}Available: setup, status, reset${c.reset}\n`);
1275
+ process.exit(1);
1276
+ }
1277
+ }
1278
+
978
1279
  // ── Route ──
979
1280
 
980
1281
  switch (command) {
@@ -983,6 +1284,7 @@ switch (command) {
983
1284
  case 'status': status(); break;
984
1285
  case 'update': update(); break;
985
1286
  case 'daemon': daemon(subcommand); break;
1287
+ case 'tunnel': tunnel(subcommand); break;
986
1288
  default:
987
1289
  fs.existsSync(CONFIG_PATH) ? start() : init();
988
1290
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fluxy-bot",
3
- "version": "0.5.57",
3
+ "version": "0.5.59",
4
4
  "releaseNotes": [
5
5
  "Fixed some bugs to iOs ",
6
6
  "2. ",
package/shared/config.ts CHANGED
@@ -10,7 +10,12 @@ export interface BotConfig {
10
10
  apiKey: string;
11
11
  baseUrl?: string;
12
12
  };
13
- tunnel: { enabled: boolean };
13
+ tunnel: {
14
+ mode: 'off' | 'quick' | 'named';
15
+ name?: string;
16
+ domain?: string;
17
+ configPath?: string;
18
+ };
14
19
  relay: {
15
20
  token: string;
16
21
  tier: string;
@@ -23,13 +28,21 @@ const DEFAULTS: BotConfig = {
23
28
  port: 3000,
24
29
  username: '',
25
30
  ai: { provider: '', model: '', apiKey: '' },
26
- tunnel: { enabled: true },
31
+ tunnel: { mode: 'quick' },
27
32
  relay: { token: '', tier: '', url: '' },
28
33
  };
29
34
 
30
35
  export function loadConfig(): BotConfig {
31
36
  if (!fs.existsSync(paths.config)) throw new Error('No config. Run `fluxy init`.');
32
- return JSON.parse(fs.readFileSync(paths.config, 'utf-8'));
37
+ const config = JSON.parse(fs.readFileSync(paths.config, 'utf-8'));
38
+
39
+ // Backward compat: migrate old { enabled: boolean } → { mode }
40
+ if ('enabled' in config.tunnel && !('mode' in config.tunnel)) {
41
+ config.tunnel = { mode: config.tunnel.enabled ? 'quick' : 'off' };
42
+ saveConfig(config);
43
+ }
44
+
45
+ return config;
33
46
  }
34
47
 
35
48
  export function saveConfig(config: BotConfig): void {
@@ -8,7 +8,7 @@ import { createProvider, type AiProvider, type ChatMessage } from '../shared/ai.
8
8
  import { paths } from '../shared/paths.js';
9
9
  import { PKG_DIR } from '../shared/paths.js';
10
10
  import { log } from '../shared/logger.js';
11
- import { startTunnel, stopTunnel, isTunnelAlive, restartTunnel } from './tunnel.js';
11
+ import { startTunnel, stopTunnel, isTunnelAlive, restartTunnel, startNamedTunnel, restartNamedTunnel } from './tunnel.js';
12
12
  import { spawnWorker, stopWorker, getWorkerPort, isWorkerAlive } from './worker.js';
13
13
  import { spawnBackend, stopBackend, getBackendPort, isBackendAlive, resetBackendRestarts } from './backend.js';
14
14
  import { updateTunnelUrl, startHeartbeat, stopHeartbeat, disconnect } from '../shared/relay.js';
@@ -663,6 +663,9 @@ export async function startSupervisor() {
663
663
  server.listen(config.port, () => {
664
664
  log.ok(`Supervisor on http://localhost:${config.port}`);
665
665
  log.ok(`Fluxy chat at http://localhost:${config.port}/fluxy`);
666
+ if (config.tunnel.mode === 'off') {
667
+ console.log('__READY__');
668
+ }
666
669
  });
667
670
 
668
671
  // Track whether an agent query is active — file watcher defers to bot:done during turns
@@ -759,7 +762,8 @@ export async function startSupervisor() {
759
762
 
760
763
  // Tunnel
761
764
  let tunnelUrl: string | null = null;
762
- if (config.tunnel.enabled) {
765
+
766
+ if (config.tunnel.mode === 'quick') {
763
767
  try {
764
768
  tunnelUrl = await startTunnel(config.port);
765
769
  log.ok(`Tunnel: ${tunnelUrl}`);
@@ -770,8 +774,6 @@ export async function startSupervisor() {
770
774
  saveConfig(config);
771
775
 
772
776
  // Wait for the tunnel to be reachable before telling the relay about it.
773
- // This prevents the relay from proxying traffic to a tunnel that isn't ready,
774
- // which would cause 502s for users on the custom domain.
775
777
  let tunnelReady = false;
776
778
  log.info(`Readiness probe: waiting for tunnel ${tunnelUrl}`);
777
779
  for (let i = 0; i < 30; i++) {
@@ -814,9 +816,49 @@ export async function startSupervisor() {
814
816
  }
815
817
  }
816
818
 
819
+ if (config.tunnel.mode === 'named') {
820
+ try {
821
+ await startNamedTunnel(config.tunnel.configPath!, config.tunnel.name!);
822
+ tunnelUrl = `https://${config.tunnel.domain}`;
823
+ log.ok(`Named tunnel: ${tunnelUrl}`);
824
+ console.log(`__TUNNEL_URL__=${tunnelUrl}`);
825
+
826
+ config.tunnelUrl = tunnelUrl;
827
+ saveConfig(config);
828
+
829
+ // Readiness probe against the user's domain
830
+ let tunnelReady = false;
831
+ log.info(`Readiness probe: waiting for tunnel ${tunnelUrl}`);
832
+ for (let i = 0; i < 30; i++) {
833
+ try {
834
+ const res = await fetch(tunnelUrl + `/api/health?_cb=${Date.now()}`, {
835
+ signal: AbortSignal.timeout(3000),
836
+ headers: { 'Cache-Control': 'no-cache, no-store' },
837
+ });
838
+ log.info(`Readiness probe #${i + 1}: ${res.status}`);
839
+ if (res.status !== 502 && res.status !== 503) {
840
+ tunnelReady = true;
841
+ break;
842
+ }
843
+ } catch (err) {
844
+ log.info(`Readiness probe #${i + 1}: ${err instanceof Error ? err.message : 'error'}`);
845
+ }
846
+ await new Promise(r => setTimeout(r, 1000));
847
+ }
848
+ if (!tunnelReady) {
849
+ log.warn('Named tunnel readiness probe timed out');
850
+ }
851
+
852
+ console.log('__READY__');
853
+ } catch (err) {
854
+ log.warn(`Named tunnel: ${err instanceof Error ? err.message : err}`);
855
+ console.log('__TUNNEL_FAILED__');
856
+ }
857
+ }
858
+
817
859
  // Tunnel watchdog — detects sleep/wake + periodic health checks
818
860
  let watchdogInterval: ReturnType<typeof setInterval> | null = null;
819
- if (tunnelUrl && config.relay?.token) {
861
+ if (tunnelUrl) {
820
862
  let lastTick = Date.now();
821
863
  let healthCounter = 0;
822
864
 
@@ -830,36 +872,46 @@ export async function startSupervisor() {
830
872
  if (!alive) {
831
873
  log.warn('Tunnel dead, restarting...');
832
874
  try {
833
- const newUrl = await restartTunnel(config.port);
834
-
835
- // Wait for the new tunnel to be reachable before updating the relay
836
- log.info(`Waiting for new tunnel to become reachable: ${newUrl}`);
837
- let tunnelReady = false;
838
- for (let i = 0; i < 30; i++) {
839
- try {
840
- const res = await fetch(newUrl + `/api/health?_cb=${Date.now()}`, {
841
- signal: AbortSignal.timeout(3000),
842
- headers: { 'Cache-Control': 'no-cache, no-store' },
843
- });
844
- if (res.status !== 502 && res.status !== 503) {
845
- tunnelReady = true;
846
- log.info(`Tunnel reachable after ${i + 1} probes`);
847
- break;
848
- }
849
- } catch {}
850
- await new Promise(r => setTimeout(r, 1000));
851
- }
852
- if (!tunnelReady) {
853
- log.warn('Tunnel readiness probe timed out — updating relay anyway');
854
- }
875
+ if (config.tunnel.mode === 'named') {
876
+ // Named tunnel: restart process, URL doesn't change
877
+ await restartNamedTunnel(config.tunnel.configPath!, config.tunnel.name!);
878
+ log.ok(`Named tunnel restored: ${tunnelUrl}`);
879
+ } else {
880
+ // Quick tunnel: restart and get new URL
881
+ const newUrl = await restartTunnel(config.port);
882
+
883
+ // Wait for the new tunnel to be reachable before updating the relay
884
+ log.info(`Waiting for new tunnel to become reachable: ${newUrl}`);
885
+ let tunnelReady = false;
886
+ for (let i = 0; i < 30; i++) {
887
+ try {
888
+ const res = await fetch(newUrl + `/api/health?_cb=${Date.now()}`, {
889
+ signal: AbortSignal.timeout(3000),
890
+ headers: { 'Cache-Control': 'no-cache, no-store' },
891
+ });
892
+ if (res.status !== 502 && res.status !== 503) {
893
+ tunnelReady = true;
894
+ log.info(`Tunnel reachable after ${i + 1} probes`);
895
+ break;
896
+ }
897
+ } catch {}
898
+ await new Promise(r => setTimeout(r, 1000));
899
+ }
900
+ if (!tunnelReady) {
901
+ log.warn('Tunnel readiness probe timed out — updating relay anyway');
902
+ }
903
+
904
+ tunnelUrl = newUrl;
905
+ config.tunnelUrl = newUrl;
906
+ saveConfig(config);
855
907
 
856
- tunnelUrl = newUrl;
857
- config.tunnelUrl = newUrl;
858
- saveConfig(config);
859
- stopHeartbeat();
860
- startHeartbeat(config.relay!.token!, newUrl);
861
- await updateTunnelUrl(config.relay!.token!, newUrl);
862
- log.ok(`Tunnel restored: ${newUrl}`);
908
+ if (config.relay?.token) {
909
+ stopHeartbeat();
910
+ startHeartbeat(config.relay.token, newUrl);
911
+ await updateTunnelUrl(config.relay.token, newUrl);
912
+ }
913
+ log.ok(`Tunnel restored: ${newUrl}`);
914
+ }
863
915
  } catch (err) {
864
916
  log.error(`Tunnel restart failed: ${err instanceof Error ? err.message : err}`);
865
917
  }
@@ -81,6 +81,36 @@ export function startTunnel(port: number): Promise<string> {
81
81
  });
82
82
  }
83
83
 
84
+ export async function startNamedTunnel(configPath: string, name: string): Promise<void> {
85
+ const bin = await installCloudflared();
86
+
87
+ proc = spawn(bin, ['tunnel', '--config', configPath, 'run', name], {
88
+ stdio: ['ignore', 'pipe', 'pipe'],
89
+ windowsHide: true,
90
+ });
91
+
92
+ proc.stdout?.on('data', (d: Buffer) => log.info(`[named-tunnel] ${d.toString().trim()}`));
93
+ proc.stderr?.on('data', (d: Buffer) => log.info(`[named-tunnel] ${d.toString().trim()}`));
94
+ proc.on('error', (e) => log.error(`Named tunnel error: ${e.message}`));
95
+ proc.on('exit', (code) => { if (code !== 0) log.warn(`Named tunnel exited with code ${code}`); });
96
+ }
97
+
98
+ export async function startTunnelForMode(
99
+ mode: 'off' | 'quick' | 'named',
100
+ port: number,
101
+ configPath?: string,
102
+ name?: string,
103
+ ): Promise<string | null> {
104
+ if (mode === 'off') return null;
105
+ if (mode === 'named') {
106
+ if (!configPath || !name) throw new Error('Named tunnel requires configPath and name');
107
+ await startNamedTunnel(configPath, name);
108
+ return null; // URL is known from config.tunnel.domain
109
+ }
110
+ // mode === 'quick'
111
+ return startTunnel(port);
112
+ }
113
+
84
114
  export function stopTunnel(): void {
85
115
  proc?.kill();
86
116
  proc = null;
@@ -99,3 +129,8 @@ export async function restartTunnel(port: number): Promise<string> {
99
129
  stopTunnel();
100
130
  return startTunnel(port);
101
131
  }
132
+
133
+ export async function restartNamedTunnel(configPath: string, name: string): Promise<void> {
134
+ stopTunnel();
135
+ await startNamedTunnel(configPath, name);
136
+ }