free-coding-models 0.2.17 → 0.3.0

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/src/config.js CHANGED
@@ -113,12 +113,16 @@
113
113
  */
114
114
 
115
115
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
116
+ import { randomBytes } from 'node:crypto'
116
117
  import { homedir } from 'node:os'
117
118
  import { join } from 'node:path'
118
119
 
119
120
  // 📖 New JSON config path — stores all providers' API keys + enabled state
120
121
  export const CONFIG_PATH = join(homedir(), '.free-coding-models.json')
121
122
 
123
+ // 📖 Daemon data directory — PID file, logs, etc.
124
+ export const DAEMON_DATA_DIR = join(homedir(), '.free-coding-models')
125
+
122
126
  // 📖 Old plain-text config path — used only for migration
123
127
  const LEGACY_CONFIG_PATH = join(homedir(), '.free-coding-models')
124
128
 
@@ -648,18 +652,37 @@ export function _emptyProfileSettings() {
648
652
  * 📖 normalizeProxySettings: keep proxy-related preferences stable across old configs,
649
653
  * 📖 new installs, and profile switches. Proxy is opt-in by default.
650
654
  *
655
+ * 📖 stableToken — persisted bearer token shared between TUI and daemon. Generated once
656
+ * on first access so env files and tool configs remain valid across restarts.
657
+ * 📖 daemonEnabled — opt-in for the always-on background proxy daemon (launchd / systemd).
658
+ * 📖 daemonConsent — ISO timestamp of when user consented to daemon install, or null.
659
+ *
651
660
  * @param {object|undefined|null} proxy
652
- * @returns {{ enabled: boolean, syncToOpenCode: boolean, preferredPort: number }}
661
+ * @returns {{ enabled: boolean, syncToOpenCode: boolean, preferredPort: number, stableToken: string, daemonEnabled: boolean, daemonConsent: string|null }}
653
662
  */
654
663
  export function normalizeProxySettings(proxy = null) {
655
664
  const preferredPort = Number.isInteger(proxy?.preferredPort) && proxy.preferredPort >= 0 && proxy.preferredPort <= 65535
656
665
  ? proxy.preferredPort
657
666
  : 0
658
667
 
668
+ // 📖 Generate a stable proxy token once and persist it forever
669
+ const stableToken = (typeof proxy?.stableToken === 'string' && proxy.stableToken.length > 0)
670
+ ? proxy.stableToken
671
+ : `fcm_${randomBytes(24).toString('hex')}`
672
+
659
673
  return {
660
674
  enabled: proxy?.enabled === true,
661
675
  syncToOpenCode: proxy?.syncToOpenCode === true,
662
676
  preferredPort,
677
+ stableToken,
678
+ daemonEnabled: proxy?.daemonEnabled === true,
679
+ daemonConsent: (typeof proxy?.daemonConsent === 'string' && proxy.daemonConsent.length > 0)
680
+ ? proxy.daemonConsent
681
+ : null,
682
+ // 📖 activeTool — which tool the proxy auto-syncs to (defaults to current Z mode)
683
+ activeTool: (typeof proxy?.activeTool === 'string' && proxy.activeTool.length > 0)
684
+ ? proxy.activeTool
685
+ : null,
663
686
  }
664
687
  }
665
688
 
@@ -0,0 +1,527 @@
1
+ /**
2
+ * @file src/daemon-manager.js
3
+ * @description OS-level service management for the FCM proxy daemon.
4
+ *
5
+ * 📖 Handles install/uninstall/status/restart of the always-on background proxy:
6
+ * - macOS: launchd LaunchAgent (~/Library/LaunchAgents/com.fcm.proxy.plist)
7
+ * - Linux: systemd user service (~/.config/systemd/user/fcm-proxy.service)
8
+ * - Windows: gracefully unsupported (falls back to in-process proxy)
9
+ *
10
+ * 📖 The daemon manager reads/writes daemon.json for IPC between TUI and daemon.
11
+ * It never imports TUI-specific modules (chalk, render-table, etc.).
12
+ *
13
+ * @functions
14
+ * → getDaemonStatus() — read daemon.json, verify PID alive, health-check HTTP
15
+ * → isDaemonRunning() — quick boolean check
16
+ * → getDaemonInfo() — raw daemon.json contents
17
+ * → installDaemon() — write plist/service, load/enable via OS (blocked in dev)
18
+ * → uninstallDaemon() — unload/disable, delete plist/service
19
+ * → restartDaemon() — stop + start via OS service manager
20
+ * → stopDaemon() — send SIGTERM without removing the service
21
+ * → killDaemonProcess() — send SIGKILL (emergency)
22
+ * → getVersionMismatch() — detect daemon vs FCM version drift
23
+ * → getDaemonLogPath() — path to daemon stdout log
24
+ * → getPlatformSupport() — { supported: boolean, platform: string, reason?: string }
25
+ *
26
+ * @exports getDaemonStatus, isDaemonRunning, getDaemonInfo, installDaemon, uninstallDaemon, restartDaemon, stopDaemon, killDaemonProcess, getVersionMismatch, getDaemonLogPath, getPlatformSupport
27
+ * @see bin/fcm-proxy-daemon.js — the actual daemon process
28
+ * @see src/config.js — DAEMON_DATA_DIR for status file location
29
+ */
30
+
31
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs'
32
+ import { join, dirname } from 'node:path'
33
+ import { homedir } from 'node:os'
34
+ import { execSync } from 'node:child_process'
35
+ import { fileURLToPath } from 'node:url'
36
+ import http from 'node:http'
37
+
38
+ /**
39
+ * 📖 Safe wrapper around execSync with a timeout to prevent indefinite hangs.
40
+ * @param {string} cmd
41
+ * @param {number} [timeoutMs=15000]
42
+ * @returns {Buffer}
43
+ */
44
+ function execSyncSafe(cmd, timeoutMs = 15000) {
45
+ return execSync(cmd, { stdio: 'pipe', timeout: timeoutMs })
46
+ }
47
+
48
+ // 📖 Paths
49
+ const DATA_DIR = join(homedir(), '.free-coding-models')
50
+ const DAEMON_STATUS_FILE = join(DATA_DIR, 'daemon.json')
51
+
52
+ // 📖 macOS launchd paths
53
+ const LAUNCH_AGENT_LABEL = 'com.fcm.proxy'
54
+ const LAUNCH_AGENTS_DIR = join(homedir(), 'Library', 'LaunchAgents')
55
+ const PLIST_PATH = join(LAUNCH_AGENTS_DIR, `${LAUNCH_AGENT_LABEL}.plist`)
56
+
57
+ // 📖 Linux systemd paths
58
+ const SYSTEMD_USER_DIR = join(homedir(), '.config', 'systemd', 'user')
59
+ const SERVICE_NAME = 'fcm-proxy'
60
+ const SERVICE_PATH = join(SYSTEMD_USER_DIR, `${SERVICE_NAME}.service`)
61
+
62
+ // 📖 Log paths
63
+ const DAEMON_STDOUT_LOG = join(DATA_DIR, 'daemon-stdout.log')
64
+ const DAEMON_STDERR_LOG = join(DATA_DIR, 'daemon-stderr.log')
65
+
66
+ // ─── Platform detection ──────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * 📖 Check if the current platform supports daemon installation.
70
+ * @returns {{ supported: boolean, platform: 'macos'|'linux'|'unsupported', reason?: string }}
71
+ */
72
+ export function getPlatformSupport() {
73
+ if (process.platform === 'darwin') return { supported: true, platform: 'macos' }
74
+ if (process.platform === 'linux') return { supported: true, platform: 'linux' }
75
+ return { supported: false, platform: 'unsupported', reason: `Platform '${process.platform}' is not supported for daemon mode. Use in-process proxy instead.` }
76
+ }
77
+
78
+ // ─── Resolve daemon script path ──────────────────────────────────────────────
79
+
80
+ /**
81
+ * 📖 Get absolute path to the fcm-proxy-daemon.js script.
82
+ * 📖 Works for both npm global installs and local development.
83
+ */
84
+ function getDaemonScriptPath() {
85
+ const thisFile = fileURLToPath(import.meta.url)
86
+ return join(dirname(thisFile), '..', 'bin', 'fcm-proxy-daemon.js')
87
+ }
88
+
89
+ /**
90
+ * 📖 Detect if we're running from a local dev checkout (not a global npm install).
91
+ * 📖 A global npm install lives in a node_modules path. A dev checkout has a .git dir.
92
+ * 📖 We block daemon install from dev to avoid hardcoding repo-local paths in launchd/systemd.
93
+ */
94
+ function isDevEnvironment() {
95
+ const thisFile = fileURLToPath(import.meta.url)
96
+ const projectRoot = join(dirname(thisFile), '..')
97
+ // 📖 If there's a .git directory at project root, it's a dev checkout
98
+ return existsSync(join(projectRoot, '.git'))
99
+ }
100
+
101
+ /**
102
+ * 📖 Get absolute path to node binary.
103
+ */
104
+ function getNodePath() {
105
+ return process.execPath
106
+ }
107
+
108
+ // ─── Daemon status ───────────────────────────────────────────────────────────
109
+
110
+ /**
111
+ * 📖 Read the raw daemon.json status file.
112
+ * @returns {object|null} parsed daemon.json or null if not found
113
+ */
114
+ export function getDaemonInfo() {
115
+ try {
116
+ if (!existsSync(DAEMON_STATUS_FILE)) return null
117
+ return JSON.parse(readFileSync(DAEMON_STATUS_FILE, 'utf8'))
118
+ } catch {
119
+ return null
120
+ }
121
+ }
122
+
123
+ /**
124
+ * 📖 Check if a PID is alive using signal 0.
125
+ * @param {number} pid
126
+ * @returns {boolean}
127
+ */
128
+ function isPidAlive(pid) {
129
+ try {
130
+ process.kill(pid, 0)
131
+ return true
132
+ } catch (err) {
133
+ // 📖 ESRCH = no such process, EPERM = exists but different user
134
+ return err.code === 'EPERM'
135
+ }
136
+ }
137
+
138
+ /**
139
+ * 📖 HTTP health check against the daemon's /v1/health endpoint.
140
+ * @param {number} port
141
+ * @param {number} [timeoutMs=3000]
142
+ * @returns {Promise<{ ok: boolean, data?: object }>}
143
+ */
144
+ // 📖 Max buffer for health check responses (64 KB) — prevents memory issues from rogue endpoints
145
+ const MAX_HEALTH_BUFFER = 64 * 1024
146
+
147
+ function healthCheck(port, timeoutMs = 3000) {
148
+ return new Promise(resolve => {
149
+ const req = http.get(`http://127.0.0.1:${port}/v1/health`, { timeout: timeoutMs }, res => {
150
+ const chunks = []
151
+ let totalSize = 0
152
+ res.on('data', c => {
153
+ totalSize += c.length
154
+ if (totalSize > MAX_HEALTH_BUFFER) {
155
+ res.destroy()
156
+ return resolve({ ok: false })
157
+ }
158
+ chunks.push(c)
159
+ })
160
+ res.on('end', () => {
161
+ try {
162
+ const data = JSON.parse(Buffer.concat(chunks).toString())
163
+ resolve({ ok: res.statusCode === 200, data })
164
+ } catch {
165
+ resolve({ ok: false })
166
+ }
167
+ })
168
+ })
169
+ req.on('error', () => resolve({ ok: false }))
170
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false }) })
171
+ })
172
+ }
173
+
174
+ /**
175
+ * 📖 Get comprehensive daemon status.
176
+ * @returns {Promise<{ status: 'running'|'stopped'|'stale'|'unhealthy'|'not-installed', info?: object, health?: object }>}
177
+ */
178
+ export async function getDaemonStatus() {
179
+ const info = getDaemonInfo()
180
+
181
+ if (!info) {
182
+ // 📖 Check if service files exist (installed but daemon.json missing)
183
+ const serviceInstalled = existsSync(PLIST_PATH) || existsSync(SERVICE_PATH)
184
+ return { status: serviceInstalled ? 'stopped' : 'not-installed' }
185
+ }
186
+
187
+ // 📖 Verify PID is alive
188
+ if (!isPidAlive(info.pid)) {
189
+ // 📖 Stale daemon.json — daemon crashed without cleanup
190
+ try { unlinkSync(DAEMON_STATUS_FILE) } catch { /* ignore */ }
191
+ return { status: 'stale', info }
192
+ }
193
+
194
+ // 📖 PID alive — health check the HTTP endpoint
195
+ const health = await healthCheck(info.port)
196
+ if (!health.ok) {
197
+ return { status: 'unhealthy', info, health }
198
+ }
199
+
200
+ return { status: 'running', info, health }
201
+ }
202
+
203
+ /**
204
+ * 📖 Quick boolean check if daemon is running and healthy.
205
+ * @returns {Promise<boolean>}
206
+ */
207
+ export async function isDaemonRunning() {
208
+ const { status } = await getDaemonStatus()
209
+ return status === 'running'
210
+ }
211
+
212
+ // ─── Install daemon ──────────────────────────────────────────────────────────
213
+
214
+ /**
215
+ * 📖 Install and start the daemon as an OS background service.
216
+ * 📖 Blocked in dev environments to avoid hardcoding repo-local paths
217
+ * in launchd/systemd service files. Use `pnpm start` + in-process proxy for dev.
218
+ * @returns {{ success: boolean, error?: string }}
219
+ */
220
+ export function installDaemon() {
221
+ const platform = getPlatformSupport()
222
+ if (!platform.supported) {
223
+ return { success: false, error: platform.reason }
224
+ }
225
+
226
+ // 📖 Block daemon install from dev checkouts — the plist/service would
227
+ // hardcode paths to your local repo, which breaks on npm update.
228
+ if (isDevEnvironment()) {
229
+ return { success: false, error: 'Cannot install daemon from a dev checkout (has .git). Install free-coding-models globally via npm/pnpm first.' }
230
+ }
231
+
232
+ const nodePath = getNodePath()
233
+ const daemonScript = getDaemonScriptPath()
234
+
235
+ if (!existsSync(daemonScript)) {
236
+ return { success: false, error: `Daemon script not found at ${daemonScript}` }
237
+ }
238
+
239
+ // 📖 Ensure data directory exists for logs
240
+ if (!existsSync(DATA_DIR)) {
241
+ mkdirSync(DATA_DIR, { mode: 0o700, recursive: true })
242
+ }
243
+
244
+ try {
245
+ if (platform.platform === 'macos') {
246
+ return installMacOS(nodePath, daemonScript)
247
+ } else {
248
+ return installLinux(nodePath, daemonScript)
249
+ }
250
+ } catch (err) {
251
+ return { success: false, error: err.message }
252
+ }
253
+ }
254
+
255
+ /**
256
+ * 📖 macOS: Write LaunchAgent plist and load it.
257
+ */
258
+ function installMacOS(nodePath, daemonScript) {
259
+ // 📖 Ensure LaunchAgents directory exists
260
+ if (!existsSync(LAUNCH_AGENTS_DIR)) {
261
+ mkdirSync(LAUNCH_AGENTS_DIR, { recursive: true })
262
+ }
263
+
264
+ // 📖 Unload existing agent if any (ignore errors)
265
+ try {
266
+ execSyncSafe(`launchctl unload "${PLIST_PATH}" 2>/dev/null`)
267
+ } catch { /* ignore */ }
268
+
269
+ const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
270
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
271
+ <plist version="1.0">
272
+ <dict>
273
+ <key>Label</key>
274
+ <string>${LAUNCH_AGENT_LABEL}</string>
275
+ <key>ProgramArguments</key>
276
+ <array>
277
+ <string>${nodePath}</string>
278
+ <string>${daemonScript}</string>
279
+ </array>
280
+ <key>KeepAlive</key>
281
+ <true/>
282
+ <key>RunAtLoad</key>
283
+ <true/>
284
+ <key>StandardOutPath</key>
285
+ <string>${DAEMON_STDOUT_LOG}</string>
286
+ <key>StandardErrorPath</key>
287
+ <string>${DAEMON_STDERR_LOG}</string>
288
+ <key>EnvironmentVariables</key>
289
+ <dict>
290
+ <key>HOME</key>
291
+ <string>${homedir()}</string>
292
+ <key>PATH</key>
293
+ <string>${process.env.PATH || '/usr/local/bin:/usr/bin:/bin'}</string>
294
+ </dict>
295
+ </dict>
296
+ </plist>
297
+ `
298
+
299
+ try {
300
+ writeFileSync(PLIST_PATH, plistContent)
301
+ } catch (err) {
302
+ throw new Error('Failed to write LaunchAgent plist: ' + err.message)
303
+ }
304
+ execSyncSafe(`launchctl load "${PLIST_PATH}"`)
305
+
306
+ return { success: true }
307
+ }
308
+
309
+ /**
310
+ * 📖 Linux: Write systemd user service and enable/start it.
311
+ */
312
+ function installLinux(nodePath, daemonScript) {
313
+ if (!existsSync(SYSTEMD_USER_DIR)) {
314
+ mkdirSync(SYSTEMD_USER_DIR, { recursive: true })
315
+ }
316
+
317
+ const serviceContent = `[Unit]
318
+ Description=FCM Proxy V2 — Always-on model rotation proxy
319
+ After=network.target
320
+
321
+ [Service]
322
+ Type=simple
323
+ ExecStart=${nodePath} ${daemonScript}
324
+ Restart=always
325
+ RestartSec=5
326
+ Environment=HOME=${homedir()}
327
+ Environment=PATH=${process.env.PATH || '/usr/local/bin:/usr/bin:/bin'}
328
+ StandardOutput=append:${DAEMON_STDOUT_LOG}
329
+ StandardError=append:${DAEMON_STDERR_LOG}
330
+
331
+ [Install]
332
+ WantedBy=default.target
333
+ `
334
+
335
+ try {
336
+ writeFileSync(SERVICE_PATH, serviceContent)
337
+ } catch (err) {
338
+ throw new Error('Failed to write systemd service file: ' + err.message)
339
+ }
340
+ execSyncSafe('systemctl --user daemon-reload')
341
+ execSyncSafe(`systemctl --user enable ${SERVICE_NAME}`)
342
+ execSyncSafe(`systemctl --user start ${SERVICE_NAME}`)
343
+
344
+ // 📖 Enable lingering so service survives logout
345
+ try {
346
+ execSyncSafe(`loginctl enable-linger ${process.env.USER || ''}`)
347
+ } catch { /* non-fatal — might need sudo */ }
348
+
349
+ return { success: true }
350
+ }
351
+
352
+ // ─── Uninstall daemon ────────────────────────────────────────────────────────
353
+
354
+ /**
355
+ * 📖 Stop and remove the daemon OS service.
356
+ * @returns {{ success: boolean, error?: string }}
357
+ */
358
+ export function uninstallDaemon() {
359
+ const platform = getPlatformSupport()
360
+ if (!platform.supported) {
361
+ return { success: false, error: platform.reason }
362
+ }
363
+
364
+ try {
365
+ if (platform.platform === 'macos') {
366
+ return uninstallMacOS()
367
+ } else {
368
+ return uninstallLinux()
369
+ }
370
+ } catch (err) {
371
+ return { success: false, error: err.message }
372
+ }
373
+ }
374
+
375
+ function uninstallMacOS() {
376
+ try {
377
+ execSyncSafe(`launchctl unload "${PLIST_PATH}" 2>/dev/null`)
378
+ } catch { /* ignore */ }
379
+
380
+ try {
381
+ if (existsSync(PLIST_PATH)) unlinkSync(PLIST_PATH)
382
+ } catch { /* ignore */ }
383
+
384
+ // 📖 Clean up daemon status file
385
+ try {
386
+ if (existsSync(DAEMON_STATUS_FILE)) unlinkSync(DAEMON_STATUS_FILE)
387
+ } catch { /* ignore */ }
388
+
389
+ return { success: true }
390
+ }
391
+
392
+ function uninstallLinux() {
393
+ try {
394
+ execSyncSafe(`systemctl --user stop ${SERVICE_NAME} 2>/dev/null`)
395
+ execSyncSafe(`systemctl --user disable ${SERVICE_NAME} 2>/dev/null`)
396
+ } catch { /* ignore */ }
397
+
398
+ try {
399
+ if (existsSync(SERVICE_PATH)) unlinkSync(SERVICE_PATH)
400
+ execSyncSafe('systemctl --user daemon-reload')
401
+ } catch { /* ignore */ }
402
+
403
+ try {
404
+ if (existsSync(DAEMON_STATUS_FILE)) unlinkSync(DAEMON_STATUS_FILE)
405
+ } catch { /* ignore */ }
406
+
407
+ return { success: true }
408
+ }
409
+
410
+ // ─── Restart daemon ──────────────────────────────────────────────────────────
411
+
412
+ /**
413
+ * 📖 Restart the daemon via the OS service manager.
414
+ * @returns {{ success: boolean, error?: string }}
415
+ */
416
+ export function restartDaemon() {
417
+ const platform = getPlatformSupport()
418
+ if (!platform.supported) {
419
+ return { success: false, error: platform.reason }
420
+ }
421
+
422
+ try {
423
+ if (platform.platform === 'macos') {
424
+ // 📖 launchd: unload + load to restart
425
+ try { execSyncSafe(`launchctl unload "${PLIST_PATH}" 2>/dev/null`) } catch { /* ignore */ }
426
+ execSyncSafe(`launchctl load "${PLIST_PATH}"`)
427
+ } else {
428
+ execSyncSafe(`systemctl --user restart ${SERVICE_NAME}`)
429
+ }
430
+ return { success: true }
431
+ } catch (err) {
432
+ return { success: false, error: err.message }
433
+ }
434
+ }
435
+
436
+ // ─── Stop daemon (kill without uninstalling the service) ────────────────────
437
+
438
+ /**
439
+ * 📖 Stop the daemon process without removing the OS service.
440
+ * 📖 On macOS with KeepAlive:true, launchd may restart it automatically.
441
+ * Use uninstallDaemon() if you want it gone permanently.
442
+ * @returns {{ success: boolean, error?: string, willRestart?: boolean }}
443
+ */
444
+ export function stopDaemon() {
445
+ const info = getDaemonInfo()
446
+ if (!info?.pid) {
447
+ return { success: false, error: 'No daemon running (no daemon.json found).' }
448
+ }
449
+
450
+ if (!isPidAlive(info.pid)) {
451
+ // 📖 Already dead, clean up stale status file
452
+ try { unlinkSync(DAEMON_STATUS_FILE) } catch { /* ignore */ }
453
+ return { success: true }
454
+ }
455
+
456
+ try {
457
+ process.kill(info.pid, 'SIGTERM')
458
+ } catch (err) {
459
+ return { success: false, error: `Failed to send SIGTERM to PID ${info.pid}: ${err.message}` }
460
+ }
461
+
462
+ // 📖 Clean up status file
463
+ try { unlinkSync(DAEMON_STATUS_FILE) } catch { /* ignore */ }
464
+
465
+ // 📖 Warn the user if the OS service will auto-restart it
466
+ const serviceInstalled = existsSync(PLIST_PATH) || existsSync(SERVICE_PATH)
467
+ return {
468
+ success: true,
469
+ willRestart: serviceInstalled,
470
+ }
471
+ }
472
+
473
+ /**
474
+ * 📖 Force-kill the daemon process (SIGKILL). Emergency escape hatch.
475
+ * @returns {{ success: boolean, error?: string }}
476
+ */
477
+ export function killDaemonProcess() {
478
+ const info = getDaemonInfo()
479
+ if (!info?.pid) {
480
+ return { success: false, error: 'No daemon PID found.' }
481
+ }
482
+
483
+ try {
484
+ if (isPidAlive(info.pid)) {
485
+ process.kill(info.pid, 'SIGKILL')
486
+ }
487
+ } catch (err) {
488
+ return { success: false, error: `Failed to kill PID ${info.pid}: ${err.message}` }
489
+ }
490
+
491
+ // 📖 Clean up status file
492
+ try { unlinkSync(DAEMON_STATUS_FILE) } catch { /* ignore */ }
493
+ return { success: true }
494
+ }
495
+
496
+ // ─── Version mismatch detection ─────────────────────────────────────────────
497
+
498
+ /**
499
+ * 📖 Check if the running daemon version differs from the installed FCM version.
500
+ * 📖 Returns null if no mismatch, otherwise { daemonVersion, installedVersion }.
501
+ * @returns {{ daemonVersion: string, installedVersion: string } | null}
502
+ */
503
+ export function getVersionMismatch() {
504
+ const info = getDaemonInfo()
505
+ if (!info?.version) return null
506
+
507
+ try {
508
+ const thisFile = fileURLToPath(import.meta.url)
509
+ const pkgPath = join(dirname(thisFile), '..', 'package.json')
510
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
511
+ if (pkg.version && pkg.version !== info.version) {
512
+ return { daemonVersion: info.version, installedVersion: pkg.version }
513
+ }
514
+ } catch { /* ignore */ }
515
+
516
+ return null
517
+ }
518
+
519
+ // ─── Log path ────────────────────────────────────────────────────────────────
520
+
521
+ /**
522
+ * 📖 Get path to the daemon stdout log.
523
+ * @returns {string}
524
+ */
525
+ export function getDaemonLogPath() {
526
+ return DAEMON_STDOUT_LOG
527
+ }
@@ -49,7 +49,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from
49
49
  import { homedir } from 'node:os'
50
50
  import { dirname, join } from 'node:path'
51
51
  import { MODELS, sources } from '../sources.js'
52
- import { getApiKey, saveConfig } from './config.js'
52
+ import { getApiKey, saveConfig, getProxySettings, loadConfig } from './config.js'
53
53
  import { ENV_VAR_NAMES, PROVIDER_METADATA } from './provider-metadata.js'
54
54
  import { getToolMeta } from './tool-metadata.js'
55
55
 
@@ -60,7 +60,7 @@ const INSTALL_TARGET_MODES = ['opencode', 'opencode-desktop', 'openclaw', 'crush
60
60
  // 📖 Connection modes: direct (pure provider) vs FCM proxy (rotates keys)
61
61
  export const CONNECTION_MODES = [
62
62
  { key: 'direct', label: 'Direct Provider', hint: 'Connect the tool straight to the provider API — no proxy involved.' },
63
- { key: 'proxy', label: 'FCM Proxy', hint: 'Route through the local FCM proxy with key rotation and usage tracking.' },
63
+ { key: 'proxy', label: 'FCM Proxy V2', hint: 'Route through FCM Proxy V2 with key rotation and usage tracking.' },
64
64
  ]
65
65
 
66
66
  function getDefaultPaths() {
@@ -487,30 +487,55 @@ function installIntoQwen(providerKey, models, apiKey, paths) {
487
487
 
488
488
  // 📖 installIntoEnvBasedTool handles tools that rely on env vars only (claude-code, codex, openhands).
489
489
  // 📖 We write a small .env-style helper file so users can source it before launching.
490
- function installIntoEnvBasedTool(providerKey, models, apiKey, toolMode, paths) {
490
+ // 📖 When connectionMode is 'proxy', writes env vars pointing to the daemon's stable port/token.
491
+ function installIntoEnvBasedTool(providerKey, models, apiKey, toolMode, paths, connectionMode = 'direct') {
491
492
  const providerId = getManagedProviderId(providerKey)
492
- const baseUrl = resolveProviderBaseUrl(providerKey)
493
493
  const home = homedir()
494
494
  const envFileName = `.fcm-${toolMode}-env`
495
495
  const envFilePath = join(home, envFileName)
496
-
497
496
  const primaryModel = models[0]
497
+
498
+ // 📖 Resolve effective API key, base URL, and model ID based on connection mode
499
+ let effectiveApiKey = apiKey
500
+ let effectiveBaseUrl = resolveProviderBaseUrl(providerKey)
501
+ let effectiveModelId = primaryModel.modelId
502
+
503
+ if (connectionMode === 'proxy') {
504
+ // 📖 Read stable proxy settings from config for daemon-compatible env files
505
+ try {
506
+ const cfg = loadConfig()
507
+ const proxySettings = getProxySettings(cfg)
508
+ effectiveApiKey = proxySettings.stableToken || apiKey
509
+ const port = proxySettings.preferredPort || 18045
510
+ effectiveBaseUrl = `http://127.0.0.1:${port}/v1`
511
+ } catch { /* fallback to direct values */ }
512
+ }
513
+
498
514
  const envLines = [
499
515
  '# 📖 Managed by free-coding-models — source this file before launching the tool',
500
516
  `# 📖 Provider: ${getProviderLabel(providerKey)} (${models.length} models)`,
501
- `export OPENAI_API_KEY="${apiKey}"`,
502
- `export OPENAI_BASE_URL="${baseUrl}"`,
503
- `export OPENAI_MODEL="${primaryModel.modelId}"`,
504
- `export LLM_API_KEY="${apiKey}"`,
505
- `export LLM_BASE_URL="${baseUrl}"`,
506
- `export LLM_MODEL="openai/${primaryModel.modelId}"`,
517
+ `# 📖 Connection: ${connectionMode === 'proxy' ? 'FCM Proxy V2 (background service)' : 'Direct provider'}`,
518
+ `export OPENAI_API_KEY="${effectiveApiKey}"`,
519
+ `export OPENAI_BASE_URL="${effectiveBaseUrl}"`,
520
+ `export OPENAI_MODEL="${effectiveModelId}"`,
521
+ `export LLM_API_KEY="${effectiveApiKey}"`,
522
+ `export LLM_BASE_URL="${effectiveBaseUrl}"`,
523
+ `export LLM_MODEL="openai/${effectiveModelId}"`,
507
524
  ]
508
525
 
509
- // 📖 Tool-specific extra env vars
526
+ // 📖 Claude Code: Anthropic-specific env vars pointing to proxy /v1/messages endpoint
510
527
  if (toolMode === 'claude-code') {
511
- envLines.push(`export ANTHROPIC_AUTH_TOKEN="${apiKey}"`)
512
- envLines.push(`export ANTHROPIC_BASE_URL="${baseUrl}"`)
513
- envLines.push(`export ANTHROPIC_MODEL="${primaryModel.modelId}"`)
528
+ if (connectionMode === 'proxy') {
529
+ // 📖 Point to proxy base (not /v1) — Claude Code adds /v1/messages itself
530
+ const proxyBase = effectiveBaseUrl.replace(/\/v1$/, '')
531
+ envLines.push(`export ANTHROPIC_API_KEY="${effectiveApiKey}"`)
532
+ envLines.push(`export ANTHROPIC_BASE_URL="${proxyBase}"`)
533
+ envLines.push(`export ANTHROPIC_MODEL="${effectiveModelId}"`)
534
+ } else {
535
+ envLines.push(`export ANTHROPIC_AUTH_TOKEN="${effectiveApiKey}"`)
536
+ envLines.push(`export ANTHROPIC_BASE_URL="${effectiveBaseUrl}"`)
537
+ envLines.push(`export ANTHROPIC_MODEL="${effectiveModelId}"`)
538
+ }
514
539
  }
515
540
 
516
541
  ensureDirFor(envFilePath)
@@ -556,7 +581,7 @@ export function installProviderEndpoints(config, providerKey, toolMode, options
556
581
  } else if (canonicalToolMode === 'qwen') {
557
582
  installResult = installIntoQwen(providerKey, models, apiKey, paths)
558
583
  } else if (canonicalToolMode === 'claude-code' || canonicalToolMode === 'codex' || canonicalToolMode === 'openhands') {
559
- installResult = installIntoEnvBasedTool(providerKey, models, apiKey, canonicalToolMode, paths)
584
+ installResult = installIntoEnvBasedTool(providerKey, models, apiKey, canonicalToolMode, paths, connectionMode)
560
585
  } else {
561
586
  throw new Error(`Unsupported install target: ${toolMode}`)
562
587
  }