idlewatch 0.1.6 → 0.1.8

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`)
@@ -10,15 +10,17 @@ import { createRequire } from 'module'
10
10
  import { parseOpenClawUsage } from '../src/openclaw-usage.js'
11
11
  import { gpuSampleDarwin } from '../src/gpu.js'
12
12
  import { memUsedPct, memoryPressureDarwin } from '../src/memory.js'
13
+ import { thermalSampleDarwin } from '../src/thermal.js'
13
14
  import { deriveUsageFreshness } from '../src/usage-freshness.js'
14
15
  import { deriveUsageAlert } from '../src/usage-alert.js'
15
16
  import { loadLastGoodUsageSnapshot, persistLastGoodUsageSnapshot } from '../src/openclaw-cache.js'
17
+ import { DAY_WINDOW_MS, loadOpenClawActivitySummary } from '../src/openclaw-activity.js'
16
18
  import { runEnrollmentWizard } from '../src/enrollment.js'
17
19
  import { enrichWithOpenClawFleetTelemetry } from '../src/telemetry-mapping.js'
18
20
  import pkg from '../package.json' with { type: 'json' }
19
21
 
20
22
  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`)
23
+ 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
24
  }
23
25
 
24
26
  const require = createRequire(import.meta.url)
@@ -54,8 +56,17 @@ function resolveEnvPath(value) {
54
56
  return path.resolve(expandSupportedPathVars(value))
55
57
  }
56
58
 
59
+ function defaultPersistedEnvFilePath() {
60
+ return path.join(os.homedir(), '.idlewatch', 'idlewatch.env')
61
+ }
62
+
63
+ function usesDefaultPersistedEnvFile(envFilePath) {
64
+ if (!envFilePath) return false
65
+ return path.resolve(envFilePath) === path.resolve(defaultPersistedEnvFilePath())
66
+ }
67
+
57
68
  function loadPersistedEnvIntoProcess() {
58
- const envFile = path.join(os.homedir(), '.idlewatch', 'idlewatch.env')
69
+ const envFile = defaultPersistedEnvFilePath()
59
70
  if (!fs.existsSync(envFile)) return null
60
71
 
61
72
  try {
@@ -71,11 +82,35 @@ function loadPersistedEnvIntoProcess() {
71
82
  }
72
83
  }
73
84
 
85
+ function buildSetupTestEnv(enrolledEnv) {
86
+ const nextEnv = { ...process.env }
87
+
88
+ for (const key of Object.keys(nextEnv)) {
89
+ if (
90
+ key.startsWith('IDLEWATCH_') ||
91
+ key.startsWith('FIREBASE_') ||
92
+ key === 'GOOGLE_APPLICATION_CREDENTIALS'
93
+ ) {
94
+ delete nextEnv[key]
95
+ }
96
+ }
97
+
98
+ for (const [key, value] of Object.entries(enrolledEnv || {})) {
99
+ nextEnv[key] = key.endsWith('_PATH') ? expandSupportedPathVars(value) : value
100
+ }
101
+
102
+ nextEnv.IDLEWATCH_SETUP_VERIFY = '1'
103
+ return nextEnv
104
+ }
105
+
74
106
  const persistedEnv = loadPersistedEnvIntoProcess()
75
107
 
108
+ const OPENCLAW_AGENT_TARGETS = ['agent_activity', 'token_usage', 'runtime_state']
109
+ const OPENCLAW_DERIVED_TARGETS = [...OPENCLAW_AGENT_TARGETS]
110
+
76
111
  function parseMonitorTargets(raw) {
77
- const allowed = new Set(['cpu', 'memory', 'gpu', 'openclaw'])
78
- const fallback = ['cpu', 'memory', 'openclaw', 'gpu']
112
+ const allowed = new Set(['cpu', 'memory', 'gpu', 'temperature', 'openclaw', ...OPENCLAW_DERIVED_TARGETS])
113
+ const fallback = ['cpu', 'memory', 'gpu', 'temperature', ...OPENCLAW_DERIVED_TARGETS]
79
114
 
80
115
  if (!raw || typeof raw !== 'string') {
81
116
  return new Set(fallback)
@@ -85,6 +120,7 @@ function parseMonitorTargets(raw) {
85
120
  .split(',')
86
121
  .map((item) => item.trim().toLowerCase())
87
122
  .filter((item) => allowed.has(item))
123
+ .flatMap((item) => (item === 'openclaw' ? OPENCLAW_DERIVED_TARGETS : [item]))
88
124
 
89
125
  if (parsed.length === 0) return new Set(fallback)
90
126
  return new Set(parsed)
@@ -203,6 +239,34 @@ function buildLocalDashboardPayload(logPath) {
203
239
  }
204
240
  }
205
241
 
242
+ function buildRollingLoadSummary(logPath, nowMs = Date.now(), windowMs = DAY_WINDOW_MS) {
243
+ const rows = parseLocalRows(logPath, 20000).filter((row) => Number(row.ts || 0) >= nowMs - windowMs)
244
+ if (rows.length === 0) return null
245
+
246
+ const average = (values) => {
247
+ const valid = values.filter((value) => Number.isFinite(value))
248
+ if (valid.length === 0) return null
249
+ return Number((valid.reduce((sum, value) => sum + value, 0) / valid.length).toFixed(2))
250
+ }
251
+
252
+ const maximum = (values) => {
253
+ const valid = values.filter((value) => Number.isFinite(value))
254
+ if (valid.length === 0) return null
255
+ return Number(Math.max(...valid).toFixed(1))
256
+ }
257
+
258
+ return {
259
+ windowMs,
260
+ sampleCount: rows.length,
261
+ cpuAvgPct: average(rows.map((row) => Number(row.cpuPct))),
262
+ memAvgPct: average(rows.map((row) => Number(row.memPct))),
263
+ gpuAvgPct: average(rows.map((row) => Number(row.gpuPct))),
264
+ tempAvgC: average(rows.map((row) => Number(row.deviceTempC))),
265
+ tempMaxC: maximum(rows.map((row) => Number(row.deviceTempC))),
266
+ tokensAvgPerMin: average(rows.map((row) => Number(row.tokensPerMin)))
267
+ }
268
+ }
269
+
206
270
  function renderLocalDashboardHtml() {
207
271
  return `<!doctype html>
208
272
  <html>
@@ -355,10 +419,11 @@ function runLocalDashboard({ host }) {
355
419
 
356
420
  const argv = process.argv.slice(2)
357
421
  const args = new Set(argv)
422
+ const statusRequested = argv[0] === 'status' || argv.includes('--status')
358
423
  const dashboardRequested = argv[0] === 'dashboard' || argv.includes('--dashboard')
359
424
  const runRequested = argv[0] === 'run' || argv.includes('--run')
360
425
  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)
426
+ const quickstartRequested = argv[0] === 'quickstart' || argv[0] === 'configure' || argv.includes('--quickstart') || argv.includes('--configure') || (interactiveDefaultRequested && !dashboardRequested && !runRequested && !statusRequested)
362
427
  if (args.has('--help') || args.has('-h')) {
363
428
  printHelp()
364
429
  process.exit(0)
@@ -381,22 +446,32 @@ if (quickstartRequested) {
381
446
  const enrolledEnv = parseEnvFileToObject(result.outputEnvFile)
382
447
  const onceRun = spawnSync(process.execPath, [process.argv[1], '--once'], {
383
448
  stdio: 'inherit',
384
- env: {
385
- ...process.env,
386
- ...enrolledEnv
387
- }
449
+ env: buildSetupTestEnv(enrolledEnv)
388
450
  })
389
451
 
390
452
  if (onceRun.status === 0) {
391
453
  console.log(`✅ Setup complete. Mode=${result.mode} device=${result.deviceName} envFile=${result.outputEnvFile}`)
392
- console.log('Initial telemetry sample sent successfully.')
454
+ if (result.mode === 'local') {
455
+ console.log('Initial local telemetry check completed successfully.')
456
+ } else {
457
+ console.log('Initial telemetry sample sent successfully.')
458
+ }
459
+ console.log('To keep this device online continuously, run: idlewatch run')
460
+ if (process.platform === 'darwin') {
461
+ console.log('On macOS, you can also enable login startup with the bundled LaunchAgent install script.')
462
+ }
393
463
  process.exit(0)
394
464
  }
395
465
 
396
466
  console.error(`⚠️ Setup is not finished yet. Mode=${result.mode} device=${result.deviceName} envFile=${result.outputEnvFile}`)
397
467
  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')
468
+ if (usesDefaultPersistedEnvFile(result.outputEnvFile)) {
469
+ console.error('Retry with: idlewatch --once')
470
+ console.error('Or rerun: idlewatch quickstart')
471
+ } else {
472
+ console.error('Retry with: idlewatch quickstart')
473
+ console.error(`Use the saved config directly: set -a; source "${result.outputEnvFile}"; set +a && idlewatch --once`)
474
+ }
400
475
  process.exit(onceRun.status ?? 1)
401
476
  } catch (err) {
402
477
  if (String(err?.message || '') === 'setup_cancelled') {
@@ -431,8 +506,12 @@ const MONITOR_TARGETS = parseMonitorTargets(process.env.IDLEWATCH_MONITOR_TARGET
431
506
  const MONITOR_CPU = MONITOR_TARGETS.has('cpu')
432
507
  const MONITOR_MEMORY = MONITOR_TARGETS.has('memory')
433
508
  const MONITOR_GPU = MONITOR_TARGETS.has('gpu')
434
- const MONITOR_OPENCLAW = MONITOR_TARGETS.has('openclaw')
435
- const EFFECTIVE_OPENCLAW_MODE = MONITOR_OPENCLAW ? OPENCLAW_USAGE_MODE : 'off'
509
+ const MONITOR_TEMPERATURE = MONITOR_TARGETS.has('temperature')
510
+ const MONITOR_AGENT_ACTIVITY = MONITOR_TARGETS.has('agent_activity')
511
+ const MONITOR_TOKEN_USAGE = MONITOR_TARGETS.has('token_usage')
512
+ const MONITOR_RUNTIME_STATE = MONITOR_TARGETS.has('runtime_state')
513
+ const MONITOR_OPENCLAW_USAGE = MONITOR_TOKEN_USAGE || MONITOR_RUNTIME_STATE
514
+ const EFFECTIVE_OPENCLAW_MODE = MONITOR_OPENCLAW_USAGE ? OPENCLAW_USAGE_MODE : 'off'
436
515
  const REQUIRE_FIREBASE_WRITES = process.env.IDLEWATCH_REQUIRE_FIREBASE_WRITES === '1'
437
516
  const CLOUD_INGEST_URL = (process.env.IDLEWATCH_CLOUD_INGEST_URL || '').trim()
438
517
  const CLOUD_API_KEY = (process.env.IDLEWATCH_CLOUD_API_KEY || '').trim().replace(/^['"]|['"]$/g, '')
@@ -638,9 +717,64 @@ const hasAnyFirebaseConfig = Boolean(PROJECT_ID || CREDS_FILE || CREDS_JSON || C
638
717
  const hasCloudConfig = Boolean(CLOUD_INGEST_URL && CLOUD_API_KEY)
639
718
  const shouldWarnAboutMissingPublishConfig = !appReady && !hasCloudConfig && !DRY_RUN && !hasAnyFirebaseConfig
640
719
 
720
+ function getPublishModeLabel() {
721
+ if (hasCloudConfig) return 'cloud'
722
+ if (appReady) return 'firebase'
723
+ return 'local-only'
724
+ }
725
+
726
+ if (statusRequested) {
727
+ const envFile = defaultPersistedEnvFilePath()
728
+ const hasConfig = fs.existsSync(envFile)
729
+ const publishMode = getPublishModeLabel()
730
+
731
+ console.log('IdleWatch status')
732
+ console.log('')
733
+ console.log(` Device: ${DEVICE_NAME}`)
734
+ console.log(` Device ID: ${DEVICE_ID}`)
735
+ console.log(` Publish mode: ${publishMode}`)
736
+ if (hasCloudConfig) {
737
+ console.log(` Cloud link: ${CLOUD_INGEST_URL}`)
738
+ console.log(` API key: ${CLOUD_API_KEY.slice(0, 8)}..${CLOUD_API_KEY.slice(-4)}`)
739
+ }
740
+ console.log(` Metrics: ${[...MONITOR_TARGETS].join(', ')}`)
741
+ console.log(` Local log: ${LOCAL_LOG_PATH || '(none)'}`)
742
+ console.log(` Config: ${hasConfig ? envFile : '(no saved config)'}`)
743
+
744
+ let hasSamples = false
745
+ if (LOCAL_LOG_PATH && fs.existsSync(LOCAL_LOG_PATH)) {
746
+ try {
747
+ const stat = fs.statSync(LOCAL_LOG_PATH)
748
+ console.log(` Log size: ${formatBytes(stat.size)}`)
749
+ const rows = parseLocalRows(LOCAL_LOG_PATH, 1)
750
+ if (rows.length > 0 && rows[0].ts) {
751
+ hasSamples = true
752
+ const ageMs = Date.now() - Number(rows[0].ts)
753
+ const ageSec = Math.round(ageMs / 1000)
754
+ 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`
755
+ console.log(` Last sample: ${agoText}`)
756
+ } else {
757
+ console.log(' Last sample: (none yet)')
758
+ }
759
+ } catch { /* ignore stat errors */ }
760
+ } else if (hasConfig) {
761
+ console.log(' Last sample: (none yet)')
762
+ }
763
+
764
+ console.log('')
765
+ if (!hasConfig) {
766
+ console.log(' Run idlewatch quickstart to set up this device.')
767
+ } else if (!hasSamples) {
768
+ console.log(' Run idlewatch --once to collect a test sample, or idlewatch run for continuous monitoring.')
769
+ } else {
770
+ console.log(' Run idlewatch configure to change device name, metrics, or API key.')
771
+ }
772
+ process.exit(0)
773
+ }
774
+
641
775
  if (shouldWarnAboutMissingPublishConfig) {
642
776
  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.'
777
+ '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
778
  )
645
779
  }
646
780
 
@@ -1202,7 +1336,9 @@ async function publish(row, retries = 2) {
1202
1336
 
1203
1337
  async function collectSample() {
1204
1338
  const sampleStartMs = Date.now()
1205
- const openclawEnabled = EFFECTIVE_OPENCLAW_MODE !== 'off'
1339
+ const dayLoadSummary = buildRollingLoadSummary(LOCAL_LOG_PATH, sampleStartMs)
1340
+ const openclawUsageEnabled = EFFECTIVE_OPENCLAW_MODE !== 'off'
1341
+ const activitySummary = MONITOR_AGENT_ACTIVITY ? loadOpenClawActivitySummary({ nowMs: sampleStartMs }) : null
1206
1342
 
1207
1343
  const disabledProbe = {
1208
1344
  result: 'disabled',
@@ -1215,7 +1351,7 @@ async function collectSample() {
1215
1351
  fallbackCacheSource: null
1216
1352
  }
1217
1353
 
1218
- let usageProbe = openclawEnabled ? loadOpenClawUsage() : { usage: null, probe: disabledProbe }
1354
+ let usageProbe = openclawUsageEnabled ? loadOpenClawUsage() : { usage: null, probe: disabledProbe }
1219
1355
  let usage = usageProbe.usage
1220
1356
  let usageFreshness = deriveUsageFreshness(usage, sampleStartMs, USAGE_STALE_MS, USAGE_NEAR_STALE_MS, USAGE_STALE_GRACE_MS)
1221
1357
  let usageRefreshAttempted = false
@@ -1227,7 +1363,7 @@ async function collectSample() {
1227
1363
  const shouldRefreshForNearStale = USAGE_REFRESH_ON_NEAR_STALE === 1 && usageFreshness.isNearStale
1228
1364
  const canRefreshFromCurrentState = usageProbe.probe.result === 'ok' || usageProbe.probe.result === 'fallback-cache'
1229
1365
 
1230
- if (openclawEnabled && usage && (usageFreshness.isPastStaleThreshold || shouldRefreshForNearStale) && canRefreshFromCurrentState) {
1366
+ if (openclawUsageEnabled && usage && (usageFreshness.isPastStaleThreshold || shouldRefreshForNearStale) && canRefreshFromCurrentState) {
1231
1367
  usageRefreshAttempted = true
1232
1368
  usageRefreshStartMs = Date.now()
1233
1369
 
@@ -1271,6 +1407,9 @@ async function collectSample() {
1271
1407
  : { pct: null, cls: 'disabled', source: 'disabled' }
1272
1408
 
1273
1409
  const usedMemPct = MONITOR_MEMORY ? memUsedPct() : null
1410
+ const thermals = MONITOR_TEMPERATURE && process.platform === 'darwin'
1411
+ ? thermalSampleDarwin()
1412
+ : { tempC: null, source: 'disabled', thermalLevel: null, thermalState: MONITOR_TEMPERATURE ? 'unavailable' : 'disabled' }
1274
1413
 
1275
1414
  const usageIntegrationStatus = usage
1276
1415
  ? usageFreshness.isStale
@@ -1278,20 +1417,20 @@ async function collectSample() {
1278
1417
  : usage?.integrationStatus === 'partial'
1279
1418
  ? 'ok'
1280
1419
  : (usage?.integrationStatus ?? 'ok')
1281
- : (openclawEnabled ? 'unavailable' : 'disabled')
1420
+ : (openclawUsageEnabled ? 'unavailable' : 'disabled')
1282
1421
 
1283
1422
  const source = {
1284
1423
  monitorTargets: [...MONITOR_TARGETS],
1285
- usage: usage ? 'openclaw' : openclawEnabled ? 'unavailable' : 'disabled',
1424
+ usage: usage ? 'openclaw' : openclawUsageEnabled ? 'unavailable' : 'disabled',
1286
1425
  usageIntegrationStatus,
1287
- usageIngestionStatus: openclawEnabled
1426
+ usageIngestionStatus: openclawUsageEnabled
1288
1427
  ? usage && ['ok', 'fallback-cache'].includes(usageProbe.probe.result)
1289
1428
  ? 'ok'
1290
1429
  : 'unavailable'
1291
1430
  : 'disabled',
1292
1431
  usageActivityStatus: usage
1293
1432
  ? usageFreshness.freshnessState
1294
- : (openclawEnabled ? 'unavailable' : 'disabled'),
1433
+ : (openclawUsageEnabled ? 'unavailable' : 'disabled'),
1295
1434
  usageProbeResult: usageProbe.probe.result,
1296
1435
  usageProbeAttempts: usageProbe.probe.attempts,
1297
1436
  usageProbeSweeps: usageProbe.probe.sweeps,
@@ -1302,7 +1441,7 @@ async function collectSample() {
1302
1441
  usageUsedFallbackCache: usageProbe.probe.usedFallbackCache,
1303
1442
  usageFallbackCacheAgeMs: usageProbe.probe.fallbackAgeMs,
1304
1443
  usageFallbackCacheSource: usageProbe.probe.fallbackCacheSource,
1305
- usageFreshnessState: openclawEnabled
1444
+ usageFreshnessState: openclawUsageEnabled
1306
1445
  ? usage
1307
1446
  ? usageFreshness.freshnessState
1308
1447
  : null
@@ -1322,6 +1461,10 @@ async function collectSample() {
1322
1461
  usageStaleMsThreshold: USAGE_STALE_MS,
1323
1462
  usageNearStaleMsThreshold: USAGE_NEAR_STALE_MS,
1324
1463
  usageStaleGraceMs: USAGE_STALE_GRACE_MS,
1464
+ activitySource: activitySummary?.source ?? (MONITOR_AGENT_ACTIVITY ? 'unavailable' : 'disabled'),
1465
+ activityWindowMs: MONITOR_AGENT_ACTIVITY ? (activitySummary?.windowMs ?? null) : null,
1466
+ thermalSource: thermals.source,
1467
+ thermalState: thermals.thermalState,
1325
1468
  memPressureSource: memPressure.source,
1326
1469
  cloudIngestionStatus: CLOUD_INGEST_URL && CLOUD_API_KEY
1327
1470
  ? cloudIngestKickedOut ? 'kicked-out' : 'enabled'
@@ -1346,16 +1489,37 @@ async function collectSample() {
1346
1489
  memPressurePct: MONITOR_MEMORY ? memPressure.pct : null,
1347
1490
  memPressureClass: MONITOR_MEMORY ? memPressure.cls : 'disabled',
1348
1491
  gpuPct: MONITOR_GPU ? gpu.pct : null,
1492
+ deviceTempC: MONITOR_TEMPERATURE ? thermals.tempC : null,
1493
+ thermalLevel: MONITOR_TEMPERATURE ? thermals.thermalLevel : null,
1494
+ thermalState: MONITOR_TEMPERATURE ? thermals.thermalState : 'disabled',
1495
+ dayWindowMs: dayLoadSummary?.windowMs ?? DAY_WINDOW_MS,
1496
+ dayCpuAvgPct: MONITOR_CPU ? (dayLoadSummary?.cpuAvgPct ?? null) : null,
1497
+ dayMemAvgPct: MONITOR_MEMORY ? (dayLoadSummary?.memAvgPct ?? null) : null,
1498
+ dayGpuAvgPct: MONITOR_GPU ? (dayLoadSummary?.gpuAvgPct ?? null) : null,
1499
+ dayTempAvgC: MONITOR_TEMPERATURE ? (dayLoadSummary?.tempAvgC ?? null) : null,
1500
+ dayTempMaxC: MONITOR_TEMPERATURE ? (dayLoadSummary?.tempMaxC ?? null) : null,
1501
+ dayTokensAvgPerMin: MONITOR_TOKEN_USAGE ? (dayLoadSummary?.tokensAvgPerMin ?? null) : null,
1349
1502
  gpuSource: gpu.source,
1350
1503
  gpuConfidence: gpu.confidence,
1351
1504
  gpuSampleWindowMs: gpu.sampleWindowMs,
1352
- tokensPerMin: MONITOR_OPENCLAW ? (usage?.tokensPerMin ?? null) : null,
1353
- openclawModel: MONITOR_OPENCLAW ? (usage?.model ?? null) : null,
1354
- openclawTotalTokens: MONITOR_OPENCLAW ? (usage?.totalTokens ?? null) : null,
1355
- openclawSessionId: MONITOR_OPENCLAW ? (usage?.sessionId ?? null) : null,
1356
- openclawAgentId: MONITOR_OPENCLAW ? (usage?.agentId ?? null) : null,
1357
- openclawUsageTs: MONITOR_OPENCLAW ? (usage?.usageTimestampMs ?? null) : null,
1358
- openclawUsageAgeMs: MONITOR_OPENCLAW ? usageFreshness.usageAgeMs : null,
1505
+ tokensPerMin: MONITOR_TOKEN_USAGE ? (usage?.tokensPerMin ?? null) : null,
1506
+ openclawModel: MONITOR_RUNTIME_STATE ? (usage?.model ?? null) : null,
1507
+ openclawProvider: MONITOR_RUNTIME_STATE ? (usage?.provider ?? null) : null,
1508
+ openclawTotalTokens: MONITOR_TOKEN_USAGE ? (usage?.totalTokens ?? null) : null,
1509
+ openclawInputTokens: MONITOR_TOKEN_USAGE ? (usage?.inputTokens ?? null) : null,
1510
+ openclawOutputTokens: MONITOR_TOKEN_USAGE ? (usage?.outputTokens ?? null) : null,
1511
+ openclawRemainingTokens: openclawUsageEnabled ? (usage?.remainingTokens ?? null) : null,
1512
+ openclawPercentUsed: openclawUsageEnabled ? (usage?.percentUsed ?? null) : null,
1513
+ openclawContextTokens: openclawUsageEnabled ? (usage?.contextTokens ?? null) : null,
1514
+ openclawBudgetKind: openclawUsageEnabled ? (usage?.budgetKind ?? null) : null,
1515
+ openclawSessionId: openclawUsageEnabled ? (usage?.sessionId ?? null) : null,
1516
+ openclawAgentId: openclawUsageEnabled ? (usage?.agentId ?? null) : null,
1517
+ openclawUsageTs: openclawUsageEnabled ? (usage?.usageTimestampMs ?? null) : null,
1518
+ openclawUsageAgeMs: openclawUsageEnabled ? usageFreshness.usageAgeMs : null,
1519
+ activityWindowMs: MONITOR_AGENT_ACTIVITY ? (activitySummary?.windowMs ?? null) : null,
1520
+ activityActiveSeconds: MONITOR_AGENT_ACTIVITY ? (activitySummary?.totalActiveSeconds ?? null) : null,
1521
+ activityIdleSeconds: MONITOR_AGENT_ACTIVITY ? (activitySummary?.idleSeconds ?? null) : null,
1522
+ activityJobs: MONITOR_AGENT_ACTIVITY ? (activitySummary?.jobs ?? []) : [],
1359
1523
  localLogPath: LOCAL_LOG_PATH,
1360
1524
  localLogBytes: null,
1361
1525
  source
@@ -1369,21 +1533,43 @@ async function collectSample() {
1369
1533
  })
1370
1534
  }
1371
1535
 
1536
+ function summarizeSetupVerification(row) {
1537
+ const metrics = []
1538
+ if (row.cpuPct !== null && row.cpuPct !== undefined) metrics.push('cpu')
1539
+ if (row.memPct !== null && row.memPct !== undefined) metrics.push('memory')
1540
+ if (row.gpuPct !== null && row.gpuPct !== undefined) metrics.push('gpu')
1541
+ if (row.tokensPerMin !== null && row.tokensPerMin !== undefined) metrics.push('openclaw')
1542
+
1543
+ const details = [
1544
+ `mode=${getPublishModeLabel()}`,
1545
+ `metrics=${metrics.length ? metrics.join(',') : 'none'}`
1546
+ ]
1547
+
1548
+ if (row.localLogPath) details.push(`localLog=${row.localLogPath}`)
1549
+ return `Initial sample ready (${details.join(' ')})`
1550
+ }
1551
+
1372
1552
  async function tick() {
1373
1553
  const row = await collectSample()
1374
1554
  const localUsage = appendLocal(row)
1375
1555
  row.localLogPath = localUsage.path
1376
1556
  row.localLogBytes = localUsage.bytes
1377
1557
 
1378
- console.log(JSON.stringify(row))
1558
+ if (process.env.IDLEWATCH_SETUP_VERIFY === '1') {
1559
+ console.log(summarizeSetupVerification(row))
1560
+ } else {
1561
+ console.log(JSON.stringify(row))
1562
+ }
1379
1563
 
1380
1564
  const published = await publish(row)
1381
1565
 
1382
1566
  if (cloudIngestKickedOut && !cloudIngestKickoutNotified) {
1383
1567
  cloudIngestKickoutNotified = true
1384
- console.error(
1385
- `Cloud ingest disabled: API key rejected (${cloudIngestKickoutReason || 'unauthorized'}). Run idlewatch quickstart to link a new key.`
1386
- )
1568
+ if (!(REQUIRE_CLOUD_WRITES && ONCE)) {
1569
+ console.error(
1570
+ `Cloud ingest disabled: API key rejected (${cloudIngestKickoutReason || 'unauthorized'}). Run idlewatch quickstart to link a new key.`
1571
+ )
1572
+ }
1387
1573
  }
1388
1574
 
1389
1575
  if (REQUIRE_FIREBASE_WRITES && ONCE && !published) {
@@ -1436,7 +1622,7 @@ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
1436
1622
  if (DRY_RUN || ONCE) {
1437
1623
  const mode = DRY_RUN ? 'dry-run' : 'once'
1438
1624
  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'}`
1625
+ `idlewatch ${mode} host=${HOST} device=${DEVICE_NAME} deviceId=${DEVICE_ID} intervalMs=${INTERVAL_MS} publish=${getPublishModeLabel()} localLog=${LOCAL_LOG_PATH} env=${persistedEnv?.envFile || 'process'}`
1440
1626
  )
1441
1627
  tick()
1442
1628
  .then(() => process.exit(0))
@@ -1446,7 +1632,7 @@ if (DRY_RUN || ONCE) {
1446
1632
  })
1447
1633
  } else {
1448
1634
  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'}`
1635
+ `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
1636
  )
1451
1637
  loop()
1452
1638
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idlewatch",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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
@@ -92,6 +92,9 @@ function validateRow(row) {
92
92
  assert.equal(typeof row.gpuSource, 'string', 'gpuSource must be string')
93
93
  assert.ok(['high', 'medium', 'low', 'none'].includes(row.gpuConfidence), 'gpuConfidence invalid')
94
94
  assertNumberOrNull(row.gpuSampleWindowMs, 'gpuSampleWindowMs')
95
+ assertNumberOrNull(row.deviceTempC, 'deviceTempC')
96
+ assertNumberOrNull(row.dayTempAvgC, 'dayTempAvgC')
97
+ assertNumberOrNull(row.dayTempMaxC, 'dayTempMaxC')
95
98
 
96
99
  assertNumberOrNull(row.tokensPerMin, 'tokensPerMin')
97
100
  assert.ok(row.openclawModel === null || typeof row.openclawModel === 'string', 'openclawModel must be string or null')
@@ -115,6 +118,7 @@ function validateRow(row) {
115
118
  assertNumberOrNull(row.fleet.resources.memUsedPct, 'fleet.resources.memUsedPct')
116
119
  assertNumberOrNull(row.fleet.resources.memPressurePct, 'fleet.resources.memPressurePct')
117
120
  assert.ok(['normal', 'warning', 'critical', 'unavailable'].includes(row.fleet.resources.memPressureClass), 'fleet.resources.memPressureClass invalid')
121
+ assertNumberOrNull(row.fleet.resources.tempC, 'fleet.resources.tempC')
118
122
  assert.equal(typeof row.fleet.usage, 'object', 'fleet.usage must exist')
119
123
  assert.ok(row.fleet.usage.model === null || typeof row.fleet.usage.model === 'string', 'fleet.usage.model invalid')
120
124
  assertNumberOrNull(row.fleet.usage.totalTokens, 'fleet.usage.totalTokens')
@@ -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