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 +208 -38
- package/bin/cli.js +315 -13
- package/package.json +1 -1
- package/shared/config.ts +16 -3
- package/supervisor/index.ts +86 -34
- package/supervisor/tunnel.ts +35 -0
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
|
|
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,
|
|
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,
|
|
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/
|
|
93
|
-
- `/api/
|
|
94
|
-
- `/api/
|
|
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/
|
|
107
|
-
src/App.tsx
|
|
108
|
-
index.html
|
|
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
|
|
111
|
-
.env
|
|
112
|
-
app.db
|
|
113
|
-
|
|
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:
|
|
143
|
-
- `fluxy:
|
|
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,
|
|
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,
|
|
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/
|
|
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/
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
465
|
-
|
|
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}
|
|
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
package/shared/config.ts
CHANGED
|
@@ -10,7 +10,12 @@ export interface BotConfig {
|
|
|
10
10
|
apiKey: string;
|
|
11
11
|
baseUrl?: string;
|
|
12
12
|
};
|
|
13
|
-
tunnel: {
|
|
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: {
|
|
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
|
-
|
|
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 {
|
package/supervisor/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
}
|
package/supervisor/tunnel.ts
CHANGED
|
@@ -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
|
+
}
|