idlewatch 0.1.5 → 0.1.7
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 +5 -3
- package/bin/idlewatch-agent.js +190 -19
- package/package.json +2 -2
- package/scripts/install-macos-launch-agent.sh +29 -0
- package/scripts/validate-onboarding.mjs +2 -2
- package/src/enrollment.js +123 -13
- package/src/openclaw-activity.js +163 -0
- package/src/openclaw-usage.js +178 -0
- package/src/telemetry-mapping.js +5 -0
- package/tui/bin/darwin-arm64/idlewatch-setup +0 -0
- package/tui/src/main.rs +34 -6
package/README.md
CHANGED
|
@@ -23,13 +23,13 @@ npx idlewatch --dry-run
|
|
|
23
23
|
|
|
24
24
|
- `quickstart`: run first-run enrollment wizard
|
|
25
25
|
- `--help`: show usage
|
|
26
|
-
- `--dry-run`: collect one sample and exit
|
|
27
|
-
- `--once`: collect one sample, publish
|
|
26
|
+
- `--dry-run`: collect one sample and exit without remote writes
|
|
27
|
+
- `--once`: collect one sample, publish it using the active configured path, then exit
|
|
28
28
|
|
|
29
29
|
## Reliability improvements
|
|
30
30
|
|
|
31
31
|
- Local NDJSON durability log at `~/.idlewatch/logs/<host>-metrics.ndjson` (override via `IDLEWATCH_LOCAL_LOG_PATH`)
|
|
32
|
-
- Retry-once+ for transient
|
|
32
|
+
- Retry-once+ for transient publish failures (cloud ingest and Firebase paths)
|
|
33
33
|
- Non-overlapping scheduler loop (prevents concurrent sample overlap when host is busy)
|
|
34
34
|
- Non-blocking CPU sampling using per-tick CPU deltas (no `Atomics.wait` stall)
|
|
35
35
|
- Darwin GPU probing fallback chain (AGX/IOGPU `ioreg` → `powermetrics` → `top` grep) with provenance fields (`gpuSource`, `gpuConfidence`, `gpuSampleWindowMs`)
|
|
@@ -57,6 +57,8 @@ Use `gpuSource` + `gpuConfidence` in dashboards to decide whether to trust value
|
|
|
57
57
|
npx idlewatch quickstart
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
+
`idlewatch` is the primary package/command name. `idlewatch-skill` still works as a compatibility alias, but treat it as legacy in user-facing docs.
|
|
61
|
+
|
|
60
62
|
The wizard keeps setup small:
|
|
61
63
|
- asks for a **device name**
|
|
62
64
|
- asks for your **API key** from `idlewatch.com/api`
|
package/bin/idlewatch-agent.js
CHANGED
|
@@ -13,12 +13,13 @@ import { memUsedPct, memoryPressureDarwin } from '../src/memory.js'
|
|
|
13
13
|
import { deriveUsageFreshness } from '../src/usage-freshness.js'
|
|
14
14
|
import { deriveUsageAlert } from '../src/usage-alert.js'
|
|
15
15
|
import { loadLastGoodUsageSnapshot, persistLastGoodUsageSnapshot } from '../src/openclaw-cache.js'
|
|
16
|
+
import { DAY_WINDOW_MS, loadOpenClawActivitySummary } from '../src/openclaw-activity.js'
|
|
16
17
|
import { runEnrollmentWizard } from '../src/enrollment.js'
|
|
17
18
|
import { enrichWithOpenClawFleetTelemetry } from '../src/telemetry-mapping.js'
|
|
18
19
|
import pkg from '../package.json' with { type: 'json' }
|
|
19
20
|
|
|
20
21
|
function printHelp() {
|
|
21
|
-
console.log(`idlewatch\n\nUsage:\n idlewatch [quickstart|configure|dashboard|run] [--dry-run] [--once] [--help]\n\nOptions:\n quickstart Run first-run setup and save local IdleWatch config\n configure Alias for quickstart; reopen setup to change device name, API key, or metrics\n dashboard Launch local dashboard from local IdleWatch logs\n run Start the background collector using saved local config\n --no-tui Skip the Rust TUI and use plain text setup without installing Cargo\n --dry-run Collect and print one telemetry sample, then exit without remote writes\n --once Collect and publish one telemetry sample, then exit\n --help Show this help message\n\nQuickstart:\n 1. Create an API key on idlewatch.com/api\n 2. Run: idlewatch quickstart\n 3. Pick a device name and metrics\n 4. IdleWatch saves your local config and sends a first sample\n\
|
|
22
|
+
console.log(`idlewatch\n\nUsage:\n idlewatch [quickstart|configure|status|dashboard|run] [--no-tui] [--dry-run] [--once] [--help]\n\nOptions:\n quickstart Run first-run setup and save local IdleWatch config\n configure Alias for quickstart; reopen setup to change device name, API key, or metrics\n status Show current device config, publish mode, and last sample age\n dashboard Launch local dashboard from local IdleWatch logs\n run Start the background collector using saved local config\n --no-tui Skip the Rust TUI and use plain text setup without installing Cargo\n --dry-run Collect and print one telemetry sample, then exit without remote writes\n --once Collect and publish one telemetry sample, then exit\n --help Show this help message\n\nQuickstart:\n 1. Create an API key on idlewatch.com/api\n 2. Run: idlewatch quickstart\n 3. Pick a device name and metrics\n 4. IdleWatch saves your local config and sends a first sample\n\nCommon env (optional):\n IDLEWATCH_CLOUD_API_KEY Cloud API key from idlewatch.com/api for device linking\n IDLEWATCH_CLOUD_INGEST_URL Cloud ingest endpoint (default: https://api.idlewatch.com/api/ingest)\n IDLEWATCH_LOCAL_LOG_PATH Optional NDJSON file path for local sample durability\n IDLEWATCH_DASHBOARD_PORT Local dashboard HTTP port (default: 4373)\n IDLEWATCH_OPENCLAW_USAGE OpenClaw usage lookup mode: auto|off (default: auto)\n IDLEWATCH_REQUIRE_CLOUD_WRITES Require cloud publish path in --once mode: 1|0 (default: 0)\n\nAdvanced env tuning:\n IDLEWATCH_HOST Optional custom host label (default: hostname)\n IDLEWATCH_INTERVAL_MS Sampling interval in ms (default: 10000)\n IDLEWATCH_OPENCLAW_PROBE_TIMEOUT_MS OpenClaw command timeout per probe in ms (default: 2500)\n IDLEWATCH_OPENCLAW_PROBE_RETRIES Extra OpenClaw probe sweep retries after first pass (default: 1)\n IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES Max per-command OpenClaw probe output capture in bytes before truncation (default: 2097152 / 2MB)\n IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES_HARD_CAP Hard cap for auto-retry output capture escalation (default: 16777216 / 16MB)\n IDLEWATCH_USAGE_STALE_MS Mark OpenClaw usage stale beyond this age in ms (default: max(interval*3,60000))\n IDLEWATCH_USAGE_NEAR_STALE_MS Mark OpenClaw usage as aging beyond this age in ms (default: floor((stale+grace)*0.85))\n IDLEWATCH_USAGE_STALE_GRACE_MS Extra grace window before status becomes stale (default: min(interval,10000))\n IDLEWATCH_USAGE_REFRESH_REPROBES Forced uncached reprobes when usage crosses stale threshold (default: 1)\n IDLEWATCH_USAGE_REFRESH_DELAY_MS Delay between forced stale-threshold reprobes in ms (default: 250)\n IDLEWATCH_USAGE_REFRESH_ON_NEAR_STALE Trigger refresh when usage is near-stale: 1|0 (default: 1)\n IDLEWATCH_USAGE_IDLE_AFTER_MS Downgrade stale usage alerts to idle notice beyond this age in ms (default: 21600000)\n IDLEWATCH_OPENCLAW_LAST_GOOD_MAX_AGE_MS Reuse last successful usage snapshot after probe failures up to this age in ms\n IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH Persist/reuse last successful usage snapshot across restarts (default: ~/.idlewatch/cache/<host>-openclaw-last-good.json)\n\nAdvanced Firebase / emulator mode:\n IDLEWATCH_REQUIRE_FIREBASE_WRITES Require Firebase publish path in --once mode: 1|0 (default: 0)\n FIREBASE_PROJECT_ID Firebase project id\n FIREBASE_SERVICE_ACCOUNT_FILE Path to service account JSON file (preferred for production)\n FIREBASE_SERVICE_ACCOUNT_JSON Raw JSON service account (supported, less secure than file path)\n FIREBASE_SERVICE_ACCOUNT_B64 Base64-encoded JSON service account (legacy)\n FIRESTORE_EMULATOR_HOST Optional Firestore emulator host; allows local writes without service-account creds\n`)
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
const require = createRequire(import.meta.url)
|
|
@@ -54,8 +55,17 @@ function resolveEnvPath(value) {
|
|
|
54
55
|
return path.resolve(expandSupportedPathVars(value))
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
function defaultPersistedEnvFilePath() {
|
|
59
|
+
return path.join(os.homedir(), '.idlewatch', 'idlewatch.env')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function usesDefaultPersistedEnvFile(envFilePath) {
|
|
63
|
+
if (!envFilePath) return false
|
|
64
|
+
return path.resolve(envFilePath) === path.resolve(defaultPersistedEnvFilePath())
|
|
65
|
+
}
|
|
66
|
+
|
|
57
67
|
function loadPersistedEnvIntoProcess() {
|
|
58
|
-
const envFile =
|
|
68
|
+
const envFile = defaultPersistedEnvFilePath()
|
|
59
69
|
if (!fs.existsSync(envFile)) return null
|
|
60
70
|
|
|
61
71
|
try {
|
|
@@ -71,6 +81,27 @@ function loadPersistedEnvIntoProcess() {
|
|
|
71
81
|
}
|
|
72
82
|
}
|
|
73
83
|
|
|
84
|
+
function buildSetupTestEnv(enrolledEnv) {
|
|
85
|
+
const nextEnv = { ...process.env }
|
|
86
|
+
|
|
87
|
+
for (const key of Object.keys(nextEnv)) {
|
|
88
|
+
if (
|
|
89
|
+
key.startsWith('IDLEWATCH_') ||
|
|
90
|
+
key.startsWith('FIREBASE_') ||
|
|
91
|
+
key === 'GOOGLE_APPLICATION_CREDENTIALS'
|
|
92
|
+
) {
|
|
93
|
+
delete nextEnv[key]
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const [key, value] of Object.entries(enrolledEnv || {})) {
|
|
98
|
+
nextEnv[key] = key.endsWith('_PATH') ? expandSupportedPathVars(value) : value
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
nextEnv.IDLEWATCH_SETUP_VERIFY = '1'
|
|
102
|
+
return nextEnv
|
|
103
|
+
}
|
|
104
|
+
|
|
74
105
|
const persistedEnv = loadPersistedEnvIntoProcess()
|
|
75
106
|
|
|
76
107
|
function parseMonitorTargets(raw) {
|
|
@@ -203,6 +234,26 @@ function buildLocalDashboardPayload(logPath) {
|
|
|
203
234
|
}
|
|
204
235
|
}
|
|
205
236
|
|
|
237
|
+
function buildRollingLoadSummary(logPath, nowMs = Date.now(), windowMs = DAY_WINDOW_MS) {
|
|
238
|
+
const rows = parseLocalRows(logPath, 20000).filter((row) => Number(row.ts || 0) >= nowMs - windowMs)
|
|
239
|
+
if (rows.length === 0) return null
|
|
240
|
+
|
|
241
|
+
const average = (values) => {
|
|
242
|
+
const valid = values.filter((value) => Number.isFinite(value))
|
|
243
|
+
if (valid.length === 0) return null
|
|
244
|
+
return Number((valid.reduce((sum, value) => sum + value, 0) / valid.length).toFixed(2))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
windowMs,
|
|
249
|
+
sampleCount: rows.length,
|
|
250
|
+
cpuAvgPct: average(rows.map((row) => Number(row.cpuPct))),
|
|
251
|
+
memAvgPct: average(rows.map((row) => Number(row.memPct))),
|
|
252
|
+
gpuAvgPct: average(rows.map((row) => Number(row.gpuPct))),
|
|
253
|
+
tokensAvgPerMin: average(rows.map((row) => Number(row.tokensPerMin)))
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
206
257
|
function renderLocalDashboardHtml() {
|
|
207
258
|
return `<!doctype html>
|
|
208
259
|
<html>
|
|
@@ -355,10 +406,11 @@ function runLocalDashboard({ host }) {
|
|
|
355
406
|
|
|
356
407
|
const argv = process.argv.slice(2)
|
|
357
408
|
const args = new Set(argv)
|
|
409
|
+
const statusRequested = argv[0] === 'status' || argv.includes('--status')
|
|
358
410
|
const dashboardRequested = argv[0] === 'dashboard' || argv.includes('--dashboard')
|
|
359
411
|
const runRequested = argv[0] === 'run' || argv.includes('--run')
|
|
360
412
|
const interactiveDefaultRequested = argv.length === 0 && process.stdin.isTTY && process.stdout.isTTY
|
|
361
|
-
const quickstartRequested = argv[0] === 'quickstart' || argv[0] === 'configure' || argv.includes('--quickstart') || argv.includes('--configure') || (interactiveDefaultRequested && !dashboardRequested && !runRequested)
|
|
413
|
+
const quickstartRequested = argv[0] === 'quickstart' || argv[0] === 'configure' || argv.includes('--quickstart') || argv.includes('--configure') || (interactiveDefaultRequested && !dashboardRequested && !runRequested && !statusRequested)
|
|
362
414
|
if (args.has('--help') || args.has('-h')) {
|
|
363
415
|
printHelp()
|
|
364
416
|
process.exit(0)
|
|
@@ -374,28 +426,48 @@ if (quickstartRequested) {
|
|
|
374
426
|
try {
|
|
375
427
|
const result = await runEnrollmentWizard({ noTui: args.has('--no-tui') })
|
|
376
428
|
|
|
429
|
+
if (!result?.outputEnvFile || !fs.existsSync(result.outputEnvFile)) {
|
|
430
|
+
throw new Error(`setup_did_not_write_env_file:${result?.outputEnvFile || 'unknown'}`)
|
|
431
|
+
}
|
|
432
|
+
|
|
377
433
|
const enrolledEnv = parseEnvFileToObject(result.outputEnvFile)
|
|
378
434
|
const onceRun = spawnSync(process.execPath, [process.argv[1], '--once'], {
|
|
379
435
|
stdio: 'inherit',
|
|
380
|
-
env:
|
|
381
|
-
...process.env,
|
|
382
|
-
...enrolledEnv
|
|
383
|
-
}
|
|
436
|
+
env: buildSetupTestEnv(enrolledEnv)
|
|
384
437
|
})
|
|
385
438
|
|
|
386
439
|
if (onceRun.status === 0) {
|
|
387
440
|
console.log(`✅ Setup complete. Mode=${result.mode} device=${result.deviceName} envFile=${result.outputEnvFile}`)
|
|
388
|
-
|
|
441
|
+
if (result.mode === 'local') {
|
|
442
|
+
console.log('Initial local telemetry check completed successfully.')
|
|
443
|
+
} else {
|
|
444
|
+
console.log('Initial telemetry sample sent successfully.')
|
|
445
|
+
}
|
|
446
|
+
console.log('To keep this device online continuously, run: idlewatch run')
|
|
447
|
+
if (process.platform === 'darwin') {
|
|
448
|
+
console.log('On macOS, you can also enable login startup with the bundled LaunchAgent install script.')
|
|
449
|
+
}
|
|
389
450
|
process.exit(0)
|
|
390
451
|
}
|
|
391
452
|
|
|
392
453
|
console.error(`⚠️ Setup is not finished yet. Mode=${result.mode} device=${result.deviceName} envFile=${result.outputEnvFile}`)
|
|
393
454
|
console.error('The first required telemetry sample did not publish successfully, so this device may not be linked yet.')
|
|
394
|
-
|
|
395
|
-
|
|
455
|
+
if (usesDefaultPersistedEnvFile(result.outputEnvFile)) {
|
|
456
|
+
console.error('Retry with: idlewatch --once')
|
|
457
|
+
console.error('Or rerun: idlewatch quickstart')
|
|
458
|
+
} else {
|
|
459
|
+
console.error('Retry with: idlewatch quickstart')
|
|
460
|
+
console.error(`Use the saved config directly: set -a; source "${result.outputEnvFile}"; set +a && idlewatch --once`)
|
|
461
|
+
}
|
|
396
462
|
process.exit(onceRun.status ?? 1)
|
|
397
463
|
} catch (err) {
|
|
398
|
-
|
|
464
|
+
if (String(err?.message || '') === 'setup_cancelled') {
|
|
465
|
+
console.error('Enrollment cancelled before saving config.')
|
|
466
|
+
} else if (String(err?.message || '').startsWith('setup_did_not_write_env_file:')) {
|
|
467
|
+
console.error(`Enrollment failed: setup did not save idlewatch.env (${String(err.message).split(':').slice(1).join(':')}).`)
|
|
468
|
+
} else {
|
|
469
|
+
console.error(`Enrollment failed: ${err.message}`)
|
|
470
|
+
}
|
|
399
471
|
process.exit(1)
|
|
400
472
|
}
|
|
401
473
|
}
|
|
@@ -624,9 +696,68 @@ if (firebaseConfigError) {
|
|
|
624
696
|
process.exit(1)
|
|
625
697
|
}
|
|
626
698
|
|
|
627
|
-
|
|
699
|
+
const hasAnyFirebaseConfig = Boolean(PROJECT_ID || CREDS_FILE || CREDS_JSON || CREDS_B64 || FIRESTORE_EMULATOR_HOST)
|
|
700
|
+
const hasCloudConfig = Boolean(CLOUD_INGEST_URL && CLOUD_API_KEY)
|
|
701
|
+
const shouldWarnAboutMissingPublishConfig = !appReady && !hasCloudConfig && !DRY_RUN && !hasAnyFirebaseConfig
|
|
702
|
+
|
|
703
|
+
function getPublishModeLabel() {
|
|
704
|
+
if (hasCloudConfig) return 'cloud'
|
|
705
|
+
if (appReady) return 'firebase'
|
|
706
|
+
return 'local-only'
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (statusRequested) {
|
|
710
|
+
const envFile = defaultPersistedEnvFilePath()
|
|
711
|
+
const hasConfig = fs.existsSync(envFile)
|
|
712
|
+
const publishMode = getPublishModeLabel()
|
|
713
|
+
|
|
714
|
+
console.log('IdleWatch status')
|
|
715
|
+
console.log('')
|
|
716
|
+
console.log(` Device: ${DEVICE_NAME}`)
|
|
717
|
+
console.log(` Device ID: ${DEVICE_ID}`)
|
|
718
|
+
console.log(` Publish mode: ${publishMode}`)
|
|
719
|
+
if (hasCloudConfig) {
|
|
720
|
+
console.log(` Cloud link: ${CLOUD_INGEST_URL}`)
|
|
721
|
+
console.log(` API key: ${CLOUD_API_KEY.slice(0, 8)}..${CLOUD_API_KEY.slice(-4)}`)
|
|
722
|
+
}
|
|
723
|
+
console.log(` Metrics: ${[...MONITOR_TARGETS].join(', ')}`)
|
|
724
|
+
console.log(` Local log: ${LOCAL_LOG_PATH || '(none)'}`)
|
|
725
|
+
console.log(` Config: ${hasConfig ? envFile : '(no saved config)'}`)
|
|
726
|
+
|
|
727
|
+
let hasSamples = false
|
|
728
|
+
if (LOCAL_LOG_PATH && fs.existsSync(LOCAL_LOG_PATH)) {
|
|
729
|
+
try {
|
|
730
|
+
const stat = fs.statSync(LOCAL_LOG_PATH)
|
|
731
|
+
console.log(` Log size: ${formatBytes(stat.size)}`)
|
|
732
|
+
const rows = parseLocalRows(LOCAL_LOG_PATH, 1)
|
|
733
|
+
if (rows.length > 0 && rows[0].ts) {
|
|
734
|
+
hasSamples = true
|
|
735
|
+
const ageMs = Date.now() - Number(rows[0].ts)
|
|
736
|
+
const ageSec = Math.round(ageMs / 1000)
|
|
737
|
+
const agoText = ageSec < 60 ? `${ageSec}s ago` : ageSec < 3600 ? `${Math.round(ageSec / 60)}m ago` : ageSec < 86400 ? `${Math.round(ageSec / 3600)}h ago` : `${Math.round(ageSec / 86400)}d ago`
|
|
738
|
+
console.log(` Last sample: ${agoText}`)
|
|
739
|
+
} else {
|
|
740
|
+
console.log(' Last sample: (none yet)')
|
|
741
|
+
}
|
|
742
|
+
} catch { /* ignore stat errors */ }
|
|
743
|
+
} else if (hasConfig) {
|
|
744
|
+
console.log(' Last sample: (none yet)')
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
console.log('')
|
|
748
|
+
if (!hasConfig) {
|
|
749
|
+
console.log(' Run idlewatch quickstart to set up this device.')
|
|
750
|
+
} else if (!hasSamples) {
|
|
751
|
+
console.log(' Run idlewatch --once to collect a test sample, or idlewatch run for continuous monitoring.')
|
|
752
|
+
} else {
|
|
753
|
+
console.log(' Run idlewatch configure to change device name, metrics, or API key.')
|
|
754
|
+
}
|
|
755
|
+
process.exit(0)
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (shouldWarnAboutMissingPublishConfig) {
|
|
628
759
|
console.error(
|
|
629
|
-
'
|
|
760
|
+
'Local-only mode: this run will stay on this Mac until you link a publish target. Run idlewatch quickstart any time if you want cloud ingest.'
|
|
630
761
|
)
|
|
631
762
|
}
|
|
632
763
|
|
|
@@ -1188,7 +1319,9 @@ async function publish(row, retries = 2) {
|
|
|
1188
1319
|
|
|
1189
1320
|
async function collectSample() {
|
|
1190
1321
|
const sampleStartMs = Date.now()
|
|
1322
|
+
const dayLoadSummary = buildRollingLoadSummary(LOCAL_LOG_PATH, sampleStartMs)
|
|
1191
1323
|
const openclawEnabled = EFFECTIVE_OPENCLAW_MODE !== 'off'
|
|
1324
|
+
const activitySummary = openclawEnabled ? loadOpenClawActivitySummary({ nowMs: sampleStartMs }) : null
|
|
1192
1325
|
|
|
1193
1326
|
const disabledProbe = {
|
|
1194
1327
|
result: 'disabled',
|
|
@@ -1308,6 +1441,8 @@ async function collectSample() {
|
|
|
1308
1441
|
usageStaleMsThreshold: USAGE_STALE_MS,
|
|
1309
1442
|
usageNearStaleMsThreshold: USAGE_NEAR_STALE_MS,
|
|
1310
1443
|
usageStaleGraceMs: USAGE_STALE_GRACE_MS,
|
|
1444
|
+
activitySource: activitySummary?.source ?? (openclawEnabled ? 'unavailable' : 'disabled'),
|
|
1445
|
+
activityWindowMs: activitySummary?.windowMs ?? null,
|
|
1311
1446
|
memPressureSource: memPressure.source,
|
|
1312
1447
|
cloudIngestionStatus: CLOUD_INGEST_URL && CLOUD_API_KEY
|
|
1313
1448
|
? cloudIngestKickedOut ? 'kicked-out' : 'enabled'
|
|
@@ -1332,16 +1467,30 @@ async function collectSample() {
|
|
|
1332
1467
|
memPressurePct: MONITOR_MEMORY ? memPressure.pct : null,
|
|
1333
1468
|
memPressureClass: MONITOR_MEMORY ? memPressure.cls : 'disabled',
|
|
1334
1469
|
gpuPct: MONITOR_GPU ? gpu.pct : null,
|
|
1470
|
+
dayWindowMs: dayLoadSummary?.windowMs ?? DAY_WINDOW_MS,
|
|
1471
|
+
dayCpuAvgPct: MONITOR_CPU ? (dayLoadSummary?.cpuAvgPct ?? null) : null,
|
|
1472
|
+
dayMemAvgPct: MONITOR_MEMORY ? (dayLoadSummary?.memAvgPct ?? null) : null,
|
|
1473
|
+
dayGpuAvgPct: MONITOR_GPU ? (dayLoadSummary?.gpuAvgPct ?? null) : null,
|
|
1474
|
+
dayTokensAvgPerMin: MONITOR_OPENCLAW ? (dayLoadSummary?.tokensAvgPerMin ?? null) : null,
|
|
1335
1475
|
gpuSource: gpu.source,
|
|
1336
1476
|
gpuConfidence: gpu.confidence,
|
|
1337
1477
|
gpuSampleWindowMs: gpu.sampleWindowMs,
|
|
1338
1478
|
tokensPerMin: MONITOR_OPENCLAW ? (usage?.tokensPerMin ?? null) : null,
|
|
1339
1479
|
openclawModel: MONITOR_OPENCLAW ? (usage?.model ?? null) : null,
|
|
1480
|
+
openclawProvider: MONITOR_OPENCLAW ? (usage?.provider ?? null) : null,
|
|
1340
1481
|
openclawTotalTokens: MONITOR_OPENCLAW ? (usage?.totalTokens ?? null) : null,
|
|
1482
|
+
openclawRemainingTokens: MONITOR_OPENCLAW ? (usage?.remainingTokens ?? null) : null,
|
|
1483
|
+
openclawPercentUsed: MONITOR_OPENCLAW ? (usage?.percentUsed ?? null) : null,
|
|
1484
|
+
openclawContextTokens: MONITOR_OPENCLAW ? (usage?.contextTokens ?? null) : null,
|
|
1485
|
+
openclawBudgetKind: MONITOR_OPENCLAW ? (usage?.budgetKind ?? null) : null,
|
|
1341
1486
|
openclawSessionId: MONITOR_OPENCLAW ? (usage?.sessionId ?? null) : null,
|
|
1342
1487
|
openclawAgentId: MONITOR_OPENCLAW ? (usage?.agentId ?? null) : null,
|
|
1343
1488
|
openclawUsageTs: MONITOR_OPENCLAW ? (usage?.usageTimestampMs ?? null) : null,
|
|
1344
1489
|
openclawUsageAgeMs: MONITOR_OPENCLAW ? usageFreshness.usageAgeMs : null,
|
|
1490
|
+
activityWindowMs: MONITOR_OPENCLAW ? (activitySummary?.windowMs ?? null) : null,
|
|
1491
|
+
activityActiveSeconds: MONITOR_OPENCLAW ? (activitySummary?.totalActiveSeconds ?? null) : null,
|
|
1492
|
+
activityIdleSeconds: MONITOR_OPENCLAW ? (activitySummary?.idleSeconds ?? null) : null,
|
|
1493
|
+
activityJobs: MONITOR_OPENCLAW ? (activitySummary?.jobs ?? []) : [],
|
|
1345
1494
|
localLogPath: LOCAL_LOG_PATH,
|
|
1346
1495
|
localLogBytes: null,
|
|
1347
1496
|
source
|
|
@@ -1355,21 +1504,43 @@ async function collectSample() {
|
|
|
1355
1504
|
})
|
|
1356
1505
|
}
|
|
1357
1506
|
|
|
1507
|
+
function summarizeSetupVerification(row) {
|
|
1508
|
+
const metrics = []
|
|
1509
|
+
if (row.cpuPct !== null && row.cpuPct !== undefined) metrics.push('cpu')
|
|
1510
|
+
if (row.memPct !== null && row.memPct !== undefined) metrics.push('memory')
|
|
1511
|
+
if (row.gpuPct !== null && row.gpuPct !== undefined) metrics.push('gpu')
|
|
1512
|
+
if (row.tokensPerMin !== null && row.tokensPerMin !== undefined) metrics.push('openclaw')
|
|
1513
|
+
|
|
1514
|
+
const details = [
|
|
1515
|
+
`mode=${getPublishModeLabel()}`,
|
|
1516
|
+
`metrics=${metrics.length ? metrics.join(',') : 'none'}`
|
|
1517
|
+
]
|
|
1518
|
+
|
|
1519
|
+
if (row.localLogPath) details.push(`localLog=${row.localLogPath}`)
|
|
1520
|
+
return `Initial sample ready (${details.join(' ')})`
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1358
1523
|
async function tick() {
|
|
1359
1524
|
const row = await collectSample()
|
|
1360
1525
|
const localUsage = appendLocal(row)
|
|
1361
1526
|
row.localLogPath = localUsage.path
|
|
1362
1527
|
row.localLogBytes = localUsage.bytes
|
|
1363
1528
|
|
|
1364
|
-
|
|
1529
|
+
if (process.env.IDLEWATCH_SETUP_VERIFY === '1') {
|
|
1530
|
+
console.log(summarizeSetupVerification(row))
|
|
1531
|
+
} else {
|
|
1532
|
+
console.log(JSON.stringify(row))
|
|
1533
|
+
}
|
|
1365
1534
|
|
|
1366
1535
|
const published = await publish(row)
|
|
1367
1536
|
|
|
1368
1537
|
if (cloudIngestKickedOut && !cloudIngestKickoutNotified) {
|
|
1369
1538
|
cloudIngestKickoutNotified = true
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1539
|
+
if (!(REQUIRE_CLOUD_WRITES && ONCE)) {
|
|
1540
|
+
console.error(
|
|
1541
|
+
`Cloud ingest disabled: API key rejected (${cloudIngestKickoutReason || 'unauthorized'}). Run idlewatch quickstart to link a new key.`
|
|
1542
|
+
)
|
|
1543
|
+
}
|
|
1373
1544
|
}
|
|
1374
1545
|
|
|
1375
1546
|
if (REQUIRE_FIREBASE_WRITES && ONCE && !published) {
|
|
@@ -1422,7 +1593,7 @@ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
|
|
|
1422
1593
|
if (DRY_RUN || ONCE) {
|
|
1423
1594
|
const mode = DRY_RUN ? 'dry-run' : 'once'
|
|
1424
1595
|
console.log(
|
|
1425
|
-
`idlewatch ${mode} host=${HOST} device=${DEVICE_NAME} deviceId=${DEVICE_ID} intervalMs=${INTERVAL_MS}
|
|
1596
|
+
`idlewatch ${mode} host=${HOST} device=${DEVICE_NAME} deviceId=${DEVICE_ID} intervalMs=${INTERVAL_MS} publish=${getPublishModeLabel()} localLog=${LOCAL_LOG_PATH} env=${persistedEnv?.envFile || 'process'}`
|
|
1426
1597
|
)
|
|
1427
1598
|
tick()
|
|
1428
1599
|
.then(() => process.exit(0))
|
|
@@ -1432,7 +1603,7 @@ if (DRY_RUN || ONCE) {
|
|
|
1432
1603
|
})
|
|
1433
1604
|
} else {
|
|
1434
1605
|
console.log(
|
|
1435
|
-
`idlewatch started host=${HOST} device=${DEVICE_NAME} deviceId=${DEVICE_ID} intervalMs=${INTERVAL_MS}
|
|
1606
|
+
`idlewatch started host=${HOST} device=${DEVICE_NAME} deviceId=${DEVICE_ID} intervalMs=${INTERVAL_MS} publish=${getPublishModeLabel()} localLog=${LOCAL_LOG_PATH} monitorTargets=${[...MONITOR_TARGETS].join(',')} openclawUsage=${EFFECTIVE_OPENCLAW_MODE} env=${persistedEnv?.envFile || 'process'}`
|
|
1436
1607
|
)
|
|
1437
1608
|
loop()
|
|
1438
1609
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idlewatch",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Host telemetry collector for IdleWatch",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"quickstart": "node bin/idlewatch-agent.js quickstart",
|
|
26
26
|
"validate:bin": "node scripts/validate-bin.mjs",
|
|
27
27
|
"validate:onboarding": "node scripts/validate-onboarding.mjs",
|
|
28
|
-
"test:unit": "node --test 'test/*.test.mjs'",
|
|
28
|
+
"test:unit": "node --test --test-concurrency=1 'test/*.test.mjs'",
|
|
29
29
|
"smoke:help": "node bin/idlewatch-agent.js --help",
|
|
30
30
|
"smoke:dry-run": "node bin/idlewatch-agent.js --dry-run",
|
|
31
31
|
"smoke:once": "IDLEWATCH_OPENCLAW_USAGE=off node bin/idlewatch-agent.js --once",
|
|
@@ -13,6 +13,18 @@ if [[ $START_INTERVAL_SEC -lt 60 ]]; then
|
|
|
13
13
|
fi
|
|
14
14
|
|
|
15
15
|
PLIST_PATH="$PLIST_ROOT/$PLIST_LABEL.plist"
|
|
16
|
+
CONFIG_ENV_PATH="${IDLEWATCH_CONFIG_ENV_PATH:-$HOME/.idlewatch/idlewatch.env}"
|
|
17
|
+
DEFAULT_APP_PATH="/Applications/IdleWatch.app"
|
|
18
|
+
DEFAULT_PLIST_ROOT="$HOME/Library/LaunchAgents"
|
|
19
|
+
DEFAULT_PLIST_LABEL="com.idlewatch.agent"
|
|
20
|
+
|
|
21
|
+
if [[ "$PLIST_LABEL" == "$DEFAULT_PLIST_LABEL" ]] && \
|
|
22
|
+
[[ "$PLIST_ROOT" != "$DEFAULT_PLIST_ROOT" || "$APP_PATH" != "$DEFAULT_APP_PATH" ]]; then
|
|
23
|
+
echo "Refusing to reuse the default LaunchAgent label ($DEFAULT_PLIST_LABEL) with a custom app path or plist root." >&2
|
|
24
|
+
echo "launchd uses the label as the real identity, so this could replace your already-loaded IdleWatch agent." >&2
|
|
25
|
+
echo "Use IDLEWATCH_LAUNCH_AGENT_LABEL to pick a different label for side-by-side QA/dev installs." >&2
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
16
28
|
|
|
17
29
|
if [[ ! -d "$PLIST_ROOT" ]]; then
|
|
18
30
|
mkdir -p "$PLIST_ROOT"
|
|
@@ -77,3 +89,20 @@ launchctl bootstrap "gui/$USER_GUID" "$PLIST_PATH"
|
|
|
77
89
|
launchctl enable "$PLIST_ID"
|
|
78
90
|
echo "Installed LaunchAgent: $PLIST_ID"
|
|
79
91
|
echo "Plist: $PLIST_PATH"
|
|
92
|
+
echo "Logs: $LOG_DIR/idlewatch.out.log and $LOG_DIR/idlewatch.err.log"
|
|
93
|
+
if [[ -f "$CONFIG_ENV_PATH" ]]; then
|
|
94
|
+
echo "Saved IdleWatch config found: $CONFIG_ENV_PATH"
|
|
95
|
+
if [[ "$CONFIG_ENV_PATH" == "$HOME/.idlewatch/idlewatch.env" ]]; then
|
|
96
|
+
echo "Background runs will auto-load it."
|
|
97
|
+
else
|
|
98
|
+
echo "Background runs auto-load only the default path: $HOME/.idlewatch/idlewatch.env"
|
|
99
|
+
echo "Move or copy this config there if you want login startup to reuse it automatically."
|
|
100
|
+
fi
|
|
101
|
+
else
|
|
102
|
+
echo "No saved IdleWatch config found yet at: $CONFIG_ENV_PATH"
|
|
103
|
+
echo "Finish setup once before relying on login startup:"
|
|
104
|
+
echo " \"$BIN_PATH\" quickstart"
|
|
105
|
+
if command -v idlewatch >/dev/null 2>&1; then
|
|
106
|
+
echo "If you already installed the CLI on PATH, 'idlewatch quickstart' works too."
|
|
107
|
+
fi
|
|
108
|
+
fi
|
|
@@ -68,9 +68,9 @@ try {
|
|
|
68
68
|
IDLEWATCH_CLOUD_INGEST_URL: cloudIngestUrl,
|
|
69
69
|
IDLEWATCH_ENROLL_OUTPUT_ENV_FILE: envOut,
|
|
70
70
|
IDLEWATCH_ENROLL_CONFIG_DIR: configDir,
|
|
71
|
-
|
|
71
|
+
IDLEWATCH_ENROLL_DEVICE_NAME: 'Validator Box',
|
|
72
72
|
IDLEWATCH_DEVICE_ID: 'validator-box',
|
|
73
|
-
|
|
73
|
+
IDLEWATCH_ENROLL_MONITOR_TARGETS: 'cpu,memory',
|
|
74
74
|
IDLEWATCH_OPENCLAW_USAGE: 'off'
|
|
75
75
|
})
|
|
76
76
|
|
package/src/enrollment.js
CHANGED
|
@@ -10,6 +10,24 @@ function defaultConfigDir() {
|
|
|
10
10
|
return path.join(os.homedir(), '.idlewatch')
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
function machineName() {
|
|
14
|
+
if (process.platform === 'darwin') {
|
|
15
|
+
const macName = spawnSync('scutil', ['--get', 'ComputerName'], { encoding: 'utf8' })
|
|
16
|
+
if (macName.status === 0) {
|
|
17
|
+
const value = String(macName.stdout || '').trim()
|
|
18
|
+
if (value) return value
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const hostName = String(os.hostname() || '').trim()
|
|
23
|
+
if (hostName) return hostName
|
|
24
|
+
|
|
25
|
+
const envHost = String(process.env.HOSTNAME || '').trim()
|
|
26
|
+
if (envHost) return envHost
|
|
27
|
+
|
|
28
|
+
return 'IdleWatch Device'
|
|
29
|
+
}
|
|
30
|
+
|
|
13
31
|
function ensureDir(dirPath) {
|
|
14
32
|
fs.mkdirSync(dirPath, { recursive: true })
|
|
15
33
|
}
|
|
@@ -80,12 +98,12 @@ function looksLikeCloudApiKey(value) {
|
|
|
80
98
|
return /^iwk_[A-Za-z0-9_-]{20,}$/.test(String(value || '').trim())
|
|
81
99
|
}
|
|
82
100
|
|
|
83
|
-
function normalizeDeviceName(raw, fallback =
|
|
101
|
+
function normalizeDeviceName(raw, fallback = machineName()) {
|
|
84
102
|
const value = String(raw || '').trim().replace(/\s+/g, ' ')
|
|
85
103
|
return value || fallback
|
|
86
104
|
}
|
|
87
105
|
|
|
88
|
-
function sanitizeDeviceId(raw, fallback =
|
|
106
|
+
function sanitizeDeviceId(raw, fallback = machineName()) {
|
|
89
107
|
const base = normalizeDeviceName(raw, fallback).toLowerCase()
|
|
90
108
|
const sanitized = base
|
|
91
109
|
.replace(/[^a-z0-9._-]+/g, '-')
|
|
@@ -100,12 +118,51 @@ function cargoAvailable() {
|
|
|
100
118
|
}
|
|
101
119
|
|
|
102
120
|
function bundledTuiBinaryPath() {
|
|
121
|
+
const override = String(process.env.IDLEWATCH_TUI_BIN || '').trim()
|
|
122
|
+
if (override) return path.resolve(override)
|
|
123
|
+
|
|
103
124
|
const platform = process.platform
|
|
104
125
|
const arch = process.arch
|
|
105
126
|
const ext = platform === 'win32' ? '.exe' : ''
|
|
106
127
|
return path.join(PACKAGE_ROOT, 'tui', 'bin', `${platform}-${arch}`, `idlewatch-setup${ext}`)
|
|
107
128
|
}
|
|
108
129
|
|
|
130
|
+
function parseEnrollmentResultFromEnvFile(outputEnvFile, { configDir, fallbackDeviceName }) {
|
|
131
|
+
if (!outputEnvFile || !fs.existsSync(outputEnvFile)) return null
|
|
132
|
+
|
|
133
|
+
let raw = ''
|
|
134
|
+
try {
|
|
135
|
+
raw = fs.readFileSync(outputEnvFile, 'utf8')
|
|
136
|
+
} catch {
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const parsed = {}
|
|
141
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
142
|
+
const trimmed = line.trim()
|
|
143
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
144
|
+
const idx = trimmed.indexOf('=')
|
|
145
|
+
if (idx <= 0) continue
|
|
146
|
+
const key = trimmed.slice(0, idx).trim()
|
|
147
|
+
const value = trimmed.slice(idx + 1).trim()
|
|
148
|
+
if (key) parsed[key] = value
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const deviceName = normalizeDeviceName(parsed.IDLEWATCH_DEVICE_NAME || fallbackDeviceName || machineName())
|
|
152
|
+
const deviceId = sanitizeDeviceId(parsed.IDLEWATCH_DEVICE_ID || deviceName, machineName())
|
|
153
|
+
const monitorTargets = normalizeMonitorTargets(parsed.IDLEWATCH_MONITOR_TARGETS || '', detectAvailableMonitorTargets())
|
|
154
|
+
const mode = looksLikeCloudApiKey(parsed.IDLEWATCH_CLOUD_API_KEY || '') ? 'production' : 'local'
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
mode,
|
|
158
|
+
configDir,
|
|
159
|
+
outputEnvFile,
|
|
160
|
+
monitorTargets,
|
|
161
|
+
deviceName,
|
|
162
|
+
deviceId
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
109
166
|
function tryBundledRustTui({ configDir, outputEnvFile }) {
|
|
110
167
|
const binPath = bundledTuiBinaryPath()
|
|
111
168
|
if (!fs.existsSync(binPath)) return { ok: false, reason: 'bundled-binary-missing', binPath }
|
|
@@ -161,8 +218,12 @@ function tryRustTui({ configDir, outputEnvFile }) {
|
|
|
161
218
|
return { ok: false, reason: `cargo-run-failed:${run.status ?? 'unknown'}`, manifestPath }
|
|
162
219
|
}
|
|
163
220
|
|
|
164
|
-
function promptModeText() {
|
|
165
|
-
|
|
221
|
+
function promptModeText({ isReconfigure = false, currentMode = null } = {}) {
|
|
222
|
+
const title = isReconfigure ? 'IdleWatch Reconfigure' : 'IdleWatch Setup Wizard'
|
|
223
|
+
const pad = Math.max(0, Math.floor((47 - title.length) / 2))
|
|
224
|
+
const titleLine = ' '.repeat(pad) + title
|
|
225
|
+
const defaultHint = currentMode === 'local' ? ' (default 2)' : ' (default 1)'
|
|
226
|
+
return `\n╭───────────────────────────────────────────────╮\n│${titleLine.padEnd(47)}│\n╰───────────────────────────────────────────────╯\n\nChoose setup mode:\n 1) Managed cloud (recommended)\n Link this device with an API key from idlewatch.com/api\n 2) Local-only (no cloud writes)\n`
|
|
166
227
|
}
|
|
167
228
|
|
|
168
229
|
export async function runEnrollmentWizard(options = {}) {
|
|
@@ -171,27 +232,40 @@ export async function runEnrollmentWizard(options = {}) {
|
|
|
171
232
|
const configDir = path.resolve(options.configDir || process.env.IDLEWATCH_ENROLL_CONFIG_DIR || defaultConfigDir())
|
|
172
233
|
const outputEnvFile = path.resolve(options.outputEnvFile || process.env.IDLEWATCH_ENROLL_OUTPUT_ENV_FILE || path.join(configDir, 'idlewatch.env'))
|
|
173
234
|
|
|
235
|
+
// Load existing saved config for reconfigure defaults
|
|
236
|
+
let existingConfig = null
|
|
237
|
+
if (fs.existsSync(outputEnvFile)) {
|
|
238
|
+
existingConfig = parseEnrollmentResultFromEnvFile(outputEnvFile, { configDir, fallbackDeviceName: machineName() })
|
|
239
|
+
}
|
|
240
|
+
|
|
174
241
|
let mode = options.mode || process.env.IDLEWATCH_ENROLL_MODE || null
|
|
175
242
|
let cloudApiKey = normalizeCloudApiKey(options.cloudApiKey || process.env.IDLEWATCH_CLOUD_API_KEY || null)
|
|
176
243
|
let cloudIngestUrl = options.cloudIngestUrl || process.env.IDLEWATCH_CLOUD_INGEST_URL || 'https://api.idlewatch.com/api/ingest'
|
|
177
|
-
let deviceName = normalizeDeviceName(
|
|
244
|
+
let deviceName = normalizeDeviceName(
|
|
245
|
+
options.deviceName || process.env.IDLEWATCH_ENROLL_DEVICE_NAME || process.env.IDLEWATCH_DEVICE_NAME || (existingConfig?.deviceName) || machineName()
|
|
246
|
+
)
|
|
178
247
|
|
|
179
248
|
const availableMonitorTargets = detectAvailableMonitorTargets()
|
|
180
249
|
let monitorTargets = normalizeMonitorTargets(
|
|
181
|
-
options.monitorTargets || process.env.IDLEWATCH_MONITOR_TARGETS || '',
|
|
250
|
+
options.monitorTargets || process.env.IDLEWATCH_ENROLL_MONITOR_TARGETS || process.env.IDLEWATCH_MONITOR_TARGETS || '',
|
|
182
251
|
availableMonitorTargets
|
|
183
252
|
)
|
|
184
253
|
|
|
185
254
|
if (!nonInteractive && !noTui) {
|
|
186
255
|
const tuiResult = tryRustTui({ configDir, outputEnvFile })
|
|
256
|
+
const tuiEnrollment = parseEnrollmentResultFromEnvFile(outputEnvFile, { configDir, fallbackDeviceName: deviceName })
|
|
187
257
|
if (tuiResult.ok) {
|
|
188
|
-
return {
|
|
258
|
+
return tuiEnrollment || {
|
|
189
259
|
mode: 'tui',
|
|
190
260
|
configDir,
|
|
191
261
|
outputEnvFile
|
|
192
262
|
}
|
|
193
263
|
}
|
|
194
264
|
|
|
265
|
+
if (tuiEnrollment) {
|
|
266
|
+
return tuiEnrollment
|
|
267
|
+
}
|
|
268
|
+
|
|
195
269
|
if (tuiResult.reason === 'bundled-binary-missing-and-cargo-missing') {
|
|
196
270
|
console.warn('IdleWatch TUI is not bundled for this platform and Cargo is not installed. Falling back to text setup. Use --no-tui to skip this check.')
|
|
197
271
|
} else if (!['disabled', 'cargo-missing', 'bundled-binary-missing'].includes(tuiResult.reason || '')) {
|
|
@@ -202,10 +276,16 @@ export async function runEnrollmentWizard(options = {}) {
|
|
|
202
276
|
let rl = null
|
|
203
277
|
if (!nonInteractive) {
|
|
204
278
|
rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
205
|
-
|
|
279
|
+
const isReconfigure = !!existingConfig
|
|
280
|
+
const currentMode = existingConfig?.mode || null
|
|
281
|
+
const modeDefault = currentMode === 'local' ? '2' : '1'
|
|
282
|
+
console.log(promptModeText({ isReconfigure, currentMode }))
|
|
206
283
|
console.log(`Storage path: ${configDir}`)
|
|
207
284
|
console.log(`Environment file: ${outputEnvFile}`)
|
|
208
|
-
|
|
285
|
+
if (isReconfigure) {
|
|
286
|
+
console.log(`Current device: ${existingConfig.deviceName} (${currentMode === 'production' ? 'cloud' : 'local-only'})`)
|
|
287
|
+
}
|
|
288
|
+
const modeInput = (await rl.question(`\nMode [1/2] (default ${modeDefault}): `)).trim() || modeDefault
|
|
209
289
|
mode = modeInput === '2' ? 'local' : 'production'
|
|
210
290
|
const deviceNameInput = (await rl.question(`Device name [${deviceName}]: `)).trim()
|
|
211
291
|
deviceName = normalizeDeviceName(deviceNameInput || deviceName)
|
|
@@ -217,9 +297,36 @@ export async function runEnrollmentWizard(options = {}) {
|
|
|
217
297
|
}
|
|
218
298
|
|
|
219
299
|
if ((mode === 'production') && !cloudApiKey) {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
300
|
+
// Try to reuse existing saved API key when reconfiguring
|
|
301
|
+
if (existingConfig?.mode === 'production' && fs.existsSync(outputEnvFile)) {
|
|
302
|
+
try {
|
|
303
|
+
const raw = fs.readFileSync(outputEnvFile, 'utf8')
|
|
304
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
305
|
+
const trimmed = line.trim()
|
|
306
|
+
if (trimmed.startsWith('IDLEWATCH_CLOUD_API_KEY=')) {
|
|
307
|
+
const savedKey = normalizeCloudApiKey(trimmed.slice('IDLEWATCH_CLOUD_API_KEY='.length))
|
|
308
|
+
if (looksLikeCloudApiKey(savedKey)) {
|
|
309
|
+
cloudApiKey = savedKey
|
|
310
|
+
break
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
} catch { /* ignore */ }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (!cloudApiKey) {
|
|
318
|
+
if (!rl) throw new Error('Missing cloud API key (IDLEWATCH_CLOUD_API_KEY).')
|
|
319
|
+
console.log('\nPaste the API key from idlewatch.com/api.')
|
|
320
|
+
cloudApiKey = normalizeCloudApiKey(await rl.question('Cloud API key: '))
|
|
321
|
+
} else if (rl) {
|
|
322
|
+
const masked = cloudApiKey.slice(0, 8) + '…' + cloudApiKey.slice(-4)
|
|
323
|
+
console.log(`\nUsing saved API key: ${masked}`)
|
|
324
|
+
const changeKey = (await rl.question('Keep this key? [Y/n]: ')).trim().toLowerCase()
|
|
325
|
+
if (changeKey === 'n' || changeKey === 'no') {
|
|
326
|
+
console.log('Paste the new API key from idlewatch.com/api.')
|
|
327
|
+
cloudApiKey = normalizeCloudApiKey(await rl.question('Cloud API key: '))
|
|
328
|
+
}
|
|
329
|
+
}
|
|
223
330
|
}
|
|
224
331
|
|
|
225
332
|
if (!nonInteractive && rl) {
|
|
@@ -229,7 +336,10 @@ export async function runEnrollmentWizard(options = {}) {
|
|
|
229
336
|
monitorTargets = normalizeMonitorTargets(monitorInput || suggested, availableMonitorTargets)
|
|
230
337
|
}
|
|
231
338
|
|
|
232
|
-
const safeDeviceId = sanitizeDeviceId(
|
|
339
|
+
const safeDeviceId = sanitizeDeviceId(
|
|
340
|
+
options.deviceId || process.env.IDLEWATCH_ENROLL_DEVICE_ID || deviceName,
|
|
341
|
+
machineName()
|
|
342
|
+
)
|
|
233
343
|
const localLogPath = path.join(configDir, 'logs', `${safeDeviceId}-metrics.ndjson`)
|
|
234
344
|
const localCachePath = path.join(configDir, 'cache', `${safeDeviceId}-openclaw-last-good.json`)
|
|
235
345
|
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
export const DAY_WINDOW_MS = 24 * 60 * 60 * 1000
|
|
6
|
+
|
|
7
|
+
function readJson(filePath) {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
10
|
+
} catch {
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readJsonLines(filePath) {
|
|
16
|
+
try {
|
|
17
|
+
return fs.readFileSync(filePath, 'utf8')
|
|
18
|
+
.split(/\r?\n/)
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
.map((line) => {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(line)
|
|
23
|
+
} catch {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
} catch {
|
|
29
|
+
return []
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseCronIntervalMs(expr) {
|
|
34
|
+
const value = String(expr || '').trim()
|
|
35
|
+
if (!value) return null
|
|
36
|
+
|
|
37
|
+
const parts = value.split(/\s+/)
|
|
38
|
+
if (parts.length !== 5) return null
|
|
39
|
+
|
|
40
|
+
const [minute, hour] = parts
|
|
41
|
+
const minuteStepMatch = minute.match(/^(?:\*|\d+-\d+)\/(\d+)$/)
|
|
42
|
+
if (minuteStepMatch && hour === '*') {
|
|
43
|
+
const minutes = Number(minuteStepMatch[1])
|
|
44
|
+
return Number.isFinite(minutes) && minutes > 0 ? minutes * 60 * 1000 : null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (/^\d+$/.test(minute) && /^\*\/(\d+)$/.test(hour)) {
|
|
48
|
+
const hours = Number(hour.slice(2))
|
|
49
|
+
return Number.isFinite(hours) && hours > 0 ? hours * 60 * 60 * 1000 : null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (/^\d+$/.test(minute) && hour === '*') return 60 * 60 * 1000
|
|
53
|
+
if (/^\d+$/.test(minute) && /^\d+$/.test(hour)) return 24 * 60 * 60 * 1000
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function round(value, digits = 1) {
|
|
58
|
+
if (!Number.isFinite(value)) return null
|
|
59
|
+
const factor = 10 ** digits
|
|
60
|
+
return Math.round(value * factor) / factor
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function loadOpenClawActivitySummary(options = {}) {
|
|
64
|
+
const homeDir = options.homeDir || os.homedir()
|
|
65
|
+
const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now()
|
|
66
|
+
const windowMs = Number.isFinite(options.windowMs) ? options.windowMs : DAY_WINDOW_MS
|
|
67
|
+
const cutoffMs = nowMs - windowMs
|
|
68
|
+
const openclawDir = path.join(homeDir, '.openclaw')
|
|
69
|
+
const jobsPath = path.join(openclawDir, 'cron', 'jobs.json')
|
|
70
|
+
const runsDir = path.join(openclawDir, 'cron', 'runs')
|
|
71
|
+
|
|
72
|
+
const jobsPayload = readJson(jobsPath)
|
|
73
|
+
const jobs = Array.isArray(jobsPayload?.jobs) ? jobsPayload.jobs : []
|
|
74
|
+
|
|
75
|
+
if (jobs.length === 0 || !fs.existsSync(runsDir)) return null
|
|
76
|
+
|
|
77
|
+
const jobsById = new Map(jobs.map((job) => [String(job.id || ''), job]).filter(([id]) => id))
|
|
78
|
+
const summaries = new Map()
|
|
79
|
+
|
|
80
|
+
for (const entry of fs.readdirSync(runsDir, { withFileTypes: true })) {
|
|
81
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue
|
|
82
|
+
|
|
83
|
+
const jobId = entry.name.replace(/\.jsonl$/, '')
|
|
84
|
+
const job = jobsById.get(jobId)
|
|
85
|
+
const runs = readJsonLines(path.join(runsDir, entry.name))
|
|
86
|
+
|
|
87
|
+
for (const run of runs) {
|
|
88
|
+
const runAtMs = Number(run.runAtMs ?? run.ts ?? 0)
|
|
89
|
+
const durationMs = Number(run.durationMs ?? 0)
|
|
90
|
+
const runStatus = String(run.status || 'unknown')
|
|
91
|
+
|
|
92
|
+
if (!Number.isFinite(runAtMs) || runAtMs < cutoffMs) continue
|
|
93
|
+
if (!Number.isFinite(durationMs) || durationMs <= 0) continue
|
|
94
|
+
if (run.action && run.action !== 'finished') continue
|
|
95
|
+
|
|
96
|
+
const intervalMs = parseCronIntervalMs(job?.schedule?.expr)
|
|
97
|
+
const summary = summaries.get(jobId) || {
|
|
98
|
+
id: jobId,
|
|
99
|
+
label: String(job?.name || run.summary || jobId).trim() || jobId,
|
|
100
|
+
kind: String(job?.schedule?.kind || '').trim() || 'cron',
|
|
101
|
+
intervalMs,
|
|
102
|
+
enabled: Boolean(job?.enabled),
|
|
103
|
+
totalDurationMs: 0,
|
|
104
|
+
runCount: 0,
|
|
105
|
+
okCount: 0,
|
|
106
|
+
errorCount: 0,
|
|
107
|
+
maxDurationMs: 0,
|
|
108
|
+
lastRunAtMs: 0,
|
|
109
|
+
lastStatus: 'unknown'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
summary.totalDurationMs += durationMs
|
|
113
|
+
summary.runCount += 1
|
|
114
|
+
summary.maxDurationMs = Math.max(summary.maxDurationMs, durationMs)
|
|
115
|
+
summary.lastRunAtMs = Math.max(summary.lastRunAtMs, runAtMs)
|
|
116
|
+
summary.lastStatus = runStatus
|
|
117
|
+
|
|
118
|
+
if (runStatus === 'error') summary.errorCount += 1
|
|
119
|
+
else summary.okCount += 1
|
|
120
|
+
|
|
121
|
+
summaries.set(jobId, summary)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const jobsSummary = [...summaries.values()]
|
|
126
|
+
.filter((item) => item.runCount > 0 && item.totalDurationMs > 0)
|
|
127
|
+
.sort((a, b) => b.totalDurationMs - a.totalDurationMs)
|
|
128
|
+
.map((item) => {
|
|
129
|
+
const avgDurationMs = item.totalDurationMs / item.runCount
|
|
130
|
+
const cycleOccupancyPct = item.intervalMs && item.intervalMs > 0
|
|
131
|
+
? round(Math.min(100, (avgDurationMs / item.intervalMs) * 100), 1)
|
|
132
|
+
: null
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
id: item.id,
|
|
136
|
+
label: item.label,
|
|
137
|
+
kind: item.kind,
|
|
138
|
+
enabled: item.enabled,
|
|
139
|
+
intervalMs: item.intervalMs,
|
|
140
|
+
runCount: item.runCount,
|
|
141
|
+
okCount: item.okCount,
|
|
142
|
+
errorCount: item.errorCount,
|
|
143
|
+
seconds: round(item.totalDurationMs / 1000, 1) ?? 0,
|
|
144
|
+
avgDurationMs: round(avgDurationMs, 0) ?? 0,
|
|
145
|
+
maxDurationMs: round(item.maxDurationMs, 0) ?? 0,
|
|
146
|
+
cycleOccupancyPct,
|
|
147
|
+
lastRunAtMs: item.lastRunAtMs,
|
|
148
|
+
lastStatus: item.lastStatus
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
if (jobsSummary.length === 0) return null
|
|
153
|
+
|
|
154
|
+
const totalActiveSeconds = jobsSummary.reduce((sum, item) => sum + Math.max(0, Number(item.seconds || 0)), 0)
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
source: 'openclaw-cron-runs',
|
|
158
|
+
windowMs,
|
|
159
|
+
totalActiveSeconds: round(totalActiveSeconds, 1) ?? 0,
|
|
160
|
+
idleSeconds: round(Math.max(0, windowMs / 1000 - totalActiveSeconds), 1) ?? 0,
|
|
161
|
+
jobs: jobsSummary
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/openclaw-usage.js
CHANGED
|
@@ -49,6 +49,67 @@ function pickString(...vals) {
|
|
|
49
49
|
return null
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
function normalizeProviderName(value) {
|
|
53
|
+
const normalized = pickString(value)?.toLowerCase()
|
|
54
|
+
if (!normalized) return null
|
|
55
|
+
|
|
56
|
+
if (['openai', 'anthropic', 'google', 'local', 'other'].includes(normalized)) return normalized
|
|
57
|
+
if (normalized === 'claude') return 'anthropic'
|
|
58
|
+
if (normalized === 'gemini') return 'google'
|
|
59
|
+
if (normalized === 'ollama') return 'local'
|
|
60
|
+
return normalized
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function inferProviderFromModel(model) {
|
|
64
|
+
const normalized = pickString(model)?.toLowerCase()
|
|
65
|
+
if (!normalized) return null
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
normalized.startsWith('openai/') ||
|
|
69
|
+
normalized.startsWith('gpt-') ||
|
|
70
|
+
normalized.startsWith('chatgpt') ||
|
|
71
|
+
normalized.startsWith('codex') ||
|
|
72
|
+
/^o[1345](?:$|[-.:/])/.test(normalized)
|
|
73
|
+
) return 'openai'
|
|
74
|
+
|
|
75
|
+
if (normalized.startsWith('anthropic/') || normalized.includes('claude')) return 'anthropic'
|
|
76
|
+
if (normalized.startsWith('google/') || normalized.includes('gemini')) return 'google'
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
normalized.startsWith('ollama/') ||
|
|
80
|
+
normalized.includes('qwen') ||
|
|
81
|
+
normalized.includes('llama') ||
|
|
82
|
+
normalized.includes('mistral') ||
|
|
83
|
+
normalized.includes('mixtral') ||
|
|
84
|
+
normalized.includes('deepseek') ||
|
|
85
|
+
normalized.includes('gemma') ||
|
|
86
|
+
normalized.includes('phi') ||
|
|
87
|
+
normalized.includes('qwq') ||
|
|
88
|
+
/:[0-9]+[a-z]?$/.test(normalized)
|
|
89
|
+
) return 'local'
|
|
90
|
+
|
|
91
|
+
return 'other'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function deriveRemainingTokens(totalTokens, contextTokens) {
|
|
95
|
+
if (!Number.isFinite(totalTokens) || !Number.isFinite(contextTokens)) return null
|
|
96
|
+
if (contextTokens < totalTokens) return null
|
|
97
|
+
return contextTokens - totalTokens
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function derivePercentUsed(totalTokens, contextTokens, remainingTokens = null) {
|
|
101
|
+
if (!Number.isFinite(contextTokens) || contextTokens <= 0) return null
|
|
102
|
+
|
|
103
|
+
const usedTokens = Number.isFinite(totalTokens)
|
|
104
|
+
? totalTokens
|
|
105
|
+
: Number.isFinite(remainingTokens)
|
|
106
|
+
? contextTokens - remainingTokens
|
|
107
|
+
: null
|
|
108
|
+
|
|
109
|
+
if (!Number.isFinite(usedTokens) || usedTokens < 0) return null
|
|
110
|
+
return Math.max(0, Math.min(100, Math.round((usedTokens / contextTokens) * 100)))
|
|
111
|
+
}
|
|
112
|
+
|
|
52
113
|
function isFreshTokenMarker(value) {
|
|
53
114
|
if (value === false || value === 0) return false
|
|
54
115
|
if (value === null || typeof value === 'undefined') return true
|
|
@@ -615,6 +676,40 @@ function parseFromStatusJson(parsed) {
|
|
|
615
676
|
deriveTokensPerMinute(session),
|
|
616
677
|
pickNumber(session?.derived?.tokensPerMinute, session?.derived?.tpm)
|
|
617
678
|
)
|
|
679
|
+
const contextTokens = pickNumber(
|
|
680
|
+
session.contextTokens,
|
|
681
|
+
session.context_tokens,
|
|
682
|
+
session?.usage?.contextTokens,
|
|
683
|
+
session?.usage?.context_tokens,
|
|
684
|
+
defaults.contextTokens,
|
|
685
|
+
defaults.context_tokens,
|
|
686
|
+
parsed?.contextTokens,
|
|
687
|
+
parsed?.context_tokens,
|
|
688
|
+
parsed?.status?.contextTokens,
|
|
689
|
+
parsed?.status?.context_tokens
|
|
690
|
+
)
|
|
691
|
+
const remainingTokens = pickNumber(
|
|
692
|
+
session.remainingTokens,
|
|
693
|
+
session.remaining_tokens,
|
|
694
|
+
session?.usage?.remainingTokens,
|
|
695
|
+
session?.usage?.remaining_tokens,
|
|
696
|
+
parsed?.remainingTokens,
|
|
697
|
+
parsed?.remaining_tokens,
|
|
698
|
+
parsed?.status?.remainingTokens,
|
|
699
|
+
parsed?.status?.remaining_tokens,
|
|
700
|
+
deriveRemainingTokens(totalTokens, contextTokens)
|
|
701
|
+
)
|
|
702
|
+
const percentUsed = pickNumber(
|
|
703
|
+
session.percentUsed,
|
|
704
|
+
session.percent_used,
|
|
705
|
+
session?.usage?.percentUsed,
|
|
706
|
+
session?.usage?.percent_used,
|
|
707
|
+
parsed?.percentUsed,
|
|
708
|
+
parsed?.percent_used,
|
|
709
|
+
parsed?.status?.percentUsed,
|
|
710
|
+
parsed?.status?.percent_used,
|
|
711
|
+
derivePercentUsed(totalTokens, contextTokens, remainingTokens)
|
|
712
|
+
)
|
|
618
713
|
const sessionAgeMs = pickNumber(session.age, session.ageMs)
|
|
619
714
|
const usageTimestampMs =
|
|
620
715
|
pickTimestamp(
|
|
@@ -676,11 +771,31 @@ function parseFromStatusJson(parsed) {
|
|
|
676
771
|
|
|
677
772
|
return {
|
|
678
773
|
model,
|
|
774
|
+
provider: normalizeProviderName(
|
|
775
|
+
pickString(
|
|
776
|
+
session.provider,
|
|
777
|
+
session.providerName,
|
|
778
|
+
session.provider_name,
|
|
779
|
+
session?.usage?.provider,
|
|
780
|
+
session?.usage?.providerName,
|
|
781
|
+
session?.usage?.provider_name,
|
|
782
|
+
parsed?.provider,
|
|
783
|
+
parsed?.providerName,
|
|
784
|
+
parsed?.provider_name,
|
|
785
|
+
parsed?.status?.provider,
|
|
786
|
+
parsed?.status?.providerName,
|
|
787
|
+
parsed?.status?.provider_name
|
|
788
|
+
)
|
|
789
|
+
) ?? inferProviderFromModel(model),
|
|
679
790
|
totalTokens,
|
|
680
791
|
tokensPerMin,
|
|
792
|
+
remainingTokens,
|
|
793
|
+
percentUsed,
|
|
794
|
+
contextTokens,
|
|
681
795
|
sessionId: pickString(session.sessionId, session.id, session?.usage?.sessionId, session?.usage?.id, session?.session_id),
|
|
682
796
|
agentId: pickString(session.agentId, session?.usage?.agentId, session?.agent_id),
|
|
683
797
|
usageTimestampMs,
|
|
798
|
+
budgetKind: contextTokens !== null ? 'context-window' : null,
|
|
684
799
|
integrationStatus: hasStrongUsage ? 'ok' : 'partial'
|
|
685
800
|
}
|
|
686
801
|
}
|
|
@@ -837,13 +952,73 @@ function parseGenericUsage(parsed) {
|
|
|
837
952
|
parsed?.rps,
|
|
838
953
|
parsed?.requestsPerMinute
|
|
839
954
|
)
|
|
955
|
+
const contextTokens = pickNumber(
|
|
956
|
+
usageRecord?.contextTokens,
|
|
957
|
+
usageRecord?.context_tokens,
|
|
958
|
+
usageRecord?.usage?.contextTokens,
|
|
959
|
+
usageRecord?.usage?.context_tokens,
|
|
960
|
+
usageTotals?.contextTokens,
|
|
961
|
+
usageTotals?.context_tokens,
|
|
962
|
+
parsed?.contextTokens,
|
|
963
|
+
parsed?.context_tokens,
|
|
964
|
+
parsed?.status?.contextTokens,
|
|
965
|
+
parsed?.status?.context_tokens
|
|
966
|
+
)
|
|
967
|
+
const remainingTokens = pickNumber(
|
|
968
|
+
usageRecord?.remainingTokens,
|
|
969
|
+
usageRecord?.remaining_tokens,
|
|
970
|
+
usageRecord?.usage?.remainingTokens,
|
|
971
|
+
usageRecord?.usage?.remaining_tokens,
|
|
972
|
+
usageTotals?.remainingTokens,
|
|
973
|
+
usageTotals?.remaining_tokens,
|
|
974
|
+
parsed?.remainingTokens,
|
|
975
|
+
parsed?.remaining_tokens,
|
|
976
|
+
parsed?.status?.remainingTokens,
|
|
977
|
+
parsed?.status?.remaining_tokens,
|
|
978
|
+
deriveRemainingTokens(totalTokens, contextTokens)
|
|
979
|
+
)
|
|
980
|
+
const percentUsed = pickNumber(
|
|
981
|
+
usageRecord?.percentUsed,
|
|
982
|
+
usageRecord?.percent_used,
|
|
983
|
+
usageRecord?.usage?.percentUsed,
|
|
984
|
+
usageRecord?.usage?.percent_used,
|
|
985
|
+
usageTotals?.percentUsed,
|
|
986
|
+
usageTotals?.percent_used,
|
|
987
|
+
parsed?.percentUsed,
|
|
988
|
+
parsed?.percent_used,
|
|
989
|
+
parsed?.status?.percentUsed,
|
|
990
|
+
parsed?.status?.percent_used,
|
|
991
|
+
derivePercentUsed(totalTokens, contextTokens, remainingTokens)
|
|
992
|
+
)
|
|
840
993
|
|
|
841
994
|
if (model === null && totalTokens === null && tokensPerMin === null) return null
|
|
842
995
|
|
|
843
996
|
return {
|
|
844
997
|
model,
|
|
998
|
+
provider: normalizeProviderName(
|
|
999
|
+
pickString(
|
|
1000
|
+
usageRecord?.provider,
|
|
1001
|
+
usageRecord?.providerName,
|
|
1002
|
+
usageRecord?.provider_name,
|
|
1003
|
+
usageRecord?.usage?.provider,
|
|
1004
|
+
usageRecord?.usage?.providerName,
|
|
1005
|
+
usageRecord?.usage?.provider_name,
|
|
1006
|
+
usageTotals?.provider,
|
|
1007
|
+
usageTotals?.providerName,
|
|
1008
|
+
usageTotals?.provider_name,
|
|
1009
|
+
parsed?.provider,
|
|
1010
|
+
parsed?.providerName,
|
|
1011
|
+
parsed?.provider_name,
|
|
1012
|
+
parsed?.status?.provider,
|
|
1013
|
+
parsed?.status?.providerName,
|
|
1014
|
+
parsed?.status?.provider_name
|
|
1015
|
+
)
|
|
1016
|
+
) ?? inferProviderFromModel(model),
|
|
845
1017
|
totalTokens,
|
|
846
1018
|
tokensPerMin,
|
|
1019
|
+
remainingTokens,
|
|
1020
|
+
percentUsed,
|
|
1021
|
+
contextTokens,
|
|
847
1022
|
sessionId: pickString(
|
|
848
1023
|
parsed?.sessionId,
|
|
849
1024
|
parsed?.session_id,
|
|
@@ -935,6 +1110,7 @@ function parseGenericUsage(parsed) {
|
|
|
935
1110
|
parsed?.status?.usage_time,
|
|
936
1111
|
parsed?.status?.usageTsMs
|
|
937
1112
|
),
|
|
1113
|
+
budgetKind: contextTokens !== null ? 'context-window' : null,
|
|
938
1114
|
integrationStatus: 'ok'
|
|
939
1115
|
}
|
|
940
1116
|
}
|
|
@@ -947,6 +1123,8 @@ function usageCandidateScore(usage) {
|
|
|
947
1123
|
if (usage.model !== null) score += 2
|
|
948
1124
|
if (usage.totalTokens !== null) score += 2
|
|
949
1125
|
if (usage.tokensPerMin !== null) score += 1
|
|
1126
|
+
if (usage.remainingTokens !== null) score += 1
|
|
1127
|
+
if (usage.contextTokens !== null) score += 1
|
|
950
1128
|
if (usage.sessionId !== null) score += 1
|
|
951
1129
|
if (usage.agentId !== null) score += 1
|
|
952
1130
|
if (usage.usageTimestampMs !== null) score += 1
|
package/src/telemetry-mapping.js
CHANGED
|
@@ -26,7 +26,12 @@ export function enrichWithOpenClawFleetTelemetry(sample, context = {}) {
|
|
|
26
26
|
},
|
|
27
27
|
usage: {
|
|
28
28
|
model: sample.openclawModel ?? null,
|
|
29
|
+
provider: sample.openclawProvider ?? null,
|
|
29
30
|
totalTokens: sample.openclawTotalTokens ?? null,
|
|
31
|
+
remainingTokens: sample.openclawRemainingTokens ?? null,
|
|
32
|
+
percentUsed: sample.openclawPercentUsed ?? null,
|
|
33
|
+
contextTokens: sample.openclawContextTokens ?? null,
|
|
34
|
+
budgetKind: sample.openclawBudgetKind ?? null,
|
|
30
35
|
tokensPerMin: sample.tokensPerMin ?? null,
|
|
31
36
|
sessionId: sample.openclawSessionId ?? null,
|
|
32
37
|
agentId: sample.openclawAgentId ?? null,
|
|
Binary file
|
package/tui/src/main.rs
CHANGED
|
@@ -70,6 +70,34 @@ fn command_exists(cmd: &str, args: &[&str]) -> bool {
|
|
|
70
70
|
.unwrap_or(false)
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
fn machine_name() -> String {
|
|
74
|
+
if cfg!(target_os = "macos") {
|
|
75
|
+
if let Ok(output) = Command::new("scutil").args(["--get", "ComputerName"]).output() {
|
|
76
|
+
if output.status.success() {
|
|
77
|
+
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
78
|
+
if !name.is_empty() {
|
|
79
|
+
return name;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if let Ok(output) = Command::new("hostname").output() {
|
|
86
|
+
if output.status.success() {
|
|
87
|
+
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
88
|
+
if !name.is_empty() {
|
|
89
|
+
return name;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
std::env::var("HOSTNAME")
|
|
95
|
+
.ok()
|
|
96
|
+
.map(|value| value.trim().to_string())
|
|
97
|
+
.filter(|value| !value.is_empty())
|
|
98
|
+
.unwrap_or_else(|| "IdleWatch Device".to_string())
|
|
99
|
+
}
|
|
100
|
+
|
|
73
101
|
fn parse_env_file(path: &Path) -> ExistingConfig {
|
|
74
102
|
let mut config = ExistingConfig::default();
|
|
75
103
|
let Ok(raw) = fs::read_to_string(path) else {
|
|
@@ -443,7 +471,7 @@ fn main() -> Result<()> {
|
|
|
443
471
|
let env_file = std::env::var("IDLEWATCH_ENROLL_OUTPUT_ENV_FILE")
|
|
444
472
|
.map(PathBuf::from)
|
|
445
473
|
.unwrap_or_else(|_| config_dir.join("idlewatch.env"));
|
|
446
|
-
let host = sanitize_host(&
|
|
474
|
+
let host = sanitize_host(&machine_name());
|
|
447
475
|
let existing = parse_env_file(&env_file);
|
|
448
476
|
|
|
449
477
|
enable_raw_mode()?;
|
|
@@ -464,7 +492,7 @@ fn main() -> Result<()> {
|
|
|
464
492
|
KeyCode::Char('q') | KeyCode::Esc => {
|
|
465
493
|
disable_raw_mode()?;
|
|
466
494
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
|
467
|
-
return
|
|
495
|
+
return Err(anyhow!("setup_cancelled"));
|
|
468
496
|
}
|
|
469
497
|
_ => {}
|
|
470
498
|
}
|
|
@@ -503,7 +531,7 @@ fn main() -> Result<()> {
|
|
|
503
531
|
KeyCode::Char('q') | KeyCode::Esc => {
|
|
504
532
|
disable_raw_mode()?;
|
|
505
533
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
|
506
|
-
return
|
|
534
|
+
return Err(anyhow!("setup_cancelled"));
|
|
507
535
|
}
|
|
508
536
|
_ => {}
|
|
509
537
|
}
|
|
@@ -545,7 +573,7 @@ fn main() -> Result<()> {
|
|
|
545
573
|
KeyCode::Char('q') | KeyCode::Esc => {
|
|
546
574
|
disable_raw_mode()?;
|
|
547
575
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
|
548
|
-
return
|
|
576
|
+
return Err(anyhow!("setup_cancelled"));
|
|
549
577
|
}
|
|
550
578
|
KeyCode::Char(c) => {
|
|
551
579
|
device_name_input.push(c);
|
|
@@ -592,7 +620,7 @@ fn main() -> Result<()> {
|
|
|
592
620
|
KeyCode::Char('q') | KeyCode::Esc => {
|
|
593
621
|
disable_raw_mode()?;
|
|
594
622
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
|
595
|
-
return
|
|
623
|
+
return Err(anyhow!("setup_cancelled"));
|
|
596
624
|
}
|
|
597
625
|
KeyCode::Char(c) => {
|
|
598
626
|
cloud_api_key_input.push(c);
|
|
@@ -673,6 +701,6 @@ fn main() -> Result<()> {
|
|
|
673
701
|
println!("Saved device name: {}", device_name);
|
|
674
702
|
println!("You can rerun this TUI anytime to update device name, API key, or metrics.");
|
|
675
703
|
println!("Next step: idlewatch-agent --once");
|
|
676
|
-
println!("For background startup on macOS:
|
|
704
|
+
println!("For background startup on macOS: /Applications/IdleWatch.app/Contents/Resources/payload/package/scripts/install-macos-launch-agent.sh");
|
|
677
705
|
Ok(())
|
|
678
706
|
}
|