fluxy-bot 0.5.56 → 0.5.58
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 +194 -12
- package/package.json +1 -1
- package/shared/config.ts +16 -3
- package/supervisor/index.ts +89 -36
- 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));
|
|
@@ -239,7 +240,7 @@ function finalMessage(tunnelUrl, relayUrl) {
|
|
|
239
240
|
${c.blue}${c.bold}${link(tunnelUrl)}${c.reset}
|
|
240
241
|
${c.dim}(cmd+click or ctrl+click to open)${c.reset}`);
|
|
241
242
|
|
|
242
|
-
if (relayUrl) {
|
|
243
|
+
if (relayUrl && relayUrl !== tunnelUrl) {
|
|
243
244
|
console.log(`
|
|
244
245
|
${c.bold}${c.white}Your permanent URL:${c.reset}
|
|
245
246
|
|
|
@@ -283,7 +284,7 @@ function createConfig() {
|
|
|
283
284
|
port: 3000,
|
|
284
285
|
username: '',
|
|
285
286
|
ai: { provider: '', model: '', apiKey: '' },
|
|
286
|
-
tunnel: {
|
|
287
|
+
tunnel: { mode: 'quick' },
|
|
287
288
|
relay: { token: '', tier: '', url: '' },
|
|
288
289
|
};
|
|
289
290
|
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
@@ -444,12 +445,15 @@ async function init() {
|
|
|
444
445
|
const isLinux = os.platform() === 'linux';
|
|
445
446
|
const hasSystemd = isLinux && (() => { try { execSync('systemctl --version', { stdio: 'ignore' }); return true; } catch { return false; } })();
|
|
446
447
|
|
|
448
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
449
|
+
const tunnelMode = config.tunnel?.mode || 'quick';
|
|
450
|
+
const hasTunnel = tunnelMode !== 'off';
|
|
451
|
+
|
|
447
452
|
const steps = [
|
|
448
453
|
'Creating config',
|
|
449
|
-
'Installing cloudflared',
|
|
454
|
+
...(hasTunnel ? ['Installing cloudflared'] : []),
|
|
450
455
|
'Starting server',
|
|
451
|
-
'Connecting tunnel',
|
|
452
|
-
'Verifying connection',
|
|
456
|
+
...(hasTunnel ? ['Connecting tunnel', 'Verifying connection'] : []),
|
|
453
457
|
'Preparing dashboard',
|
|
454
458
|
...(hasSystemd ? ['Setting up auto-start daemon'] : []),
|
|
455
459
|
];
|
|
@@ -461,16 +465,18 @@ async function init() {
|
|
|
461
465
|
stepper.advance();
|
|
462
466
|
|
|
463
467
|
// Cloudflared
|
|
464
|
-
|
|
465
|
-
|
|
468
|
+
if (hasTunnel) {
|
|
469
|
+
await installCloudflared();
|
|
470
|
+
stepper.advance();
|
|
471
|
+
}
|
|
466
472
|
|
|
467
473
|
// Server + Tunnel
|
|
468
474
|
stepper.advance();
|
|
469
|
-
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
470
475
|
let result;
|
|
471
476
|
try {
|
|
472
477
|
result = await bootServer({
|
|
473
478
|
onTunnelUp: (url) => {
|
|
479
|
+
if (!hasTunnel) return;
|
|
474
480
|
stepper.advance(); // Connecting tunnel done
|
|
475
481
|
// Show the direct URL while waiting for the custom domain to become reachable
|
|
476
482
|
if (config.relay?.url) {
|
|
@@ -481,6 +487,7 @@ async function init() {
|
|
|
481
487
|
}
|
|
482
488
|
},
|
|
483
489
|
onReady: () => {
|
|
490
|
+
if (!hasTunnel) return;
|
|
484
491
|
stepper.setInfo([]);
|
|
485
492
|
stepper.advance(); // Verifying connection done
|
|
486
493
|
},
|
|
@@ -557,11 +564,14 @@ async function start() {
|
|
|
557
564
|
|
|
558
565
|
banner();
|
|
559
566
|
|
|
567
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
568
|
+
const tunnelMode = config.tunnel?.mode ?? (config.tunnel?.enabled === false ? 'off' : 'quick');
|
|
569
|
+
const hasTunnel = tunnelMode !== 'off';
|
|
570
|
+
|
|
560
571
|
const steps = [
|
|
561
572
|
'Loading config',
|
|
562
573
|
'Starting server',
|
|
563
|
-
'Connecting tunnel',
|
|
564
|
-
'Verifying connection',
|
|
574
|
+
...(hasTunnel ? ['Connecting tunnel', 'Verifying connection'] : []),
|
|
565
575
|
'Preparing dashboard',
|
|
566
576
|
...(needsDaemon ? ['Setting up auto-start daemon'] : []),
|
|
567
577
|
];
|
|
@@ -571,11 +581,11 @@ async function start() {
|
|
|
571
581
|
stepper.advance(); // config exists
|
|
572
582
|
stepper.advance(); // starting
|
|
573
583
|
|
|
574
|
-
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
575
584
|
let result;
|
|
576
585
|
try {
|
|
577
586
|
result = await bootServer({
|
|
578
587
|
onTunnelUp: (url) => {
|
|
588
|
+
if (!hasTunnel) return;
|
|
579
589
|
stepper.advance(); // Connecting tunnel done
|
|
580
590
|
if (config.relay?.url) {
|
|
581
591
|
stepper.setInfo([
|
|
@@ -585,6 +595,7 @@ async function start() {
|
|
|
585
595
|
}
|
|
586
596
|
},
|
|
587
597
|
onReady: () => {
|
|
598
|
+
if (!hasTunnel) return;
|
|
588
599
|
stepper.setInfo([]);
|
|
589
600
|
stepper.advance(); // Verifying connection done
|
|
590
601
|
},
|
|
@@ -654,8 +665,11 @@ async function status() {
|
|
|
654
665
|
if (healthOk) {
|
|
655
666
|
console.log(`\n ${c.blue}●${c.reset} Fluxy is running${daemonRunning ? ` ${c.dim}(daemon)${c.reset}` : ''}`);
|
|
656
667
|
if (uptime != null) console.log(` ${c.dim}Uptime: ${uptime}s${c.reset}`);
|
|
668
|
+
if (config?.tunnelUrl) {
|
|
669
|
+
console.log(` ${c.dim}Tunnel: ${c.reset}${c.blue}${link(config.tunnelUrl)}${c.reset}`);
|
|
670
|
+
}
|
|
657
671
|
if (config?.relay?.url) {
|
|
658
|
-
console.log(` ${c.dim}
|
|
672
|
+
console.log(` ${c.dim}Relay: ${c.reset}${c.pink}${link(config.relay.url)}${c.reset}`);
|
|
659
673
|
}
|
|
660
674
|
console.log(` ${c.dim}Config: ${CONFIG_PATH}${c.reset}\n`);
|
|
661
675
|
} else if (daemonRunning) {
|
|
@@ -975,6 +989,173 @@ async function daemon(sub) {
|
|
|
975
989
|
}
|
|
976
990
|
}
|
|
977
991
|
|
|
992
|
+
// ── Tunnel management ──
|
|
993
|
+
|
|
994
|
+
function ask(question) {
|
|
995
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
996
|
+
return new Promise((resolve) => rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); }));
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
async function tunnel(sub) {
|
|
1000
|
+
const action = sub || 'status';
|
|
1001
|
+
|
|
1002
|
+
switch (action) {
|
|
1003
|
+
case 'setup': {
|
|
1004
|
+
banner();
|
|
1005
|
+
console.log(`\n ${c.bold}${c.white}Named Tunnel Setup${c.reset}\n`);
|
|
1006
|
+
|
|
1007
|
+
// Step 1: Ensure cloudflared is installed
|
|
1008
|
+
console.log(` ${c.blue}⠋${c.reset} Checking cloudflared...`);
|
|
1009
|
+
await installCloudflared();
|
|
1010
|
+
console.log(` ${c.blue}✔${c.reset} cloudflared ready\n`);
|
|
1011
|
+
|
|
1012
|
+
// Step 2: Login to Cloudflare
|
|
1013
|
+
console.log(` ${c.bold}${c.white}Step 1:${c.reset} Log in to Cloudflare\n`);
|
|
1014
|
+
console.log(` ${c.dim}This will open a browser window. Authorize the domain you want to use.${c.reset}\n`);
|
|
1015
|
+
try {
|
|
1016
|
+
spawnSync('cloudflared', ['tunnel', 'login'], { stdio: 'inherit' });
|
|
1017
|
+
} catch {
|
|
1018
|
+
// Try local binary
|
|
1019
|
+
if (fs.existsSync(CF_PATH)) {
|
|
1020
|
+
spawnSync(CF_PATH, ['tunnel', 'login'], { stdio: 'inherit' });
|
|
1021
|
+
} else {
|
|
1022
|
+
console.log(`\n ${c.red}✗${c.reset} cloudflared login failed.\n`);
|
|
1023
|
+
process.exit(1);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
console.log('');
|
|
1027
|
+
|
|
1028
|
+
// Step 3: Ask for tunnel name
|
|
1029
|
+
const tunnelName = (await ask(` ${c.bold}Tunnel name${c.reset} ${c.dim}(default: fluxy)${c.reset}: `)) || 'fluxy';
|
|
1030
|
+
|
|
1031
|
+
// Step 4: Create tunnel
|
|
1032
|
+
console.log(`\n ${c.blue}⠋${c.reset} Creating tunnel "${tunnelName}"...`);
|
|
1033
|
+
let createOutput;
|
|
1034
|
+
try {
|
|
1035
|
+
createOutput = execSync(`cloudflared tunnel create ${tunnelName}`, { encoding: 'utf-8' });
|
|
1036
|
+
} catch {
|
|
1037
|
+
try {
|
|
1038
|
+
createOutput = execSync(`${CF_PATH} tunnel create ${tunnelName}`, { encoding: 'utf-8' });
|
|
1039
|
+
} catch (err) {
|
|
1040
|
+
console.log(`\n ${c.red}✗${c.reset} Failed to create tunnel. It may already exist.`);
|
|
1041
|
+
console.log(` ${c.dim}Try: cloudflared tunnel list${c.reset}\n`);
|
|
1042
|
+
process.exit(1);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Parse UUID from output (format: "Created tunnel <name> with id <uuid>")
|
|
1047
|
+
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);
|
|
1048
|
+
if (!uuidMatch) {
|
|
1049
|
+
console.log(`\n ${c.red}✗${c.reset} Could not parse tunnel UUID from output.`);
|
|
1050
|
+
console.log(` ${c.dim}${createOutput}${c.reset}\n`);
|
|
1051
|
+
process.exit(1);
|
|
1052
|
+
}
|
|
1053
|
+
const tunnelUuid = uuidMatch[1];
|
|
1054
|
+
console.log(` ${c.blue}✔${c.reset} Tunnel created: ${c.dim}${tunnelUuid}${c.reset}\n`);
|
|
1055
|
+
|
|
1056
|
+
// Step 5: Ask for domain
|
|
1057
|
+
const domain = await ask(` ${c.bold}Your domain${c.reset} ${c.dim}(e.g. bot.mydomain.com)${c.reset}: `);
|
|
1058
|
+
if (!domain) {
|
|
1059
|
+
console.log(`\n ${c.red}✗${c.reset} Domain is required for named tunnels.\n`);
|
|
1060
|
+
process.exit(1);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// Step 6: Generate cloudflared config YAML
|
|
1064
|
+
const config = fs.existsSync(CONFIG_PATH) ? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) : {};
|
|
1065
|
+
const port = config.port || 3000;
|
|
1066
|
+
const cfHome = path.join(os.homedir(), '.cloudflared');
|
|
1067
|
+
const cfConfigPath = path.join(DATA_DIR, 'cloudflared-config.yml');
|
|
1068
|
+
|
|
1069
|
+
const yamlContent = `tunnel: ${tunnelUuid}
|
|
1070
|
+
credentials-file: ${path.join(cfHome, `${tunnelUuid}.json`)}
|
|
1071
|
+
ingress:
|
|
1072
|
+
- service: http://localhost:${port}
|
|
1073
|
+
`;
|
|
1074
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
1075
|
+
fs.writeFileSync(cfConfigPath, yamlContent);
|
|
1076
|
+
console.log(`\n ${c.blue}✔${c.reset} Config written to ${c.dim}${cfConfigPath}${c.reset}`);
|
|
1077
|
+
|
|
1078
|
+
// Step 7: Update fluxy config
|
|
1079
|
+
config.tunnel = {
|
|
1080
|
+
mode: 'named',
|
|
1081
|
+
name: tunnelName,
|
|
1082
|
+
domain: domain,
|
|
1083
|
+
configPath: cfConfigPath,
|
|
1084
|
+
};
|
|
1085
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
1086
|
+
console.log(` ${c.blue}✔${c.reset} Fluxy config updated\n`);
|
|
1087
|
+
|
|
1088
|
+
// Step 8: Print DNS instructions
|
|
1089
|
+
console.log(` ${c.dim}─────────────────────────────────${c.reset}\n`);
|
|
1090
|
+
console.log(` ${c.bold}${c.white}Tunnel created!${c.reset}\n`);
|
|
1091
|
+
console.log(` ${c.white}To connect your domain, add a CNAME record pointing to:${c.reset}\n`);
|
|
1092
|
+
console.log(` ${c.pink}${c.bold}${tunnelUuid}.cfargotunnel.com${c.reset}\n`);
|
|
1093
|
+
console.log(` ${c.dim}Or run: cloudflared tunnel route dns ${tunnelName} ${domain}${c.reset}\n`);
|
|
1094
|
+
|
|
1095
|
+
// Step 9: Offer restart if daemon is running
|
|
1096
|
+
if (os.platform() === 'linux' && isServiceInstalled() && isServiceActive()) {
|
|
1097
|
+
const restart = await ask(` ${c.bold}Restart daemon now?${c.reset} ${c.dim}(Y/n)${c.reset}: `);
|
|
1098
|
+
if (!restart || restart.toLowerCase() === 'y') {
|
|
1099
|
+
try {
|
|
1100
|
+
const cmd = needsSudo() ? `sudo systemctl restart ${SERVICE_NAME}` : `systemctl restart ${SERVICE_NAME}`;
|
|
1101
|
+
execSync(cmd, { stdio: 'ignore' });
|
|
1102
|
+
console.log(`\n ${c.blue}✔${c.reset} Daemon restarted.\n`);
|
|
1103
|
+
} catch {
|
|
1104
|
+
console.log(`\n ${c.yellow}⚠${c.reset} Restart failed. Try ${c.pink}fluxy daemon restart${c.reset}\n`);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
} else {
|
|
1108
|
+
console.log(` ${c.dim}Run ${c.reset}${c.pink}fluxy start${c.reset}${c.dim} to launch with the named tunnel.${c.reset}\n`);
|
|
1109
|
+
}
|
|
1110
|
+
break;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
case 'status': {
|
|
1114
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
1115
|
+
console.log(`\n ${c.dim}No config found. Run ${c.reset}${c.pink}fluxy init${c.reset}${c.dim} first.${c.reset}\n`);
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
1119
|
+
const mode = config.tunnel?.mode ?? (config.tunnel?.enabled === false ? 'off' : 'quick');
|
|
1120
|
+
|
|
1121
|
+
console.log(`\n ${c.bold}${c.white}Tunnel Configuration${c.reset}\n`);
|
|
1122
|
+
console.log(` ${c.dim}Mode:${c.reset} ${c.bold}${mode}${c.reset}`);
|
|
1123
|
+
if (mode === 'named') {
|
|
1124
|
+
if (config.tunnel?.name) console.log(` ${c.dim}Name:${c.reset} ${config.tunnel.name}`);
|
|
1125
|
+
if (config.tunnel?.domain) console.log(` ${c.dim}Domain:${c.reset} ${c.pink}${config.tunnel.domain}${c.reset}`);
|
|
1126
|
+
if (config.tunnel?.configPath) console.log(` ${c.dim}Config:${c.reset} ${config.tunnel.configPath}`);
|
|
1127
|
+
}
|
|
1128
|
+
if (config.tunnelUrl) {
|
|
1129
|
+
console.log(` ${c.dim}URL:${c.reset} ${c.blue}${link(config.tunnelUrl)}${c.reset}`);
|
|
1130
|
+
}
|
|
1131
|
+
console.log('');
|
|
1132
|
+
break;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
case 'reset': {
|
|
1136
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
1137
|
+
console.log(`\n ${c.dim}No config found. Run ${c.reset}${c.pink}fluxy init${c.reset}${c.dim} first.${c.reset}\n`);
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
1141
|
+
config.tunnel = { mode: 'quick' };
|
|
1142
|
+
delete config.tunnelUrl;
|
|
1143
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
1144
|
+
console.log(`\n ${c.blue}✔${c.reset} Tunnel mode reset to ${c.bold}quick${c.reset} (random trycloudflare.com URL).\n`);
|
|
1145
|
+
|
|
1146
|
+
if (os.platform() === 'linux' && isServiceInstalled() && isServiceActive()) {
|
|
1147
|
+
console.log(` ${c.dim}Restart the daemon to apply: ${c.reset}${c.pink}fluxy daemon restart${c.reset}\n`);
|
|
1148
|
+
}
|
|
1149
|
+
break;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
default:
|
|
1153
|
+
console.log(`\n ${c.red}✗${c.reset} Unknown tunnel command: ${action}`);
|
|
1154
|
+
console.log(` ${c.dim}Available: setup, status, reset${c.reset}\n`);
|
|
1155
|
+
process.exit(1);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
978
1159
|
// ── Route ──
|
|
979
1160
|
|
|
980
1161
|
switch (command) {
|
|
@@ -983,6 +1164,7 @@ switch (command) {
|
|
|
983
1164
|
case 'status': status(); break;
|
|
984
1165
|
case 'update': update(); break;
|
|
985
1166
|
case 'daemon': daemon(subcommand); break;
|
|
1167
|
+
case 'tunnel': tunnel(subcommand); break;
|
|
986
1168
|
default:
|
|
987
1169
|
fs.existsSync(CONFIG_PATH) ? start() : init();
|
|
988
1170
|
}
|
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
|
|
@@ -673,14 +676,15 @@ export async function startSupervisor() {
|
|
|
673
676
|
// Run fluxy update in a separate systemd scope so it survives daemon stop/restart
|
|
674
677
|
function runDeferredUpdate() {
|
|
675
678
|
const cliPath = path.join(PKG_DIR, 'bin', 'cli.js');
|
|
679
|
+
const user = os.userInfo().username;
|
|
676
680
|
log.info('Deferred update triggered — running fluxy update via systemd-run...');
|
|
677
681
|
try {
|
|
678
682
|
const child = cpSpawn('sudo', [
|
|
679
683
|
'systemd-run', '--quiet',
|
|
680
684
|
'--unit=fluxy-update',
|
|
685
|
+
'--uid=' + user,
|
|
681
686
|
'--setenv=HOME=' + os.homedir(),
|
|
682
|
-
'--setenv=
|
|
683
|
-
'--setenv=FLUXY_REAL_HOME=' + os.homedir(),
|
|
687
|
+
'--setenv=PATH=' + process.env.PATH,
|
|
684
688
|
process.execPath, cliPath, 'update',
|
|
685
689
|
], { detached: true, stdio: 'ignore' });
|
|
686
690
|
child.unref();
|
|
@@ -758,7 +762,8 @@ export async function startSupervisor() {
|
|
|
758
762
|
|
|
759
763
|
// Tunnel
|
|
760
764
|
let tunnelUrl: string | null = null;
|
|
761
|
-
|
|
765
|
+
|
|
766
|
+
if (config.tunnel.mode === 'quick') {
|
|
762
767
|
try {
|
|
763
768
|
tunnelUrl = await startTunnel(config.port);
|
|
764
769
|
log.ok(`Tunnel: ${tunnelUrl}`);
|
|
@@ -769,8 +774,6 @@ export async function startSupervisor() {
|
|
|
769
774
|
saveConfig(config);
|
|
770
775
|
|
|
771
776
|
// Wait for the tunnel to be reachable before telling the relay about it.
|
|
772
|
-
// This prevents the relay from proxying traffic to a tunnel that isn't ready,
|
|
773
|
-
// which would cause 502s for users on the custom domain.
|
|
774
777
|
let tunnelReady = false;
|
|
775
778
|
log.info(`Readiness probe: waiting for tunnel ${tunnelUrl}`);
|
|
776
779
|
for (let i = 0; i < 30; i++) {
|
|
@@ -813,9 +816,49 @@ export async function startSupervisor() {
|
|
|
813
816
|
}
|
|
814
817
|
}
|
|
815
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
|
+
|
|
816
859
|
// Tunnel watchdog — detects sleep/wake + periodic health checks
|
|
817
860
|
let watchdogInterval: ReturnType<typeof setInterval> | null = null;
|
|
818
|
-
if (tunnelUrl
|
|
861
|
+
if (tunnelUrl) {
|
|
819
862
|
let lastTick = Date.now();
|
|
820
863
|
let healthCounter = 0;
|
|
821
864
|
|
|
@@ -829,36 +872,46 @@ export async function startSupervisor() {
|
|
|
829
872
|
if (!alive) {
|
|
830
873
|
log.warn('Tunnel dead, restarting...');
|
|
831
874
|
try {
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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);
|
|
854
907
|
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
+
}
|
|
862
915
|
} catch (err) {
|
|
863
916
|
log.error(`Tunnel restart failed: ${err instanceof Error ? err.message : err}`);
|
|
864
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
|
+
}
|