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 +3 -3
- package/bin/idlewatch-agent.js +222 -36
- package/package.json +2 -2
- package/scripts/install-macos-launch-agent.sh +29 -0
- package/scripts/validate-dry-run-schema.mjs +4 -0
- package/scripts/validate-onboarding.mjs +2 -2
- package/src/enrollment.js +119 -16
- package/src/openclaw-activity.js +163 -0
- package/src/openclaw-usage.js +232 -2
- package/src/telemetry-mapping.js +11 -1
- package/src/thermal.js +113 -0
- package/tui/src/main.rs +74 -25
package/README.md
CHANGED
|
@@ -23,13 +23,13 @@ npx idlewatch --dry-run
|
|
|
23
23
|
|
|
24
24
|
- `quickstart`: run first-run enrollment wizard
|
|
25
25
|
- `--help`: show usage
|
|
26
|
-
- `--dry-run`: collect one sample and exit
|
|
27
|
-
- `--once`: collect one sample, publish
|
|
26
|
+
- `--dry-run`: collect one sample and exit without remote writes
|
|
27
|
+
- `--once`: collect one sample, publish it using the active configured path, then exit
|
|
28
28
|
|
|
29
29
|
## Reliability improvements
|
|
30
30
|
|
|
31
31
|
- Local NDJSON durability log at `~/.idlewatch/logs/<host>-metrics.ndjson` (override via `IDLEWATCH_LOCAL_LOG_PATH`)
|
|
32
|
-
- Retry-once+ for transient
|
|
32
|
+
- Retry-once+ for transient publish failures (cloud ingest and Firebase paths)
|
|
33
33
|
- Non-overlapping scheduler loop (prevents concurrent sample overlap when host is busy)
|
|
34
34
|
- Non-blocking CPU sampling using per-tick CPU deltas (no `Atomics.wait` stall)
|
|
35
35
|
- Darwin GPU probing fallback chain (AGX/IOGPU `ioreg` → `powermetrics` → `top` grep) with provenance fields (`gpuSource`, `gpuConfidence`, `gpuSampleWindowMs`)
|
package/bin/idlewatch-agent.js
CHANGED
|
@@ -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\
|
|
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 =
|
|
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', '
|
|
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
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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
|
|
435
|
-
const
|
|
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
|
-
'
|
|
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
|
|
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 =
|
|
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 (
|
|
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
|
-
: (
|
|
1420
|
+
: (openclawUsageEnabled ? 'unavailable' : 'disabled')
|
|
1282
1421
|
|
|
1283
1422
|
const source = {
|
|
1284
1423
|
monitorTargets: [...MONITOR_TARGETS],
|
|
1285
|
-
usage: usage ? 'openclaw' :
|
|
1424
|
+
usage: usage ? 'openclaw' : openclawUsageEnabled ? 'unavailable' : 'disabled',
|
|
1286
1425
|
usageIntegrationStatus,
|
|
1287
|
-
usageIngestionStatus:
|
|
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
|
-
: (
|
|
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:
|
|
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:
|
|
1353
|
-
openclawModel:
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1385
|
-
|
|
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}
|
|
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}
|
|
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.
|
|
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
|
-
|
|
71
|
+
IDLEWATCH_ENROLL_DEVICE_NAME: 'Validator Box',
|
|
72
72
|
IDLEWATCH_DEVICE_ID: 'validator-box',
|
|
73
|
-
|
|
73
|
+
IDLEWATCH_ENROLL_MONITOR_TARGETS: 'cpu,memory',
|
|
74
74
|
IDLEWATCH_OPENCLAW_USAGE: 'off'
|
|
75
75
|
})
|
|
76
76
|
|