idlewatch 0.1.1 → 0.1.2

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.
Files changed (72) hide show
  1. package/README.md +12 -11
  2. package/bin/idlewatch-agent.js +69 -20
  3. package/package.json +12 -1
  4. package/scripts/install-macos-launch-agent.sh +1 -0
  5. package/scripts/validate-onboarding.mjs +57 -22
  6. package/src/enrollment.js +48 -7
  7. package/tui/src/main.rs +248 -17
  8. package/.github/workflows/ci.yml +0 -99
  9. package/.github/workflows/release-macos-trusted.yml +0 -103
  10. package/docs/onboarding-external.md +0 -58
  11. package/docs/packaging/macos-dmg.md +0 -199
  12. package/docs/packaging/macos-launch-agent.md +0 -70
  13. package/docs/qa/archive/mac-qa-log-2026-02-17.md +0 -5838
  14. package/docs/qa/mac-qa-log.md +0 -2864
  15. package/docs/telemetry/idle-stale-policy.md +0 -57
  16. package/docs/telemetry/openclaw-mapping.md +0 -80
  17. package/test/config.test.mjs +0 -112
  18. package/test/fixtures/gpu-agx.txt +0 -2
  19. package/test/fixtures/gpu-iogpu.txt +0 -2
  20. package/test/fixtures/gpu-top-grep.txt +0 -2
  21. package/test/fixtures/openclaw-fleet-sample-v1.json +0 -68
  22. package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-iso-ts.txt +0 -2
  23. package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-newest.txt +0 -2
  24. package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-string-ts.txt +0 -2
  25. package/test/fixtures/openclaw-mixed-status-then-generic-output.txt +0 -2
  26. package/test/fixtures/openclaw-stats-current-wrapper.json +0 -12
  27. package/test/fixtures/openclaw-stats-current-wrapper2.json +0 -15
  28. package/test/fixtures/openclaw-stats-data-wrapper.json +0 -21
  29. package/test/fixtures/openclaw-stats-nested-session-wrapper.json +0 -23
  30. package/test/fixtures/openclaw-stats-payload-wrapper.json +0 -1
  31. package/test/fixtures/openclaw-stats-status-current-wrapper.json +0 -19
  32. package/test/fixtures/openclaw-stats.json +0 -17
  33. package/test/fixtures/openclaw-status-ansi-complex-noise.txt +0 -3
  34. package/test/fixtures/openclaw-status-ansi-noise.txt +0 -2
  35. package/test/fixtures/openclaw-status-control-noise.txt +0 -1
  36. package/test/fixtures/openclaw-status-data-wrapper.json +0 -20
  37. package/test/fixtures/openclaw-status-dcs-noise.txt +0 -1
  38. package/test/fixtures/openclaw-status-epoch-seconds.json +0 -15
  39. package/test/fixtures/openclaw-status-mixed-noise.txt +0 -1
  40. package/test/fixtures/openclaw-status-multi-json.txt +0 -3
  41. package/test/fixtures/openclaw-status-nested-recent.json +0 -19
  42. package/test/fixtures/openclaw-status-noisy-default-then-usage.txt +0 -2
  43. package/test/fixtures/openclaw-status-noisy.txt +0 -3
  44. package/test/fixtures/openclaw-status-osc-noise.txt +0 -1
  45. package/test/fixtures/openclaw-status-result-session.json +0 -15
  46. package/test/fixtures/openclaw-status-session-map-with-defaults.json +0 -23
  47. package/test/fixtures/openclaw-status-session-map.json +0 -28
  48. package/test/fixtures/openclaw-status-session-model-name.json +0 -18
  49. package/test/fixtures/openclaw-status-snake-session-wrapper.json +0 -13
  50. package/test/fixtures/openclaw-status-stats-current-sessions-snake-tokens.json +0 -25
  51. package/test/fixtures/openclaw-status-stats-current-sessions.json +0 -28
  52. package/test/fixtures/openclaw-status-stats-current-usage-time-camelcase.json +0 -19
  53. package/test/fixtures/openclaw-status-stats-session-default-model.json +0 -27
  54. package/test/fixtures/openclaw-status-status-wrapper.json +0 -13
  55. package/test/fixtures/openclaw-status-strings.json +0 -38
  56. package/test/fixtures/openclaw-status-ts-ms-alias.json +0 -14
  57. package/test/fixtures/openclaw-status-updated-at-ms-alias.json +0 -14
  58. package/test/fixtures/openclaw-status-usage-timestamp-ms-alias.json +0 -14
  59. package/test/fixtures/openclaw-status-usage-ts-alias.json +0 -14
  60. package/test/fixtures/openclaw-status-wrap-session-object.json +0 -24
  61. package/test/fixtures/openclaw-status.json +0 -41
  62. package/test/fixtures/openclaw-usage-model-name-generic.json +0 -9
  63. package/test/gpu.test.mjs +0 -58
  64. package/test/memory.test.mjs +0 -35
  65. package/test/openclaw-cache.test.mjs +0 -48
  66. package/test/openclaw-env.test.mjs +0 -365
  67. package/test/openclaw-usage.test.mjs +0 -555
  68. package/test/telemetry-mapping.test.mjs +0 -69
  69. package/test/telemetry-row-parser.test.mjs +0 -44
  70. package/test/usage-alert.test.mjs +0 -73
  71. package/test/usage-freshness.test.mjs +0 -63
  72. package/test/validate-dry-run-schema.test.mjs +0 -146
package/README.md CHANGED
@@ -1,20 +1,20 @@
1
- # idlewatch-skill
1
+ # idlewatch
2
2
 
3
3
  Telemetry collector for IdleWatch.
4
4
 
5
5
  ## Install / Run
6
6
 
7
7
  ```bash
8
- npm install
9
- npm start
8
+ npm install -g idlewatch
9
+ idlewatch --help
10
10
  ```
11
11
 
12
- With npx (after publish):
12
+ Or run it directly with npx:
13
13
 
14
14
  ```bash
15
- npx idlewatch-skill --help
16
- npx idlewatch-skill quickstart
17
- npx idlewatch-skill --dry-run
15
+ npx idlewatch --help
16
+ npx idlewatch quickstart
17
+ npx idlewatch --dry-run
18
18
  ```
19
19
 
20
20
  `quickstart` runs a first-run setup wizard that writes a local env file and (for production mode) stores a locked-down copy of the service-account key under `~/.idlewatch/`. On hosts with Rust/Cargo available, quickstart launches a ratatui-powered onboarding flow first; otherwise it falls back to the text wizard.
@@ -54,7 +54,7 @@ Use `gpuSource` + `gpuConfidence` in dashboards to decide whether to trust value
54
54
  ### Recommended: guided enrollment (external users)
55
55
 
56
56
  ```bash
57
- npx idlewatch-skill quickstart
57
+ npx idlewatch quickstart
58
58
  ```
59
59
 
60
60
  The wizard supports:
@@ -62,13 +62,14 @@ The wizard supports:
62
62
  - **Emulator mode**: writes `FIREBASE_PROJECT_ID` + `FIRESTORE_EMULATOR_HOST` only.
63
63
  - **Local-only mode**: writes no Firebase credentials.
64
64
 
65
- Then load generated env and run:
65
+ Then run a one-shot publish check:
66
66
 
67
67
  ```bash
68
- set -a; source "$HOME/.idlewatch/idlewatch.env"; set +a
69
- idlewatch-agent --once
68
+ idlewatch --once
70
69
  ```
71
70
 
71
+ The saved config is auto-loaded from `~/.idlewatch/idlewatch.env`, so you should not need to manually source the env file in normal use.
72
+
72
73
  ### Manual wiring
73
74
 
74
75
  ```bash
@@ -18,7 +18,7 @@ import { enrichWithOpenClawFleetTelemetry } from '../src/telemetry-mapping.js'
18
18
  import pkg from '../package.json' with { type: 'json' }
19
19
 
20
20
  function printHelp() {
21
- console.log(`idlewatch-agent\n\nUsage:\n idlewatch-agent [quickstart|dashboard] [--dry-run] [--once] [--help]\n\nOptions:\n quickstart Run first-run enrollment wizard and generate secure env config\n dashboard Launch local dashboard from local IdleWatch logs\n --dry-run Collect and print one telemetry sample, then exit without Firebase writes\n --once Collect and publish one telemetry sample, then exit\n --help Show this help message\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_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 IDLEWATCH_CLOUD_INGEST_URL Optional cloud ingest endpoint (e.g. https://idlewatch.com/api/ingest)\n IDLEWATCH_CLOUD_API_KEY Cloud API key from dashboard for device linking\n IDLEWATCH_REQUIRE_CLOUD_WRITES Require cloud publish path in --once mode: 1|0 (default: 0)\n`)
21
+ console.log(`idlewatch\n\nUsage:\n idlewatch [quickstart|configure|dashboard|run] [--dry-run] [--once] [--help]\n\nOptions:\n quickstart Run first-run setup TUI 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 --dry-run Collect and print one telemetry sample, then exit without cloud/Firebase writes\n --once Collect and publish one telemetry sample, then exit\n --help Show this help message\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_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 IDLEWATCH_CLOUD_INGEST_URL Optional cloud ingest endpoint (e.g. https://idlewatch.com/api/ingest)\n IDLEWATCH_CLOUD_API_KEY Cloud API key from dashboard for device linking\n IDLEWATCH_REQUIRE_CLOUD_WRITES Require cloud publish path in --once mode: 1|0 (default: 0)\n`)
22
22
  }
23
23
 
24
24
  const require = createRequire(import.meta.url)
@@ -38,6 +38,41 @@ function parseEnvFileToObject(envFilePath) {
38
38
  return env
39
39
  }
40
40
 
41
+ function expandSupportedPathVars(value) {
42
+ if (typeof value !== 'string' || !value) return value
43
+
44
+ const home = process.env.HOME || os.homedir()
45
+ const tmpdir = process.env.TMPDIR || os.tmpdir()
46
+
47
+ return value
48
+ .replace(/^~(?=$|\/)/, home)
49
+ .replace(/\$\{HOME\}|\$HOME/g, home)
50
+ .replace(/\$\{TMPDIR\}|\$TMPDIR/g, tmpdir)
51
+ }
52
+
53
+ function resolveEnvPath(value) {
54
+ return path.resolve(expandSupportedPathVars(value))
55
+ }
56
+
57
+ function loadPersistedEnvIntoProcess() {
58
+ const envFile = path.join(os.homedir(), '.idlewatch', 'idlewatch.env')
59
+ if (!fs.existsSync(envFile)) return null
60
+
61
+ try {
62
+ const parsed = parseEnvFileToObject(envFile)
63
+ for (const [key, value] of Object.entries(parsed)) {
64
+ if (process.env[key]) continue
65
+ process.env[key] = key.endsWith('_PATH') ? expandSupportedPathVars(value) : value
66
+ }
67
+ return { envFile, parsed }
68
+ } catch (error) {
69
+ console.error(`Failed to load persisted IdleWatch config from ${envFile}: ${error.message}`)
70
+ return null
71
+ }
72
+ }
73
+
74
+ const persistedEnv = loadPersistedEnvIntoProcess()
75
+
41
76
  function parseMonitorTargets(raw) {
42
77
  const allowed = new Set(['cpu', 'memory', 'gpu', 'openclaw'])
43
78
  const fallback = ['cpu', 'memory', 'openclaw', 'gpu']
@@ -70,7 +105,7 @@ function formatBytes(bytes) {
70
105
 
71
106
  function resolveDashboardLogPath(host) {
72
107
  if (process.env.IDLEWATCH_LOCAL_LOG_PATH) {
73
- return path.resolve(process.env.IDLEWATCH_LOCAL_LOG_PATH)
108
+ return resolveEnvPath(process.env.IDLEWATCH_LOCAL_LOG_PATH)
74
109
  }
75
110
 
76
111
  const envFile = path.join(os.homedir(), '.idlewatch', 'idlewatch.env')
@@ -78,7 +113,7 @@ function resolveDashboardLogPath(host) {
78
113
  try {
79
114
  const parsed = parseEnvFileToObject(envFile)
80
115
  if (parsed.IDLEWATCH_LOCAL_LOG_PATH) {
81
- return path.resolve(parsed.IDLEWATCH_LOCAL_LOG_PATH)
116
+ return resolveEnvPath(parsed.IDLEWATCH_LOCAL_LOG_PATH)
82
117
  }
83
118
  } catch {
84
119
  // ignore malformed env file and fallback
@@ -319,9 +354,11 @@ function runLocalDashboard({ host }) {
319
354
  }
320
355
 
321
356
  const argv = process.argv.slice(2)
322
- const quickstartRequested = argv[0] === 'quickstart' || argv.includes('--quickstart')
323
- const dashboardRequested = argv[0] === 'dashboard' || argv.includes('--dashboard')
324
357
  const args = new Set(argv)
358
+ const dashboardRequested = argv[0] === 'dashboard' || argv.includes('--dashboard')
359
+ const runRequested = argv[0] === 'run' || argv.includes('--run')
360
+ 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)
325
362
  if (args.has('--help') || args.has('-h')) {
326
363
  printHelp()
327
364
  process.exit(0)
@@ -336,7 +373,6 @@ if (dashboardRequested) {
336
373
  if (quickstartRequested) {
337
374
  try {
338
375
  const result = await runEnrollmentWizard()
339
- console.log(`Enrollment complete. Mode=${result.mode} envFile=${result.outputEnvFile}`)
340
376
 
341
377
  const enrolledEnv = parseEnvFileToObject(result.outputEnvFile)
342
378
  const onceRun = spawnSync(process.execPath, [process.argv[1], '--once'], {
@@ -348,12 +384,15 @@ if (quickstartRequested) {
348
384
  })
349
385
 
350
386
  if (onceRun.status === 0) {
351
- console.log('✅ Initial telemetry sample sent successfully.')
387
+ console.log(`✅ Setup complete. Mode=${result.mode} device=${result.deviceName} envFile=${result.outputEnvFile}`)
388
+ console.log('Initial telemetry sample sent successfully.')
352
389
  process.exit(0)
353
390
  }
354
391
 
355
- console.log('⚠️ Initial --once sample did not complete successfully.')
356
- console.log(`You can retry with: set -a; source "${result.outputEnvFile}"; set +a && idlewatch-agent --once`)
392
+ console.error(`⚠️ Setup is not finished yet. Mode=${result.mode} device=${result.deviceName} envFile=${result.outputEnvFile}`)
393
+ console.error('The first required telemetry sample did not publish successfully, so this device may not be linked yet.')
394
+ console.error(`Retry with: idlewatch --once`)
395
+ console.error('Or rerun: idlewatch quickstart')
357
396
  process.exit(onceRun.status ?? 1)
358
397
  } catch (err) {
359
398
  console.error(`Enrollment failed: ${err.message}`)
@@ -363,8 +402,14 @@ if (quickstartRequested) {
363
402
 
364
403
  const DRY_RUN = args.has('--dry-run')
365
404
  const ONCE = args.has('--once')
405
+ const DEVICE_NAME = (process.env.IDLEWATCH_DEVICE_NAME || process.env.IDLEWATCH_HOST || os.hostname()).trim()
406
+ const DEVICE_ID = (process.env.IDLEWATCH_DEVICE_ID || DEVICE_NAME)
407
+ .trim()
408
+ .toLowerCase()
409
+ .replace(/[^a-z0-9_.-]+/g, '-')
410
+ .replace(/^-+|-+$/g, '') || 'device'
366
411
  const HOST = process.env.IDLEWATCH_HOST || os.hostname()
367
- const SAFE_HOST = HOST.replace(/[^a-zA-Z0-9_.-]/g, '_')
412
+ const SAFE_HOST = DEVICE_ID.replace(/[^a-zA-Z0-9_.-]/g, '_')
368
413
  const INTERVAL_MS = Number(process.env.IDLEWATCH_INTERVAL_MS || 10000)
369
414
  const PROJECT_ID = process.env.FIREBASE_PROJECT_ID
370
415
  const CREDS_FILE = process.env.FIREBASE_SERVICE_ACCOUNT_FILE
@@ -379,8 +424,8 @@ const MONITOR_GPU = MONITOR_TARGETS.has('gpu')
379
424
  const MONITOR_OPENCLAW = MONITOR_TARGETS.has('openclaw')
380
425
  const EFFECTIVE_OPENCLAW_MODE = MONITOR_OPENCLAW ? OPENCLAW_USAGE_MODE : 'off'
381
426
  const REQUIRE_FIREBASE_WRITES = process.env.IDLEWATCH_REQUIRE_FIREBASE_WRITES === '1'
382
- const CLOUD_INGEST_URL = process.env.IDLEWATCH_CLOUD_INGEST_URL
383
- const CLOUD_API_KEY = process.env.IDLEWATCH_CLOUD_API_KEY
427
+ const CLOUD_INGEST_URL = (process.env.IDLEWATCH_CLOUD_INGEST_URL || '').trim()
428
+ const CLOUD_API_KEY = (process.env.IDLEWATCH_CLOUD_API_KEY || '').trim().replace(/^['"]|['"]$/g, '')
384
429
  const REQUIRE_CLOUD_WRITES = process.env.IDLEWATCH_REQUIRE_CLOUD_WRITES === '1'
385
430
  let cloudIngestKickedOut = false
386
431
  let cloudIngestKickoutReason = null
@@ -399,7 +444,7 @@ const OPENCLAW_PROBE_RETRIES = process.env.IDLEWATCH_OPENCLAW_PROBE_RETRIES
399
444
  const BASE_DIR = path.join(os.homedir(), '.idlewatch')
400
445
 
401
446
  const LOCAL_LOG_PATH = process.env.IDLEWATCH_LOCAL_LOG_PATH
402
- ? path.resolve(process.env.IDLEWATCH_LOCAL_LOG_PATH)
447
+ ? resolveEnvPath(process.env.IDLEWATCH_LOCAL_LOG_PATH)
403
448
  : path.join(BASE_DIR, 'logs', `${SAFE_HOST}-metrics.ndjson`)
404
449
 
405
450
  if (!Number.isFinite(INTERVAL_MS) || INTERVAL_MS <= 0) {
@@ -522,7 +567,7 @@ if (process.env.IDLEWATCH_OPENCLAW_LAST_GOOD_MAX_AGE_MS && (!Number.isFinite(OPE
522
567
  }
523
568
 
524
569
  const OPENCLAW_LAST_GOOD_CACHE_PATH = process.env.IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH
525
- ? path.resolve(process.env.IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH)
570
+ ? resolveEnvPath(process.env.IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH)
526
571
  : path.join(BASE_DIR, 'cache', `${SAFE_HOST}-openclaw-last-good.json`)
527
572
 
528
573
  let appReady = false
@@ -579,9 +624,9 @@ if (firebaseConfigError) {
579
624
  process.exit(1)
580
625
  }
581
626
 
582
- if (!appReady) {
627
+ if (!appReady && !(CLOUD_INGEST_URL && CLOUD_API_KEY)) {
583
628
  console.error(
584
- 'Firebase is not configured. Running in local-only mode (stdout logging only). Set FIREBASE_PROJECT_ID + FIREBASE_SERVICE_ACCOUNT_FILE (preferred, or FIREBASE_SERVICE_ACCOUNT_JSON / FIREBASE_SERVICE_ACCOUNT_B64), or use FIREBASE_PROJECT_ID + FIRESTORE_EMULATOR_HOST for emulator writes.'
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 configure cloud ingest via idlewatch setup.'
585
630
  )
586
631
  }
587
632
 
@@ -1276,6 +1321,10 @@ async function collectSample() {
1276
1321
 
1277
1322
  const row = {
1278
1323
  host: HOST,
1324
+ hostId: HOST,
1325
+ hostName: HOST,
1326
+ deviceId: DEVICE_ID,
1327
+ deviceName: DEVICE_NAME,
1279
1328
  ts: sampleAtMs,
1280
1329
  cpuPct: MONITOR_CPU ? cpuPct() : null,
1281
1330
  memPct: MONITOR_MEMORY ? usedMemPct : null,
@@ -1360,10 +1409,10 @@ async function gracefulShutdown(signal) {
1360
1409
  if (stopped) return
1361
1410
  stopped = true
1362
1411
  if (inflightTick) {
1363
- console.log(`idlewatch-agent received ${signal}, waiting for in-flight sample…`)
1412
+ console.log(`idlewatch received ${signal}, waiting for in-flight sample…`)
1364
1413
  try { await inflightTick } catch { /* already logged */ }
1365
1414
  }
1366
- console.log('idlewatch-agent stopped')
1415
+ console.log('idlewatch stopped')
1367
1416
  process.exit(0)
1368
1417
  }
1369
1418
 
@@ -1373,7 +1422,7 @@ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
1373
1422
  if (DRY_RUN || ONCE) {
1374
1423
  const mode = DRY_RUN ? 'dry-run' : 'once'
1375
1424
  console.log(
1376
- `idlewatch-agent ${mode} host=${HOST} intervalMs=${INTERVAL_MS} firebase=${appReady} localLog=${LOCAL_LOG_PATH}`
1425
+ `idlewatch ${mode} host=${HOST} device=${DEVICE_NAME} deviceId=${DEVICE_ID} intervalMs=${INTERVAL_MS} firebase=${appReady} localLog=${LOCAL_LOG_PATH} env=${persistedEnv?.envFile || 'process'}`
1377
1426
  )
1378
1427
  tick()
1379
1428
  .then(() => process.exit(0))
@@ -1383,7 +1432,7 @@ if (DRY_RUN || ONCE) {
1383
1432
  })
1384
1433
  } else {
1385
1434
  console.log(
1386
- `idlewatch-agent started host=${HOST} intervalMs=${INTERVAL_MS} firebase=${appReady} localLog=${LOCAL_LOG_PATH} monitorTargets=${[...MONITOR_TARGETS].join(',')} openclawUsage=${EFFECTIVE_OPENCLAW_MODE}`
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'}`
1387
1436
  )
1388
1437
  loop()
1389
1438
  }
package/package.json CHANGED
@@ -1,8 +1,19 @@
1
1
  {
2
2
  "name": "idlewatch",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Host telemetry collector for IdleWatch",
5
5
  "type": "module",
6
+ "files": [
7
+ "bin",
8
+ "src",
9
+ "scripts",
10
+ "skill",
11
+ "tui/src",
12
+ "tui/Cargo.toml",
13
+ "tui/Cargo.lock",
14
+ "README.md",
15
+ ".env.example"
16
+ ],
6
17
  "bin": {
7
18
  "idlewatch": "bin/idlewatch-agent.js",
8
19
  "idlewatch-agent": "bin/idlewatch-agent.js",
@@ -43,6 +43,7 @@ cat > "$PLIST_PATH" <<'PLIST'
43
43
  <key>ProgramArguments</key>
44
44
  <array>
45
45
  <string>{{BIN_PATH}}</string>
46
+ <string>--run</string>
46
47
  </array>
47
48
  <key>RunAtLoad</key>
48
49
  <true/>
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs'
2
+ import http from 'node:http'
2
3
  import os from 'node:os'
3
4
  import path from 'node:path'
4
5
  import { spawnSync } from 'node:child_process'
@@ -7,31 +8,51 @@ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '
7
8
  const binPath = path.join(repoRoot, 'bin', 'idlewatch-agent.js')
8
9
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'idlewatch-onboarding-'))
9
10
 
10
- try {
11
- const sourceCreds = path.join(tmpRoot, 'service-account.json')
12
- fs.writeFileSync(
13
- sourceCreds,
14
- JSON.stringify({
15
- type: 'service_account',
16
- project_id: 'idlewatch-test-project',
17
- private_key_id: 'abc123',
18
- private_key: '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n',
19
- client_email: 'idlewatch@idlewatch-test-project.iam.gserviceaccount.com',
20
- client_id: '123'
11
+ const requests = []
12
+ const server = http.createServer((req, res) => {
13
+ let body = ''
14
+ req.on('data', (chunk) => {
15
+ body += chunk
16
+ })
17
+ req.on('end', () => {
18
+ requests.push({
19
+ method: req.method,
20
+ url: req.url,
21
+ headers: req.headers,
22
+ body
21
23
  })
22
- )
24
+ res.writeHead(200, { 'content-type': 'application/json' })
25
+ res.end(JSON.stringify({ ok: true }))
26
+ })
27
+ })
28
+
29
+ await new Promise((resolve, reject) => {
30
+ server.listen(0, '127.0.0.1', () => resolve())
31
+ server.once('error', reject)
32
+ })
23
33
 
34
+ const address = server.address()
35
+ const cloudIngestUrl = `http://127.0.0.1:${address.port}/api/ingest`
36
+
37
+ try {
24
38
  const envOut = path.join(tmpRoot, 'generated.env')
25
39
  const configDir = path.join(tmpRoot, 'config')
40
+ const localLogPath = path.join(configDir, 'logs', 'validator-box-metrics.ndjson')
41
+ const lastGoodCachePath = path.join(configDir, 'cache', 'validator-box-openclaw-last-good.json')
42
+
26
43
  const run = spawnSync(process.execPath, [binPath, 'quickstart'], {
27
44
  env: {
28
45
  ...process.env,
29
46
  IDLEWATCH_ENROLL_NON_INTERACTIVE: '1',
30
47
  IDLEWATCH_ENROLL_MODE: 'production',
31
- IDLEWATCH_ENROLL_PROJECT_ID: 'idlewatch-test-project',
32
- IDLEWATCH_ENROLL_SERVICE_ACCOUNT_FILE: sourceCreds,
48
+ IDLEWATCH_CLOUD_API_KEY: 'iwk_abcdefghijklmnopqrstuvwxyz123456',
49
+ IDLEWATCH_CLOUD_INGEST_URL: cloudIngestUrl,
33
50
  IDLEWATCH_ENROLL_OUTPUT_ENV_FILE: envOut,
34
- IDLEWATCH_ENROLL_CONFIG_DIR: configDir
51
+ IDLEWATCH_ENROLL_CONFIG_DIR: configDir,
52
+ IDLEWATCH_DEVICE_NAME: 'Validator Box',
53
+ IDLEWATCH_DEVICE_ID: 'validator-box',
54
+ IDLEWATCH_MONITOR_TARGETS: 'cpu,memory',
55
+ IDLEWATCH_OPENCLAW_USAGE: 'off'
35
56
  },
36
57
  encoding: 'utf8'
37
58
  })
@@ -45,19 +66,33 @@ try {
45
66
  }
46
67
 
47
68
  const envContent = fs.readFileSync(envOut, 'utf8')
48
- if (!envContent.includes('FIREBASE_PROJECT_ID=idlewatch-test-project')) {
49
- throw new Error('env file missing FIREBASE_PROJECT_ID')
69
+ for (const requiredLine of [
70
+ 'IDLEWATCH_DEVICE_NAME=Validator Box',
71
+ 'IDLEWATCH_DEVICE_ID=validator-box',
72
+ 'IDLEWATCH_MONITOR_TARGETS=cpu,memory',
73
+ 'IDLEWATCH_OPENCLAW_USAGE=off',
74
+ `IDLEWATCH_LOCAL_LOG_PATH=${localLogPath}`,
75
+ `IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH=${lastGoodCachePath}`,
76
+ `IDLEWATCH_CLOUD_INGEST_URL=${cloudIngestUrl}`,
77
+ 'IDLEWATCH_CLOUD_API_KEY=iwk_abcdefghijklmnopqrstuvwxyz123456',
78
+ 'IDLEWATCH_REQUIRE_CLOUD_WRITES=1'
79
+ ]) {
80
+ if (!envContent.includes(requiredLine)) {
81
+ throw new Error(`env file missing ${requiredLine}`)
82
+ }
50
83
  }
51
- if (!envContent.includes('FIREBASE_SERVICE_ACCOUNT_FILE=')) {
52
- throw new Error('env file missing FIREBASE_SERVICE_ACCOUNT_FILE')
84
+
85
+ if (requests.length === 0) {
86
+ throw new Error('quickstart did not send the initial telemetry sample')
53
87
  }
54
88
 
55
- const copiedCreds = path.join(configDir, 'credentials', 'idlewatch-test-project-service-account.json')
56
- if (!fs.existsSync(copiedCreds)) {
57
- throw new Error('quickstart did not copy service account file to secure credentials path')
89
+ const telemetry = JSON.parse(requests[0].body)
90
+ if (telemetry.deviceId !== 'validator-box') {
91
+ throw new Error('initial telemetry sample used unexpected device id')
58
92
  }
59
93
 
60
94
  console.log('onboarding validation passed')
61
95
  } finally {
96
+ server.close()
62
97
  fs.rmSync(tmpRoot, { recursive: true, force: true })
63
98
  }
package/src/enrollment.js CHANGED
@@ -58,6 +58,39 @@ function normalizeMonitorTargets(raw, available) {
58
58
  return [...new Set(parsed)]
59
59
  }
60
60
 
61
+ function normalizeCloudApiKey(raw) {
62
+ const trimmed = String(raw || '').trim()
63
+ if (!trimmed) return ''
64
+
65
+ const token = trimmed
66
+ .split(/\s+/)
67
+ .find((part) => part.startsWith('iwk_'))
68
+
69
+ if (token) {
70
+ return token.replace(/^['"]|['",]$/g, '')
71
+ }
72
+
73
+ return trimmed.replace(/^['"]|['"]$/g, '')
74
+ }
75
+
76
+ function looksLikeCloudApiKey(value) {
77
+ return /^iwk_[A-Za-z0-9_-]{20,}$/.test(String(value || '').trim())
78
+ }
79
+
80
+ function normalizeDeviceName(raw, fallback = os.hostname()) {
81
+ const value = String(raw || '').trim().replace(/\s+/g, ' ')
82
+ return value || fallback
83
+ }
84
+
85
+ function sanitizeDeviceId(raw, fallback = os.hostname()) {
86
+ const base = normalizeDeviceName(raw, fallback).toLowerCase()
87
+ const sanitized = base
88
+ .replace(/[^a-z0-9._-]+/g, '-')
89
+ .replace(/^-+|-+$/g, '')
90
+ .slice(0, 80)
91
+ return sanitized || normalizeDeviceName(fallback).replace(/[^a-zA-Z0-9_.-]/g, '_')
92
+ }
93
+
61
94
  function tryRustTui({ configDir, outputEnvFile }) {
62
95
  const disabled = process.env.IDLEWATCH_DISABLE_RUST_TUI === '1'
63
96
  if (disabled) return false
@@ -94,8 +127,9 @@ export async function runEnrollmentWizard(options = {}) {
94
127
  const outputEnvFile = path.resolve(options.outputEnvFile || process.env.IDLEWATCH_ENROLL_OUTPUT_ENV_FILE || path.join(configDir, 'idlewatch.env'))
95
128
 
96
129
  let mode = options.mode || process.env.IDLEWATCH_ENROLL_MODE || null
97
- let cloudApiKey = options.cloudApiKey || process.env.IDLEWATCH_CLOUD_API_KEY || null
130
+ let cloudApiKey = normalizeCloudApiKey(options.cloudApiKey || process.env.IDLEWATCH_CLOUD_API_KEY || null)
98
131
  let cloudIngestUrl = options.cloudIngestUrl || process.env.IDLEWATCH_CLOUD_INGEST_URL || 'https://api.idlewatch.com/api/ingest'
132
+ let deviceName = normalizeDeviceName(options.deviceName || process.env.IDLEWATCH_DEVICE_NAME || os.hostname())
99
133
 
100
134
  const availableMonitorTargets = detectAvailableMonitorTargets()
101
135
  let monitorTargets = normalizeMonitorTargets(
@@ -119,6 +153,8 @@ export async function runEnrollmentWizard(options = {}) {
119
153
  console.log(`Environment file: ${outputEnvFile}`)
120
154
  const modeInput = (await rl.question('\nMode [1/2] (default 1): ')).trim() || '1'
121
155
  mode = modeInput === '2' ? 'local' : 'production'
156
+ const deviceNameInput = (await rl.question(`Device name [${deviceName}]: `)).trim()
157
+ deviceName = normalizeDeviceName(deviceNameInput || deviceName)
122
158
  }
123
159
 
124
160
  if (!mode) mode = 'production'
@@ -129,7 +165,7 @@ export async function runEnrollmentWizard(options = {}) {
129
165
  if ((mode === 'production') && !cloudApiKey) {
130
166
  if (!rl) throw new Error('Missing cloud API key (IDLEWATCH_CLOUD_API_KEY).')
131
167
  console.log('\nPaste the API key from idlewatch.com/api.')
132
- cloudApiKey = (await rl.question('Cloud API key: ')).trim()
168
+ cloudApiKey = normalizeCloudApiKey(await rl.question('Cloud API key: '))
133
169
  }
134
170
 
135
171
  if (!nonInteractive && rl) {
@@ -139,11 +175,14 @@ export async function runEnrollmentWizard(options = {}) {
139
175
  monitorTargets = normalizeMonitorTargets(monitorInput || suggested, availableMonitorTargets)
140
176
  }
141
177
 
142
- const localLogPath = path.join(configDir, 'logs', `${os.hostname().replace(/[^a-zA-Z0-9_.-]/g, '_')}-metrics.ndjson`)
143
- const localCachePath = path.join(configDir, 'cache', `${os.hostname().replace(/[^a-zA-Z0-9_.-]/g, '_')}-openclaw-last-good.json`)
178
+ const safeDeviceId = sanitizeDeviceId(options.deviceId || process.env.IDLEWATCH_DEVICE_ID || deviceName, os.hostname())
179
+ const localLogPath = path.join(configDir, 'logs', `${safeDeviceId}-metrics.ndjson`)
180
+ const localCachePath = path.join(configDir, 'cache', `${safeDeviceId}-openclaw-last-good.json`)
144
181
 
145
182
  const envLines = [
146
183
  '# Generated by idlewatch-agent quickstart',
184
+ `IDLEWATCH_DEVICE_NAME=${deviceName}`,
185
+ `IDLEWATCH_DEVICE_ID=${safeDeviceId}`,
147
186
  `IDLEWATCH_MONITOR_TARGETS=${monitorTargets.join(',')}`,
148
187
  `IDLEWATCH_OPENCLAW_USAGE=${monitorTargets.includes('openclaw') ? 'auto' : 'off'}`,
149
188
  `IDLEWATCH_LOCAL_LOG_PATH=${localLogPath}`,
@@ -155,8 +194,8 @@ export async function runEnrollmentWizard(options = {}) {
155
194
  }
156
195
 
157
196
  if (mode === 'production') {
158
- if (!cloudApiKey) {
159
- throw new Error('Cloud API key is required for production mode.')
197
+ if (!cloudApiKey || !looksLikeCloudApiKey(cloudApiKey)) {
198
+ throw new Error('Cloud API key is invalid. Copy the full key from idlewatch.com/api (starts with iwk_).')
160
199
  }
161
200
  envLines.push(`IDLEWATCH_CLOUD_INGEST_URL=${cloudIngestUrl}`)
162
201
  envLines.push(`IDLEWATCH_CLOUD_API_KEY=${cloudApiKey}`)
@@ -171,6 +210,8 @@ export async function runEnrollmentWizard(options = {}) {
171
210
  mode,
172
211
  configDir,
173
212
  outputEnvFile,
174
- monitorTargets
213
+ monitorTargets,
214
+ deviceName,
215
+ deviceId: safeDeviceId
175
216
  }
176
217
  }