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 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 (no Firebase write)
27
- - `--once`: collect one sample, publish to Firebase when configured, then exit
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 Firestore write failures
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`
@@ -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\nEnvironment:\n IDLEWATCH_HOST Optional custom host label (default: hostname)\n IDLEWATCH_INTERVAL_MS Sampling interval in ms (default: 10000)\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_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 IDLEWATCH_CLOUD_INGEST_URL Optional cloud ingest endpoint (e.g. https://idlewatch.com/api/ingest)\n IDLEWATCH_CLOUD_API_KEY Cloud API key from idlewatch.com/api for device linking\n IDLEWATCH_REQUIRE_CLOUD_WRITES Require cloud publish path in --once mode: 1|0 (default: 0)\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
+ 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 = path.join(os.homedir(), '.idlewatch', 'idlewatch.env')
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
- console.log('Initial telemetry sample sent successfully.')
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
- console.error(`Retry with: set -a; source "${result.outputEnvFile}"; set +a && idlewatch --once`)
395
- console.error('Or rerun: idlewatch quickstart')
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
- console.error(`Enrollment failed: ${err.message}`)
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
- if (!appReady && !(CLOUD_INGEST_URL && CLOUD_API_KEY)) {
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
- 'Firebase is not configured. Running without Firebase writes. Set FIREBASE_PROJECT_ID + FIREBASE_SERVICE_ACCOUNT_FILE (preferred, or FIREBASE_SERVICE_ACCOUNT_JSON / FIREBASE_SERVICE_ACCOUNT_B64), use FIREBASE_PROJECT_ID + FIRESTORE_EMULATOR_HOST for emulator writes, or run idlewatch quickstart to link cloud ingest.'
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
- console.log(JSON.stringify(row))
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
- console.error(
1371
- `Cloud ingest disabled: API key rejected (${cloudIngestKickoutReason || 'unauthorized'}). Run idlewatch quickstart to link a new key.`
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} firebase=${appReady} localLog=${LOCAL_LOG_PATH} env=${persistedEnv?.envFile || 'process'}`
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} firebase=${appReady} localLog=${LOCAL_LOG_PATH} monitorTargets=${[...MONITOR_TARGETS].join(',')} openclawUsage=${EFFECTIVE_OPENCLAW_MODE} env=${persistedEnv?.envFile || 'process'}`
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.5",
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
- IDLEWATCH_DEVICE_NAME: 'Validator Box',
71
+ IDLEWATCH_ENROLL_DEVICE_NAME: 'Validator Box',
72
72
  IDLEWATCH_DEVICE_ID: 'validator-box',
73
- IDLEWATCH_MONITOR_TARGETS: 'cpu,memory',
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 = os.hostname()) {
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 = os.hostname()) {
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
- return `\n╭───────────────────────────────────────────────╮\n│ IdleWatch Setup Wizard │\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`
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(options.deviceName || process.env.IDLEWATCH_DEVICE_NAME || os.hostname())
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
- console.log(promptModeText())
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
- const modeInput = (await rl.question('\nMode [1/2] (default 1): ')).trim() || '1'
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
- if (!rl) throw new Error('Missing cloud API key (IDLEWATCH_CLOUD_API_KEY).')
221
- console.log('\nPaste the API key from idlewatch.com/api.')
222
- cloudApiKey = normalizeCloudApiKey(await rl.question('Cloud API key: '))
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(options.deviceId || process.env.IDLEWATCH_DEVICE_ID || deviceName, os.hostname())
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
+ }
@@ -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
@@ -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,
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(&std::env::var("HOSTNAME").unwrap_or_else(|_| "host".to_string()));
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 Ok(());
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 Ok(());
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 Ok(());
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 Ok(());
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: npm run install:macos-launch-agent");
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
  }