idlewatch 0.1.6 → 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`)
@@ -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)
@@ -381,22 +433,32 @@ if (quickstartRequested) {
381
433
  const enrolledEnv = parseEnvFileToObject(result.outputEnvFile)
382
434
  const onceRun = spawnSync(process.execPath, [process.argv[1], '--once'], {
383
435
  stdio: 'inherit',
384
- env: {
385
- ...process.env,
386
- ...enrolledEnv
387
- }
436
+ env: buildSetupTestEnv(enrolledEnv)
388
437
  })
389
438
 
390
439
  if (onceRun.status === 0) {
391
440
  console.log(`✅ Setup complete. Mode=${result.mode} device=${result.deviceName} envFile=${result.outputEnvFile}`)
392
- 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
+ }
393
450
  process.exit(0)
394
451
  }
395
452
 
396
453
  console.error(`⚠️ Setup is not finished yet. Mode=${result.mode} device=${result.deviceName} envFile=${result.outputEnvFile}`)
397
454
  console.error('The first required telemetry sample did not publish successfully, so this device may not be linked yet.')
398
- console.error(`Retry with: set -a; source "${result.outputEnvFile}"; set +a && idlewatch --once`)
399
- 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
+ }
400
462
  process.exit(onceRun.status ?? 1)
401
463
  } catch (err) {
402
464
  if (String(err?.message || '') === 'setup_cancelled') {
@@ -638,9 +700,64 @@ const hasAnyFirebaseConfig = Boolean(PROJECT_ID || CREDS_FILE || CREDS_JSON || C
638
700
  const hasCloudConfig = Boolean(CLOUD_INGEST_URL && CLOUD_API_KEY)
639
701
  const shouldWarnAboutMissingPublishConfig = !appReady && !hasCloudConfig && !DRY_RUN && !hasAnyFirebaseConfig
640
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
+
641
758
  if (shouldWarnAboutMissingPublishConfig) {
642
759
  console.error(
643
- 'No publish target is configured yet. Running in local-only mode. Run idlewatch quickstart to link cloud ingest, or configure Firebase/emulator mode if you need that path.'
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.'
644
761
  )
645
762
  }
646
763
 
@@ -1202,7 +1319,9 @@ async function publish(row, retries = 2) {
1202
1319
 
1203
1320
  async function collectSample() {
1204
1321
  const sampleStartMs = Date.now()
1322
+ const dayLoadSummary = buildRollingLoadSummary(LOCAL_LOG_PATH, sampleStartMs)
1205
1323
  const openclawEnabled = EFFECTIVE_OPENCLAW_MODE !== 'off'
1324
+ const activitySummary = openclawEnabled ? loadOpenClawActivitySummary({ nowMs: sampleStartMs }) : null
1206
1325
 
1207
1326
  const disabledProbe = {
1208
1327
  result: 'disabled',
@@ -1322,6 +1441,8 @@ async function collectSample() {
1322
1441
  usageStaleMsThreshold: USAGE_STALE_MS,
1323
1442
  usageNearStaleMsThreshold: USAGE_NEAR_STALE_MS,
1324
1443
  usageStaleGraceMs: USAGE_STALE_GRACE_MS,
1444
+ activitySource: activitySummary?.source ?? (openclawEnabled ? 'unavailable' : 'disabled'),
1445
+ activityWindowMs: activitySummary?.windowMs ?? null,
1325
1446
  memPressureSource: memPressure.source,
1326
1447
  cloudIngestionStatus: CLOUD_INGEST_URL && CLOUD_API_KEY
1327
1448
  ? cloudIngestKickedOut ? 'kicked-out' : 'enabled'
@@ -1346,16 +1467,30 @@ async function collectSample() {
1346
1467
  memPressurePct: MONITOR_MEMORY ? memPressure.pct : null,
1347
1468
  memPressureClass: MONITOR_MEMORY ? memPressure.cls : 'disabled',
1348
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,
1349
1475
  gpuSource: gpu.source,
1350
1476
  gpuConfidence: gpu.confidence,
1351
1477
  gpuSampleWindowMs: gpu.sampleWindowMs,
1352
1478
  tokensPerMin: MONITOR_OPENCLAW ? (usage?.tokensPerMin ?? null) : null,
1353
1479
  openclawModel: MONITOR_OPENCLAW ? (usage?.model ?? null) : null,
1480
+ openclawProvider: MONITOR_OPENCLAW ? (usage?.provider ?? null) : null,
1354
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,
1355
1486
  openclawSessionId: MONITOR_OPENCLAW ? (usage?.sessionId ?? null) : null,
1356
1487
  openclawAgentId: MONITOR_OPENCLAW ? (usage?.agentId ?? null) : null,
1357
1488
  openclawUsageTs: MONITOR_OPENCLAW ? (usage?.usageTimestampMs ?? null) : null,
1358
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 ?? []) : [],
1359
1494
  localLogPath: LOCAL_LOG_PATH,
1360
1495
  localLogBytes: null,
1361
1496
  source
@@ -1369,21 +1504,43 @@ async function collectSample() {
1369
1504
  })
1370
1505
  }
1371
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
+
1372
1523
  async function tick() {
1373
1524
  const row = await collectSample()
1374
1525
  const localUsage = appendLocal(row)
1375
1526
  row.localLogPath = localUsage.path
1376
1527
  row.localLogBytes = localUsage.bytes
1377
1528
 
1378
- 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
+ }
1379
1534
 
1380
1535
  const published = await publish(row)
1381
1536
 
1382
1537
  if (cloudIngestKickedOut && !cloudIngestKickoutNotified) {
1383
1538
  cloudIngestKickoutNotified = true
1384
- console.error(
1385
- `Cloud ingest disabled: API key rejected (${cloudIngestKickoutReason || 'unauthorized'}). Run idlewatch quickstart to link a new key.`
1386
- )
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
+ }
1387
1544
  }
1388
1545
 
1389
1546
  if (REQUIRE_FIREBASE_WRITES && ONCE && !published) {
@@ -1436,7 +1593,7 @@ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
1436
1593
  if (DRY_RUN || ONCE) {
1437
1594
  const mode = DRY_RUN ? 'dry-run' : 'once'
1438
1595
  console.log(
1439
- `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'}`
1440
1597
  )
1441
1598
  tick()
1442
1599
  .then(() => process.exit(0))
@@ -1446,7 +1603,7 @@ if (DRY_RUN || ONCE) {
1446
1603
  })
1447
1604
  } else {
1448
1605
  console.log(
1449
- `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'}`
1450
1607
  )
1451
1608
  loop()
1452
1609
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idlewatch",
3
- "version": "0.1.6",
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
@@ -118,12 +118,51 @@ function cargoAvailable() {
118
118
  }
119
119
 
120
120
  function bundledTuiBinaryPath() {
121
+ const override = String(process.env.IDLEWATCH_TUI_BIN || '').trim()
122
+ if (override) return path.resolve(override)
123
+
121
124
  const platform = process.platform
122
125
  const arch = process.arch
123
126
  const ext = platform === 'win32' ? '.exe' : ''
124
127
  return path.join(PACKAGE_ROOT, 'tui', 'bin', `${platform}-${arch}`, `idlewatch-setup${ext}`)
125
128
  }
126
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
+
127
166
  function tryBundledRustTui({ configDir, outputEnvFile }) {
128
167
  const binPath = bundledTuiBinaryPath()
129
168
  if (!fs.existsSync(binPath)) return { ok: false, reason: 'bundled-binary-missing', binPath }
@@ -179,8 +218,12 @@ function tryRustTui({ configDir, outputEnvFile }) {
179
218
  return { ok: false, reason: `cargo-run-failed:${run.status ?? 'unknown'}`, manifestPath }
180
219
  }
181
220
 
182
- function promptModeText() {
183
- 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`
184
227
  }
185
228
 
186
229
  export async function runEnrollmentWizard(options = {}) {
@@ -189,27 +232,40 @@ export async function runEnrollmentWizard(options = {}) {
189
232
  const configDir = path.resolve(options.configDir || process.env.IDLEWATCH_ENROLL_CONFIG_DIR || defaultConfigDir())
190
233
  const outputEnvFile = path.resolve(options.outputEnvFile || process.env.IDLEWATCH_ENROLL_OUTPUT_ENV_FILE || path.join(configDir, 'idlewatch.env'))
191
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
+
192
241
  let mode = options.mode || process.env.IDLEWATCH_ENROLL_MODE || null
193
242
  let cloudApiKey = normalizeCloudApiKey(options.cloudApiKey || process.env.IDLEWATCH_CLOUD_API_KEY || null)
194
243
  let cloudIngestUrl = options.cloudIngestUrl || process.env.IDLEWATCH_CLOUD_INGEST_URL || 'https://api.idlewatch.com/api/ingest'
195
- let deviceName = normalizeDeviceName(options.deviceName || process.env.IDLEWATCH_DEVICE_NAME || machineName())
244
+ let deviceName = normalizeDeviceName(
245
+ options.deviceName || process.env.IDLEWATCH_ENROLL_DEVICE_NAME || process.env.IDLEWATCH_DEVICE_NAME || (existingConfig?.deviceName) || machineName()
246
+ )
196
247
 
197
248
  const availableMonitorTargets = detectAvailableMonitorTargets()
198
249
  let monitorTargets = normalizeMonitorTargets(
199
- options.monitorTargets || process.env.IDLEWATCH_MONITOR_TARGETS || '',
250
+ options.monitorTargets || process.env.IDLEWATCH_ENROLL_MONITOR_TARGETS || process.env.IDLEWATCH_MONITOR_TARGETS || '',
200
251
  availableMonitorTargets
201
252
  )
202
253
 
203
254
  if (!nonInteractive && !noTui) {
204
255
  const tuiResult = tryRustTui({ configDir, outputEnvFile })
256
+ const tuiEnrollment = parseEnrollmentResultFromEnvFile(outputEnvFile, { configDir, fallbackDeviceName: deviceName })
205
257
  if (tuiResult.ok) {
206
- return {
258
+ return tuiEnrollment || {
207
259
  mode: 'tui',
208
260
  configDir,
209
261
  outputEnvFile
210
262
  }
211
263
  }
212
264
 
265
+ if (tuiEnrollment) {
266
+ return tuiEnrollment
267
+ }
268
+
213
269
  if (tuiResult.reason === 'bundled-binary-missing-and-cargo-missing') {
214
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.')
215
271
  } else if (!['disabled', 'cargo-missing', 'bundled-binary-missing'].includes(tuiResult.reason || '')) {
@@ -220,10 +276,16 @@ export async function runEnrollmentWizard(options = {}) {
220
276
  let rl = null
221
277
  if (!nonInteractive) {
222
278
  rl = readline.createInterface({ input: process.stdin, output: process.stdout })
223
- 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 }))
224
283
  console.log(`Storage path: ${configDir}`)
225
284
  console.log(`Environment file: ${outputEnvFile}`)
226
- 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
227
289
  mode = modeInput === '2' ? 'local' : 'production'
228
290
  const deviceNameInput = (await rl.question(`Device name [${deviceName}]: `)).trim()
229
291
  deviceName = normalizeDeviceName(deviceNameInput || deviceName)
@@ -235,9 +297,36 @@ export async function runEnrollmentWizard(options = {}) {
235
297
  }
236
298
 
237
299
  if ((mode === 'production') && !cloudApiKey) {
238
- if (!rl) throw new Error('Missing cloud API key (IDLEWATCH_CLOUD_API_KEY).')
239
- console.log('\nPaste the API key from idlewatch.com/api.')
240
- 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
+ }
241
330
  }
242
331
 
243
332
  if (!nonInteractive && rl) {
@@ -247,7 +336,10 @@ export async function runEnrollmentWizard(options = {}) {
247
336
  monitorTargets = normalizeMonitorTargets(monitorInput || suggested, availableMonitorTargets)
248
337
  }
249
338
 
250
- const safeDeviceId = sanitizeDeviceId(options.deviceId || process.env.IDLEWATCH_DEVICE_ID || deviceName, machineName())
339
+ const safeDeviceId = sanitizeDeviceId(
340
+ options.deviceId || process.env.IDLEWATCH_ENROLL_DEVICE_ID || deviceName,
341
+ machineName()
342
+ )
251
343
  const localLogPath = path.join(configDir, 'logs', `${safeDeviceId}-metrics.ndjson`)
252
344
  const localCachePath = path.join(configDir, 'cache', `${safeDeviceId}-openclaw-last-good.json`)
253
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
@@ -701,6 +701,6 @@ fn main() -> Result<()> {
701
701
  println!("Saved device name: {}", device_name);
702
702
  println!("You can rerun this TUI anytime to update device name, API key, or metrics.");
703
703
  println!("Next step: idlewatch-agent --once");
704
- 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");
705
705
  Ok(())
706
706
  }