idlewatch 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/bin/idlewatch-agent.js +174 -17
- package/package.json +2 -2
- package/scripts/install-macos-launch-agent.sh +29 -0
- package/scripts/validate-onboarding.mjs +2 -2
- package/src/enrollment.js +103 -11
- package/src/openclaw-activity.js +163 -0
- package/src/openclaw-usage.js +178 -0
- package/src/telemetry-mapping.js +5 -0
- package/tui/src/main.rs +1 -1
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
|
@@ -13,12 +13,13 @@ import { memUsedPct, memoryPressureDarwin } from '../src/memory.js'
|
|
|
13
13
|
import { deriveUsageFreshness } from '../src/usage-freshness.js'
|
|
14
14
|
import { deriveUsageAlert } from '../src/usage-alert.js'
|
|
15
15
|
import { loadLastGoodUsageSnapshot, persistLastGoodUsageSnapshot } from '../src/openclaw-cache.js'
|
|
16
|
+
import { DAY_WINDOW_MS, loadOpenClawActivitySummary } from '../src/openclaw-activity.js'
|
|
16
17
|
import { runEnrollmentWizard } from '../src/enrollment.js'
|
|
17
18
|
import { enrichWithOpenClawFleetTelemetry } from '../src/telemetry-mapping.js'
|
|
18
19
|
import pkg from '../package.json' with { type: 'json' }
|
|
19
20
|
|
|
20
21
|
function printHelp() {
|
|
21
|
-
console.log(`idlewatch\n\nUsage:\n idlewatch [quickstart|configure|dashboard|run] [--dry-run] [--once] [--help]\n\nOptions:\n quickstart Run first-run setup and save local IdleWatch config\n configure Alias for quickstart; reopen setup to change device name, API key, or metrics\n dashboard Launch local dashboard from local IdleWatch logs\n run Start the background collector using saved local config\n --no-tui Skip the Rust TUI and use plain text setup without installing Cargo\n --dry-run Collect and print one telemetry sample, then exit without remote writes\n --once Collect and publish one telemetry sample, then exit\n --help Show this help message\n\nQuickstart:\n 1. Create an API key on idlewatch.com/api\n 2. Run: idlewatch quickstart\n 3. Pick a device name and metrics\n 4. IdleWatch saves your local config and sends a first sample\n\
|
|
22
|
+
console.log(`idlewatch\n\nUsage:\n idlewatch [quickstart|configure|status|dashboard|run] [--no-tui] [--dry-run] [--once] [--help]\n\nOptions:\n quickstart Run first-run setup and save local IdleWatch config\n configure Alias for quickstart; reopen setup to change device name, API key, or metrics\n status Show current device config, publish mode, and last sample age\n dashboard Launch local dashboard from local IdleWatch logs\n run Start the background collector using saved local config\n --no-tui Skip the Rust TUI and use plain text setup without installing Cargo\n --dry-run Collect and print one telemetry sample, then exit without remote writes\n --once Collect and publish one telemetry sample, then exit\n --help Show this help message\n\nQuickstart:\n 1. Create an API key on idlewatch.com/api\n 2. Run: idlewatch quickstart\n 3. Pick a device name and metrics\n 4. IdleWatch saves your local config and sends a first sample\n\nCommon env (optional):\n IDLEWATCH_CLOUD_API_KEY Cloud API key from idlewatch.com/api for device linking\n IDLEWATCH_CLOUD_INGEST_URL Cloud ingest endpoint (default: https://api.idlewatch.com/api/ingest)\n IDLEWATCH_LOCAL_LOG_PATH Optional NDJSON file path for local sample durability\n IDLEWATCH_DASHBOARD_PORT Local dashboard HTTP port (default: 4373)\n IDLEWATCH_OPENCLAW_USAGE OpenClaw usage lookup mode: auto|off (default: auto)\n IDLEWATCH_REQUIRE_CLOUD_WRITES Require cloud publish path in --once mode: 1|0 (default: 0)\n\nAdvanced env tuning:\n IDLEWATCH_HOST Optional custom host label (default: hostname)\n IDLEWATCH_INTERVAL_MS Sampling interval in ms (default: 10000)\n IDLEWATCH_OPENCLAW_PROBE_TIMEOUT_MS OpenClaw command timeout per probe in ms (default: 2500)\n IDLEWATCH_OPENCLAW_PROBE_RETRIES Extra OpenClaw probe sweep retries after first pass (default: 1)\n IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES Max per-command OpenClaw probe output capture in bytes before truncation (default: 2097152 / 2MB)\n IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES_HARD_CAP Hard cap for auto-retry output capture escalation (default: 16777216 / 16MB)\n IDLEWATCH_USAGE_STALE_MS Mark OpenClaw usage stale beyond this age in ms (default: max(interval*3,60000))\n IDLEWATCH_USAGE_NEAR_STALE_MS Mark OpenClaw usage as aging beyond this age in ms (default: floor((stale+grace)*0.85))\n IDLEWATCH_USAGE_STALE_GRACE_MS Extra grace window before status becomes stale (default: min(interval,10000))\n IDLEWATCH_USAGE_REFRESH_REPROBES Forced uncached reprobes when usage crosses stale threshold (default: 1)\n IDLEWATCH_USAGE_REFRESH_DELAY_MS Delay between forced stale-threshold reprobes in ms (default: 250)\n IDLEWATCH_USAGE_REFRESH_ON_NEAR_STALE Trigger refresh when usage is near-stale: 1|0 (default: 1)\n IDLEWATCH_USAGE_IDLE_AFTER_MS Downgrade stale usage alerts to idle notice beyond this age in ms (default: 21600000)\n IDLEWATCH_OPENCLAW_LAST_GOOD_MAX_AGE_MS Reuse last successful usage snapshot after probe failures up to this age in ms\n IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH Persist/reuse last successful usage snapshot across restarts (default: ~/.idlewatch/cache/<host>-openclaw-last-good.json)\n\nAdvanced Firebase / emulator mode:\n IDLEWATCH_REQUIRE_FIREBASE_WRITES Require Firebase publish path in --once mode: 1|0 (default: 0)\n FIREBASE_PROJECT_ID Firebase project id\n FIREBASE_SERVICE_ACCOUNT_FILE Path to service account JSON file (preferred for production)\n FIREBASE_SERVICE_ACCOUNT_JSON Raw JSON service account (supported, less secure than file path)\n FIREBASE_SERVICE_ACCOUNT_B64 Base64-encoded JSON service account (legacy)\n FIRESTORE_EMULATOR_HOST Optional Firestore emulator host; allows local writes without service-account creds\n`)
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
const require = createRequire(import.meta.url)
|
|
@@ -54,8 +55,17 @@ function resolveEnvPath(value) {
|
|
|
54
55
|
return path.resolve(expandSupportedPathVars(value))
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
function defaultPersistedEnvFilePath() {
|
|
59
|
+
return path.join(os.homedir(), '.idlewatch', 'idlewatch.env')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function usesDefaultPersistedEnvFile(envFilePath) {
|
|
63
|
+
if (!envFilePath) return false
|
|
64
|
+
return path.resolve(envFilePath) === path.resolve(defaultPersistedEnvFilePath())
|
|
65
|
+
}
|
|
66
|
+
|
|
57
67
|
function loadPersistedEnvIntoProcess() {
|
|
58
|
-
const envFile =
|
|
68
|
+
const envFile = defaultPersistedEnvFilePath()
|
|
59
69
|
if (!fs.existsSync(envFile)) return null
|
|
60
70
|
|
|
61
71
|
try {
|
|
@@ -71,6 +81,27 @@ function loadPersistedEnvIntoProcess() {
|
|
|
71
81
|
}
|
|
72
82
|
}
|
|
73
83
|
|
|
84
|
+
function buildSetupTestEnv(enrolledEnv) {
|
|
85
|
+
const nextEnv = { ...process.env }
|
|
86
|
+
|
|
87
|
+
for (const key of Object.keys(nextEnv)) {
|
|
88
|
+
if (
|
|
89
|
+
key.startsWith('IDLEWATCH_') ||
|
|
90
|
+
key.startsWith('FIREBASE_') ||
|
|
91
|
+
key === 'GOOGLE_APPLICATION_CREDENTIALS'
|
|
92
|
+
) {
|
|
93
|
+
delete nextEnv[key]
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const [key, value] of Object.entries(enrolledEnv || {})) {
|
|
98
|
+
nextEnv[key] = key.endsWith('_PATH') ? expandSupportedPathVars(value) : value
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
nextEnv.IDLEWATCH_SETUP_VERIFY = '1'
|
|
102
|
+
return nextEnv
|
|
103
|
+
}
|
|
104
|
+
|
|
74
105
|
const persistedEnv = loadPersistedEnvIntoProcess()
|
|
75
106
|
|
|
76
107
|
function parseMonitorTargets(raw) {
|
|
@@ -203,6 +234,26 @@ function buildLocalDashboardPayload(logPath) {
|
|
|
203
234
|
}
|
|
204
235
|
}
|
|
205
236
|
|
|
237
|
+
function buildRollingLoadSummary(logPath, nowMs = Date.now(), windowMs = DAY_WINDOW_MS) {
|
|
238
|
+
const rows = parseLocalRows(logPath, 20000).filter((row) => Number(row.ts || 0) >= nowMs - windowMs)
|
|
239
|
+
if (rows.length === 0) return null
|
|
240
|
+
|
|
241
|
+
const average = (values) => {
|
|
242
|
+
const valid = values.filter((value) => Number.isFinite(value))
|
|
243
|
+
if (valid.length === 0) return null
|
|
244
|
+
return Number((valid.reduce((sum, value) => sum + value, 0) / valid.length).toFixed(2))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
windowMs,
|
|
249
|
+
sampleCount: rows.length,
|
|
250
|
+
cpuAvgPct: average(rows.map((row) => Number(row.cpuPct))),
|
|
251
|
+
memAvgPct: average(rows.map((row) => Number(row.memPct))),
|
|
252
|
+
gpuAvgPct: average(rows.map((row) => Number(row.gpuPct))),
|
|
253
|
+
tokensAvgPerMin: average(rows.map((row) => Number(row.tokensPerMin)))
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
206
257
|
function renderLocalDashboardHtml() {
|
|
207
258
|
return `<!doctype html>
|
|
208
259
|
<html>
|
|
@@ -355,10 +406,11 @@ function runLocalDashboard({ host }) {
|
|
|
355
406
|
|
|
356
407
|
const argv = process.argv.slice(2)
|
|
357
408
|
const args = new Set(argv)
|
|
409
|
+
const statusRequested = argv[0] === 'status' || argv.includes('--status')
|
|
358
410
|
const dashboardRequested = argv[0] === 'dashboard' || argv.includes('--dashboard')
|
|
359
411
|
const runRequested = argv[0] === 'run' || argv.includes('--run')
|
|
360
412
|
const interactiveDefaultRequested = argv.length === 0 && process.stdin.isTTY && process.stdout.isTTY
|
|
361
|
-
const quickstartRequested = argv[0] === 'quickstart' || argv[0] === 'configure' || argv.includes('--quickstart') || argv.includes('--configure') || (interactiveDefaultRequested && !dashboardRequested && !runRequested)
|
|
413
|
+
const quickstartRequested = argv[0] === 'quickstart' || argv[0] === 'configure' || argv.includes('--quickstart') || argv.includes('--configure') || (interactiveDefaultRequested && !dashboardRequested && !runRequested && !statusRequested)
|
|
362
414
|
if (args.has('--help') || args.has('-h')) {
|
|
363
415
|
printHelp()
|
|
364
416
|
process.exit(0)
|
|
@@ -381,22 +433,32 @@ if (quickstartRequested) {
|
|
|
381
433
|
const enrolledEnv = parseEnvFileToObject(result.outputEnvFile)
|
|
382
434
|
const onceRun = spawnSync(process.execPath, [process.argv[1], '--once'], {
|
|
383
435
|
stdio: 'inherit',
|
|
384
|
-
env:
|
|
385
|
-
...process.env,
|
|
386
|
-
...enrolledEnv
|
|
387
|
-
}
|
|
436
|
+
env: buildSetupTestEnv(enrolledEnv)
|
|
388
437
|
})
|
|
389
438
|
|
|
390
439
|
if (onceRun.status === 0) {
|
|
391
440
|
console.log(`✅ Setup complete. Mode=${result.mode} device=${result.deviceName} envFile=${result.outputEnvFile}`)
|
|
392
|
-
|
|
441
|
+
if (result.mode === 'local') {
|
|
442
|
+
console.log('Initial local telemetry check completed successfully.')
|
|
443
|
+
} else {
|
|
444
|
+
console.log('Initial telemetry sample sent successfully.')
|
|
445
|
+
}
|
|
446
|
+
console.log('To keep this device online continuously, run: idlewatch run')
|
|
447
|
+
if (process.platform === 'darwin') {
|
|
448
|
+
console.log('On macOS, you can also enable login startup with the bundled LaunchAgent install script.')
|
|
449
|
+
}
|
|
393
450
|
process.exit(0)
|
|
394
451
|
}
|
|
395
452
|
|
|
396
453
|
console.error(`⚠️ Setup is not finished yet. Mode=${result.mode} device=${result.deviceName} envFile=${result.outputEnvFile}`)
|
|
397
454
|
console.error('The first required telemetry sample did not publish successfully, so this device may not be linked yet.')
|
|
398
|
-
|
|
399
|
-
|
|
455
|
+
if (usesDefaultPersistedEnvFile(result.outputEnvFile)) {
|
|
456
|
+
console.error('Retry with: idlewatch --once')
|
|
457
|
+
console.error('Or rerun: idlewatch quickstart')
|
|
458
|
+
} else {
|
|
459
|
+
console.error('Retry with: idlewatch quickstart')
|
|
460
|
+
console.error(`Use the saved config directly: set -a; source "${result.outputEnvFile}"; set +a && idlewatch --once`)
|
|
461
|
+
}
|
|
400
462
|
process.exit(onceRun.status ?? 1)
|
|
401
463
|
} catch (err) {
|
|
402
464
|
if (String(err?.message || '') === 'setup_cancelled') {
|
|
@@ -638,9 +700,64 @@ const hasAnyFirebaseConfig = Boolean(PROJECT_ID || CREDS_FILE || CREDS_JSON || C
|
|
|
638
700
|
const hasCloudConfig = Boolean(CLOUD_INGEST_URL && CLOUD_API_KEY)
|
|
639
701
|
const shouldWarnAboutMissingPublishConfig = !appReady && !hasCloudConfig && !DRY_RUN && !hasAnyFirebaseConfig
|
|
640
702
|
|
|
703
|
+
function getPublishModeLabel() {
|
|
704
|
+
if (hasCloudConfig) return 'cloud'
|
|
705
|
+
if (appReady) return 'firebase'
|
|
706
|
+
return 'local-only'
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (statusRequested) {
|
|
710
|
+
const envFile = defaultPersistedEnvFilePath()
|
|
711
|
+
const hasConfig = fs.existsSync(envFile)
|
|
712
|
+
const publishMode = getPublishModeLabel()
|
|
713
|
+
|
|
714
|
+
console.log('IdleWatch status')
|
|
715
|
+
console.log('')
|
|
716
|
+
console.log(` Device: ${DEVICE_NAME}`)
|
|
717
|
+
console.log(` Device ID: ${DEVICE_ID}`)
|
|
718
|
+
console.log(` Publish mode: ${publishMode}`)
|
|
719
|
+
if (hasCloudConfig) {
|
|
720
|
+
console.log(` Cloud link: ${CLOUD_INGEST_URL}`)
|
|
721
|
+
console.log(` API key: ${CLOUD_API_KEY.slice(0, 8)}..${CLOUD_API_KEY.slice(-4)}`)
|
|
722
|
+
}
|
|
723
|
+
console.log(` Metrics: ${[...MONITOR_TARGETS].join(', ')}`)
|
|
724
|
+
console.log(` Local log: ${LOCAL_LOG_PATH || '(none)'}`)
|
|
725
|
+
console.log(` Config: ${hasConfig ? envFile : '(no saved config)'}`)
|
|
726
|
+
|
|
727
|
+
let hasSamples = false
|
|
728
|
+
if (LOCAL_LOG_PATH && fs.existsSync(LOCAL_LOG_PATH)) {
|
|
729
|
+
try {
|
|
730
|
+
const stat = fs.statSync(LOCAL_LOG_PATH)
|
|
731
|
+
console.log(` Log size: ${formatBytes(stat.size)}`)
|
|
732
|
+
const rows = parseLocalRows(LOCAL_LOG_PATH, 1)
|
|
733
|
+
if (rows.length > 0 && rows[0].ts) {
|
|
734
|
+
hasSamples = true
|
|
735
|
+
const ageMs = Date.now() - Number(rows[0].ts)
|
|
736
|
+
const ageSec = Math.round(ageMs / 1000)
|
|
737
|
+
const agoText = ageSec < 60 ? `${ageSec}s ago` : ageSec < 3600 ? `${Math.round(ageSec / 60)}m ago` : ageSec < 86400 ? `${Math.round(ageSec / 3600)}h ago` : `${Math.round(ageSec / 86400)}d ago`
|
|
738
|
+
console.log(` Last sample: ${agoText}`)
|
|
739
|
+
} else {
|
|
740
|
+
console.log(' Last sample: (none yet)')
|
|
741
|
+
}
|
|
742
|
+
} catch { /* ignore stat errors */ }
|
|
743
|
+
} else if (hasConfig) {
|
|
744
|
+
console.log(' Last sample: (none yet)')
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
console.log('')
|
|
748
|
+
if (!hasConfig) {
|
|
749
|
+
console.log(' Run idlewatch quickstart to set up this device.')
|
|
750
|
+
} else if (!hasSamples) {
|
|
751
|
+
console.log(' Run idlewatch --once to collect a test sample, or idlewatch run for continuous monitoring.')
|
|
752
|
+
} else {
|
|
753
|
+
console.log(' Run idlewatch configure to change device name, metrics, or API key.')
|
|
754
|
+
}
|
|
755
|
+
process.exit(0)
|
|
756
|
+
}
|
|
757
|
+
|
|
641
758
|
if (shouldWarnAboutMissingPublishConfig) {
|
|
642
759
|
console.error(
|
|
643
|
-
'
|
|
760
|
+
'Local-only mode: this run will stay on this Mac until you link a publish target. Run idlewatch quickstart any time if you want cloud ingest.'
|
|
644
761
|
)
|
|
645
762
|
}
|
|
646
763
|
|
|
@@ -1202,7 +1319,9 @@ async function publish(row, retries = 2) {
|
|
|
1202
1319
|
|
|
1203
1320
|
async function collectSample() {
|
|
1204
1321
|
const sampleStartMs = Date.now()
|
|
1322
|
+
const dayLoadSummary = buildRollingLoadSummary(LOCAL_LOG_PATH, sampleStartMs)
|
|
1205
1323
|
const openclawEnabled = EFFECTIVE_OPENCLAW_MODE !== 'off'
|
|
1324
|
+
const activitySummary = openclawEnabled ? loadOpenClawActivitySummary({ nowMs: sampleStartMs }) : null
|
|
1206
1325
|
|
|
1207
1326
|
const disabledProbe = {
|
|
1208
1327
|
result: 'disabled',
|
|
@@ -1322,6 +1441,8 @@ async function collectSample() {
|
|
|
1322
1441
|
usageStaleMsThreshold: USAGE_STALE_MS,
|
|
1323
1442
|
usageNearStaleMsThreshold: USAGE_NEAR_STALE_MS,
|
|
1324
1443
|
usageStaleGraceMs: USAGE_STALE_GRACE_MS,
|
|
1444
|
+
activitySource: activitySummary?.source ?? (openclawEnabled ? 'unavailable' : 'disabled'),
|
|
1445
|
+
activityWindowMs: activitySummary?.windowMs ?? null,
|
|
1325
1446
|
memPressureSource: memPressure.source,
|
|
1326
1447
|
cloudIngestionStatus: CLOUD_INGEST_URL && CLOUD_API_KEY
|
|
1327
1448
|
? cloudIngestKickedOut ? 'kicked-out' : 'enabled'
|
|
@@ -1346,16 +1467,30 @@ async function collectSample() {
|
|
|
1346
1467
|
memPressurePct: MONITOR_MEMORY ? memPressure.pct : null,
|
|
1347
1468
|
memPressureClass: MONITOR_MEMORY ? memPressure.cls : 'disabled',
|
|
1348
1469
|
gpuPct: MONITOR_GPU ? gpu.pct : null,
|
|
1470
|
+
dayWindowMs: dayLoadSummary?.windowMs ?? DAY_WINDOW_MS,
|
|
1471
|
+
dayCpuAvgPct: MONITOR_CPU ? (dayLoadSummary?.cpuAvgPct ?? null) : null,
|
|
1472
|
+
dayMemAvgPct: MONITOR_MEMORY ? (dayLoadSummary?.memAvgPct ?? null) : null,
|
|
1473
|
+
dayGpuAvgPct: MONITOR_GPU ? (dayLoadSummary?.gpuAvgPct ?? null) : null,
|
|
1474
|
+
dayTokensAvgPerMin: MONITOR_OPENCLAW ? (dayLoadSummary?.tokensAvgPerMin ?? null) : null,
|
|
1349
1475
|
gpuSource: gpu.source,
|
|
1350
1476
|
gpuConfidence: gpu.confidence,
|
|
1351
1477
|
gpuSampleWindowMs: gpu.sampleWindowMs,
|
|
1352
1478
|
tokensPerMin: MONITOR_OPENCLAW ? (usage?.tokensPerMin ?? null) : null,
|
|
1353
1479
|
openclawModel: MONITOR_OPENCLAW ? (usage?.model ?? null) : null,
|
|
1480
|
+
openclawProvider: MONITOR_OPENCLAW ? (usage?.provider ?? null) : null,
|
|
1354
1481
|
openclawTotalTokens: MONITOR_OPENCLAW ? (usage?.totalTokens ?? null) : null,
|
|
1482
|
+
openclawRemainingTokens: MONITOR_OPENCLAW ? (usage?.remainingTokens ?? null) : null,
|
|
1483
|
+
openclawPercentUsed: MONITOR_OPENCLAW ? (usage?.percentUsed ?? null) : null,
|
|
1484
|
+
openclawContextTokens: MONITOR_OPENCLAW ? (usage?.contextTokens ?? null) : null,
|
|
1485
|
+
openclawBudgetKind: MONITOR_OPENCLAW ? (usage?.budgetKind ?? null) : null,
|
|
1355
1486
|
openclawSessionId: MONITOR_OPENCLAW ? (usage?.sessionId ?? null) : null,
|
|
1356
1487
|
openclawAgentId: MONITOR_OPENCLAW ? (usage?.agentId ?? null) : null,
|
|
1357
1488
|
openclawUsageTs: MONITOR_OPENCLAW ? (usage?.usageTimestampMs ?? null) : null,
|
|
1358
1489
|
openclawUsageAgeMs: MONITOR_OPENCLAW ? usageFreshness.usageAgeMs : null,
|
|
1490
|
+
activityWindowMs: MONITOR_OPENCLAW ? (activitySummary?.windowMs ?? null) : null,
|
|
1491
|
+
activityActiveSeconds: MONITOR_OPENCLAW ? (activitySummary?.totalActiveSeconds ?? null) : null,
|
|
1492
|
+
activityIdleSeconds: MONITOR_OPENCLAW ? (activitySummary?.idleSeconds ?? null) : null,
|
|
1493
|
+
activityJobs: MONITOR_OPENCLAW ? (activitySummary?.jobs ?? []) : [],
|
|
1359
1494
|
localLogPath: LOCAL_LOG_PATH,
|
|
1360
1495
|
localLogBytes: null,
|
|
1361
1496
|
source
|
|
@@ -1369,21 +1504,43 @@ async function collectSample() {
|
|
|
1369
1504
|
})
|
|
1370
1505
|
}
|
|
1371
1506
|
|
|
1507
|
+
function summarizeSetupVerification(row) {
|
|
1508
|
+
const metrics = []
|
|
1509
|
+
if (row.cpuPct !== null && row.cpuPct !== undefined) metrics.push('cpu')
|
|
1510
|
+
if (row.memPct !== null && row.memPct !== undefined) metrics.push('memory')
|
|
1511
|
+
if (row.gpuPct !== null && row.gpuPct !== undefined) metrics.push('gpu')
|
|
1512
|
+
if (row.tokensPerMin !== null && row.tokensPerMin !== undefined) metrics.push('openclaw')
|
|
1513
|
+
|
|
1514
|
+
const details = [
|
|
1515
|
+
`mode=${getPublishModeLabel()}`,
|
|
1516
|
+
`metrics=${metrics.length ? metrics.join(',') : 'none'}`
|
|
1517
|
+
]
|
|
1518
|
+
|
|
1519
|
+
if (row.localLogPath) details.push(`localLog=${row.localLogPath}`)
|
|
1520
|
+
return `Initial sample ready (${details.join(' ')})`
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1372
1523
|
async function tick() {
|
|
1373
1524
|
const row = await collectSample()
|
|
1374
1525
|
const localUsage = appendLocal(row)
|
|
1375
1526
|
row.localLogPath = localUsage.path
|
|
1376
1527
|
row.localLogBytes = localUsage.bytes
|
|
1377
1528
|
|
|
1378
|
-
|
|
1529
|
+
if (process.env.IDLEWATCH_SETUP_VERIFY === '1') {
|
|
1530
|
+
console.log(summarizeSetupVerification(row))
|
|
1531
|
+
} else {
|
|
1532
|
+
console.log(JSON.stringify(row))
|
|
1533
|
+
}
|
|
1379
1534
|
|
|
1380
1535
|
const published = await publish(row)
|
|
1381
1536
|
|
|
1382
1537
|
if (cloudIngestKickedOut && !cloudIngestKickoutNotified) {
|
|
1383
1538
|
cloudIngestKickoutNotified = true
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1539
|
+
if (!(REQUIRE_CLOUD_WRITES && ONCE)) {
|
|
1540
|
+
console.error(
|
|
1541
|
+
`Cloud ingest disabled: API key rejected (${cloudIngestKickoutReason || 'unauthorized'}). Run idlewatch quickstart to link a new key.`
|
|
1542
|
+
)
|
|
1543
|
+
}
|
|
1387
1544
|
}
|
|
1388
1545
|
|
|
1389
1546
|
if (REQUIRE_FIREBASE_WRITES && ONCE && !published) {
|
|
@@ -1436,7 +1593,7 @@ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
|
|
|
1436
1593
|
if (DRY_RUN || ONCE) {
|
|
1437
1594
|
const mode = DRY_RUN ? 'dry-run' : 'once'
|
|
1438
1595
|
console.log(
|
|
1439
|
-
`idlewatch ${mode} host=${HOST} device=${DEVICE_NAME} deviceId=${DEVICE_ID} intervalMs=${INTERVAL_MS}
|
|
1596
|
+
`idlewatch ${mode} host=${HOST} device=${DEVICE_NAME} deviceId=${DEVICE_ID} intervalMs=${INTERVAL_MS} publish=${getPublishModeLabel()} localLog=${LOCAL_LOG_PATH} env=${persistedEnv?.envFile || 'process'}`
|
|
1440
1597
|
)
|
|
1441
1598
|
tick()
|
|
1442
1599
|
.then(() => process.exit(0))
|
|
@@ -1446,7 +1603,7 @@ if (DRY_RUN || ONCE) {
|
|
|
1446
1603
|
})
|
|
1447
1604
|
} else {
|
|
1448
1605
|
console.log(
|
|
1449
|
-
`idlewatch started host=${HOST} device=${DEVICE_NAME} deviceId=${DEVICE_ID} intervalMs=${INTERVAL_MS}
|
|
1606
|
+
`idlewatch started host=${HOST} device=${DEVICE_NAME} deviceId=${DEVICE_ID} intervalMs=${INTERVAL_MS} publish=${getPublishModeLabel()} localLog=${LOCAL_LOG_PATH} monitorTargets=${[...MONITOR_TARGETS].join(',')} openclawUsage=${EFFECTIVE_OPENCLAW_MODE} env=${persistedEnv?.envFile || 'process'}`
|
|
1450
1607
|
)
|
|
1451
1608
|
loop()
|
|
1452
1609
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idlewatch",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Host telemetry collector for IdleWatch",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"quickstart": "node bin/idlewatch-agent.js quickstart",
|
|
26
26
|
"validate:bin": "node scripts/validate-bin.mjs",
|
|
27
27
|
"validate:onboarding": "node scripts/validate-onboarding.mjs",
|
|
28
|
-
"test:unit": "node --test 'test/*.test.mjs'",
|
|
28
|
+
"test:unit": "node --test --test-concurrency=1 'test/*.test.mjs'",
|
|
29
29
|
"smoke:help": "node bin/idlewatch-agent.js --help",
|
|
30
30
|
"smoke:dry-run": "node bin/idlewatch-agent.js --dry-run",
|
|
31
31
|
"smoke:once": "IDLEWATCH_OPENCLAW_USAGE=off node bin/idlewatch-agent.js --once",
|
|
@@ -13,6 +13,18 @@ if [[ $START_INTERVAL_SEC -lt 60 ]]; then
|
|
|
13
13
|
fi
|
|
14
14
|
|
|
15
15
|
PLIST_PATH="$PLIST_ROOT/$PLIST_LABEL.plist"
|
|
16
|
+
CONFIG_ENV_PATH="${IDLEWATCH_CONFIG_ENV_PATH:-$HOME/.idlewatch/idlewatch.env}"
|
|
17
|
+
DEFAULT_APP_PATH="/Applications/IdleWatch.app"
|
|
18
|
+
DEFAULT_PLIST_ROOT="$HOME/Library/LaunchAgents"
|
|
19
|
+
DEFAULT_PLIST_LABEL="com.idlewatch.agent"
|
|
20
|
+
|
|
21
|
+
if [[ "$PLIST_LABEL" == "$DEFAULT_PLIST_LABEL" ]] && \
|
|
22
|
+
[[ "$PLIST_ROOT" != "$DEFAULT_PLIST_ROOT" || "$APP_PATH" != "$DEFAULT_APP_PATH" ]]; then
|
|
23
|
+
echo "Refusing to reuse the default LaunchAgent label ($DEFAULT_PLIST_LABEL) with a custom app path or plist root." >&2
|
|
24
|
+
echo "launchd uses the label as the real identity, so this could replace your already-loaded IdleWatch agent." >&2
|
|
25
|
+
echo "Use IDLEWATCH_LAUNCH_AGENT_LABEL to pick a different label for side-by-side QA/dev installs." >&2
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
16
28
|
|
|
17
29
|
if [[ ! -d "$PLIST_ROOT" ]]; then
|
|
18
30
|
mkdir -p "$PLIST_ROOT"
|
|
@@ -77,3 +89,20 @@ launchctl bootstrap "gui/$USER_GUID" "$PLIST_PATH"
|
|
|
77
89
|
launchctl enable "$PLIST_ID"
|
|
78
90
|
echo "Installed LaunchAgent: $PLIST_ID"
|
|
79
91
|
echo "Plist: $PLIST_PATH"
|
|
92
|
+
echo "Logs: $LOG_DIR/idlewatch.out.log and $LOG_DIR/idlewatch.err.log"
|
|
93
|
+
if [[ -f "$CONFIG_ENV_PATH" ]]; then
|
|
94
|
+
echo "Saved IdleWatch config found: $CONFIG_ENV_PATH"
|
|
95
|
+
if [[ "$CONFIG_ENV_PATH" == "$HOME/.idlewatch/idlewatch.env" ]]; then
|
|
96
|
+
echo "Background runs will auto-load it."
|
|
97
|
+
else
|
|
98
|
+
echo "Background runs auto-load only the default path: $HOME/.idlewatch/idlewatch.env"
|
|
99
|
+
echo "Move or copy this config there if you want login startup to reuse it automatically."
|
|
100
|
+
fi
|
|
101
|
+
else
|
|
102
|
+
echo "No saved IdleWatch config found yet at: $CONFIG_ENV_PATH"
|
|
103
|
+
echo "Finish setup once before relying on login startup:"
|
|
104
|
+
echo " \"$BIN_PATH\" quickstart"
|
|
105
|
+
if command -v idlewatch >/dev/null 2>&1; then
|
|
106
|
+
echo "If you already installed the CLI on PATH, 'idlewatch quickstart' works too."
|
|
107
|
+
fi
|
|
108
|
+
fi
|
|
@@ -68,9 +68,9 @@ try {
|
|
|
68
68
|
IDLEWATCH_CLOUD_INGEST_URL: cloudIngestUrl,
|
|
69
69
|
IDLEWATCH_ENROLL_OUTPUT_ENV_FILE: envOut,
|
|
70
70
|
IDLEWATCH_ENROLL_CONFIG_DIR: configDir,
|
|
71
|
-
|
|
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
|
|
package/src/enrollment.js
CHANGED
|
@@ -118,12 +118,51 @@ function cargoAvailable() {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
function bundledTuiBinaryPath() {
|
|
121
|
+
const override = String(process.env.IDLEWATCH_TUI_BIN || '').trim()
|
|
122
|
+
if (override) return path.resolve(override)
|
|
123
|
+
|
|
121
124
|
const platform = process.platform
|
|
122
125
|
const arch = process.arch
|
|
123
126
|
const ext = platform === 'win32' ? '.exe' : ''
|
|
124
127
|
return path.join(PACKAGE_ROOT, 'tui', 'bin', `${platform}-${arch}`, `idlewatch-setup${ext}`)
|
|
125
128
|
}
|
|
126
129
|
|
|
130
|
+
function parseEnrollmentResultFromEnvFile(outputEnvFile, { configDir, fallbackDeviceName }) {
|
|
131
|
+
if (!outputEnvFile || !fs.existsSync(outputEnvFile)) return null
|
|
132
|
+
|
|
133
|
+
let raw = ''
|
|
134
|
+
try {
|
|
135
|
+
raw = fs.readFileSync(outputEnvFile, 'utf8')
|
|
136
|
+
} catch {
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const parsed = {}
|
|
141
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
142
|
+
const trimmed = line.trim()
|
|
143
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
144
|
+
const idx = trimmed.indexOf('=')
|
|
145
|
+
if (idx <= 0) continue
|
|
146
|
+
const key = trimmed.slice(0, idx).trim()
|
|
147
|
+
const value = trimmed.slice(idx + 1).trim()
|
|
148
|
+
if (key) parsed[key] = value
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const deviceName = normalizeDeviceName(parsed.IDLEWATCH_DEVICE_NAME || fallbackDeviceName || machineName())
|
|
152
|
+
const deviceId = sanitizeDeviceId(parsed.IDLEWATCH_DEVICE_ID || deviceName, machineName())
|
|
153
|
+
const monitorTargets = normalizeMonitorTargets(parsed.IDLEWATCH_MONITOR_TARGETS || '', detectAvailableMonitorTargets())
|
|
154
|
+
const mode = looksLikeCloudApiKey(parsed.IDLEWATCH_CLOUD_API_KEY || '') ? 'production' : 'local'
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
mode,
|
|
158
|
+
configDir,
|
|
159
|
+
outputEnvFile,
|
|
160
|
+
monitorTargets,
|
|
161
|
+
deviceName,
|
|
162
|
+
deviceId
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
127
166
|
function tryBundledRustTui({ configDir, outputEnvFile }) {
|
|
128
167
|
const binPath = bundledTuiBinaryPath()
|
|
129
168
|
if (!fs.existsSync(binPath)) return { ok: false, reason: 'bundled-binary-missing', binPath }
|
|
@@ -179,8 +218,12 @@ function tryRustTui({ configDir, outputEnvFile }) {
|
|
|
179
218
|
return { ok: false, reason: `cargo-run-failed:${run.status ?? 'unknown'}`, manifestPath }
|
|
180
219
|
}
|
|
181
220
|
|
|
182
|
-
function promptModeText() {
|
|
183
|
-
|
|
221
|
+
function promptModeText({ isReconfigure = false, currentMode = null } = {}) {
|
|
222
|
+
const title = isReconfigure ? 'IdleWatch Reconfigure' : 'IdleWatch Setup Wizard'
|
|
223
|
+
const pad = Math.max(0, Math.floor((47 - title.length) / 2))
|
|
224
|
+
const titleLine = ' '.repeat(pad) + title
|
|
225
|
+
const defaultHint = currentMode === 'local' ? ' (default 2)' : ' (default 1)'
|
|
226
|
+
return `\n╭───────────────────────────────────────────────╮\n│${titleLine.padEnd(47)}│\n╰───────────────────────────────────────────────╯\n\nChoose setup mode:\n 1) Managed cloud (recommended)\n Link this device with an API key from idlewatch.com/api\n 2) Local-only (no cloud writes)\n`
|
|
184
227
|
}
|
|
185
228
|
|
|
186
229
|
export async function runEnrollmentWizard(options = {}) {
|
|
@@ -189,27 +232,40 @@ export async function runEnrollmentWizard(options = {}) {
|
|
|
189
232
|
const configDir = path.resolve(options.configDir || process.env.IDLEWATCH_ENROLL_CONFIG_DIR || defaultConfigDir())
|
|
190
233
|
const outputEnvFile = path.resolve(options.outputEnvFile || process.env.IDLEWATCH_ENROLL_OUTPUT_ENV_FILE || path.join(configDir, 'idlewatch.env'))
|
|
191
234
|
|
|
235
|
+
// Load existing saved config for reconfigure defaults
|
|
236
|
+
let existingConfig = null
|
|
237
|
+
if (fs.existsSync(outputEnvFile)) {
|
|
238
|
+
existingConfig = parseEnrollmentResultFromEnvFile(outputEnvFile, { configDir, fallbackDeviceName: machineName() })
|
|
239
|
+
}
|
|
240
|
+
|
|
192
241
|
let mode = options.mode || process.env.IDLEWATCH_ENROLL_MODE || null
|
|
193
242
|
let cloudApiKey = normalizeCloudApiKey(options.cloudApiKey || process.env.IDLEWATCH_CLOUD_API_KEY || null)
|
|
194
243
|
let cloudIngestUrl = options.cloudIngestUrl || process.env.IDLEWATCH_CLOUD_INGEST_URL || 'https://api.idlewatch.com/api/ingest'
|
|
195
|
-
let deviceName = normalizeDeviceName(
|
|
244
|
+
let deviceName = normalizeDeviceName(
|
|
245
|
+
options.deviceName || process.env.IDLEWATCH_ENROLL_DEVICE_NAME || process.env.IDLEWATCH_DEVICE_NAME || (existingConfig?.deviceName) || machineName()
|
|
246
|
+
)
|
|
196
247
|
|
|
197
248
|
const availableMonitorTargets = detectAvailableMonitorTargets()
|
|
198
249
|
let monitorTargets = normalizeMonitorTargets(
|
|
199
|
-
options.monitorTargets || process.env.IDLEWATCH_MONITOR_TARGETS || '',
|
|
250
|
+
options.monitorTargets || process.env.IDLEWATCH_ENROLL_MONITOR_TARGETS || process.env.IDLEWATCH_MONITOR_TARGETS || '',
|
|
200
251
|
availableMonitorTargets
|
|
201
252
|
)
|
|
202
253
|
|
|
203
254
|
if (!nonInteractive && !noTui) {
|
|
204
255
|
const tuiResult = tryRustTui({ configDir, outputEnvFile })
|
|
256
|
+
const tuiEnrollment = parseEnrollmentResultFromEnvFile(outputEnvFile, { configDir, fallbackDeviceName: deviceName })
|
|
205
257
|
if (tuiResult.ok) {
|
|
206
|
-
return {
|
|
258
|
+
return tuiEnrollment || {
|
|
207
259
|
mode: 'tui',
|
|
208
260
|
configDir,
|
|
209
261
|
outputEnvFile
|
|
210
262
|
}
|
|
211
263
|
}
|
|
212
264
|
|
|
265
|
+
if (tuiEnrollment) {
|
|
266
|
+
return tuiEnrollment
|
|
267
|
+
}
|
|
268
|
+
|
|
213
269
|
if (tuiResult.reason === 'bundled-binary-missing-and-cargo-missing') {
|
|
214
270
|
console.warn('IdleWatch TUI is not bundled for this platform and Cargo is not installed. Falling back to text setup. Use --no-tui to skip this check.')
|
|
215
271
|
} else if (!['disabled', 'cargo-missing', 'bundled-binary-missing'].includes(tuiResult.reason || '')) {
|
|
@@ -220,10 +276,16 @@ export async function runEnrollmentWizard(options = {}) {
|
|
|
220
276
|
let rl = null
|
|
221
277
|
if (!nonInteractive) {
|
|
222
278
|
rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
223
|
-
|
|
279
|
+
const isReconfigure = !!existingConfig
|
|
280
|
+
const currentMode = existingConfig?.mode || null
|
|
281
|
+
const modeDefault = currentMode === 'local' ? '2' : '1'
|
|
282
|
+
console.log(promptModeText({ isReconfigure, currentMode }))
|
|
224
283
|
console.log(`Storage path: ${configDir}`)
|
|
225
284
|
console.log(`Environment file: ${outputEnvFile}`)
|
|
226
|
-
|
|
285
|
+
if (isReconfigure) {
|
|
286
|
+
console.log(`Current device: ${existingConfig.deviceName} (${currentMode === 'production' ? 'cloud' : 'local-only'})`)
|
|
287
|
+
}
|
|
288
|
+
const modeInput = (await rl.question(`\nMode [1/2] (default ${modeDefault}): `)).trim() || modeDefault
|
|
227
289
|
mode = modeInput === '2' ? 'local' : 'production'
|
|
228
290
|
const deviceNameInput = (await rl.question(`Device name [${deviceName}]: `)).trim()
|
|
229
291
|
deviceName = normalizeDeviceName(deviceNameInput || deviceName)
|
|
@@ -235,9 +297,36 @@ export async function runEnrollmentWizard(options = {}) {
|
|
|
235
297
|
}
|
|
236
298
|
|
|
237
299
|
if ((mode === 'production') && !cloudApiKey) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
300
|
+
// Try to reuse existing saved API key when reconfiguring
|
|
301
|
+
if (existingConfig?.mode === 'production' && fs.existsSync(outputEnvFile)) {
|
|
302
|
+
try {
|
|
303
|
+
const raw = fs.readFileSync(outputEnvFile, 'utf8')
|
|
304
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
305
|
+
const trimmed = line.trim()
|
|
306
|
+
if (trimmed.startsWith('IDLEWATCH_CLOUD_API_KEY=')) {
|
|
307
|
+
const savedKey = normalizeCloudApiKey(trimmed.slice('IDLEWATCH_CLOUD_API_KEY='.length))
|
|
308
|
+
if (looksLikeCloudApiKey(savedKey)) {
|
|
309
|
+
cloudApiKey = savedKey
|
|
310
|
+
break
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
} catch { /* ignore */ }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (!cloudApiKey) {
|
|
318
|
+
if (!rl) throw new Error('Missing cloud API key (IDLEWATCH_CLOUD_API_KEY).')
|
|
319
|
+
console.log('\nPaste the API key from idlewatch.com/api.')
|
|
320
|
+
cloudApiKey = normalizeCloudApiKey(await rl.question('Cloud API key: '))
|
|
321
|
+
} else if (rl) {
|
|
322
|
+
const masked = cloudApiKey.slice(0, 8) + '…' + cloudApiKey.slice(-4)
|
|
323
|
+
console.log(`\nUsing saved API key: ${masked}`)
|
|
324
|
+
const changeKey = (await rl.question('Keep this key? [Y/n]: ')).trim().toLowerCase()
|
|
325
|
+
if (changeKey === 'n' || changeKey === 'no') {
|
|
326
|
+
console.log('Paste the new API key from idlewatch.com/api.')
|
|
327
|
+
cloudApiKey = normalizeCloudApiKey(await rl.question('Cloud API key: '))
|
|
328
|
+
}
|
|
329
|
+
}
|
|
241
330
|
}
|
|
242
331
|
|
|
243
332
|
if (!nonInteractive && rl) {
|
|
@@ -247,7 +336,10 @@ export async function runEnrollmentWizard(options = {}) {
|
|
|
247
336
|
monitorTargets = normalizeMonitorTargets(monitorInput || suggested, availableMonitorTargets)
|
|
248
337
|
}
|
|
249
338
|
|
|
250
|
-
const safeDeviceId = sanitizeDeviceId(
|
|
339
|
+
const safeDeviceId = sanitizeDeviceId(
|
|
340
|
+
options.deviceId || process.env.IDLEWATCH_ENROLL_DEVICE_ID || deviceName,
|
|
341
|
+
machineName()
|
|
342
|
+
)
|
|
251
343
|
const localLogPath = path.join(configDir, 'logs', `${safeDeviceId}-metrics.ndjson`)
|
|
252
344
|
const localCachePath = path.join(configDir, 'cache', `${safeDeviceId}-openclaw-last-good.json`)
|
|
253
345
|
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
export const DAY_WINDOW_MS = 24 * 60 * 60 * 1000
|
|
6
|
+
|
|
7
|
+
function readJson(filePath) {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
10
|
+
} catch {
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readJsonLines(filePath) {
|
|
16
|
+
try {
|
|
17
|
+
return fs.readFileSync(filePath, 'utf8')
|
|
18
|
+
.split(/\r?\n/)
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
.map((line) => {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(line)
|
|
23
|
+
} catch {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
} catch {
|
|
29
|
+
return []
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseCronIntervalMs(expr) {
|
|
34
|
+
const value = String(expr || '').trim()
|
|
35
|
+
if (!value) return null
|
|
36
|
+
|
|
37
|
+
const parts = value.split(/\s+/)
|
|
38
|
+
if (parts.length !== 5) return null
|
|
39
|
+
|
|
40
|
+
const [minute, hour] = parts
|
|
41
|
+
const minuteStepMatch = minute.match(/^(?:\*|\d+-\d+)\/(\d+)$/)
|
|
42
|
+
if (minuteStepMatch && hour === '*') {
|
|
43
|
+
const minutes = Number(minuteStepMatch[1])
|
|
44
|
+
return Number.isFinite(minutes) && minutes > 0 ? minutes * 60 * 1000 : null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (/^\d+$/.test(minute) && /^\*\/(\d+)$/.test(hour)) {
|
|
48
|
+
const hours = Number(hour.slice(2))
|
|
49
|
+
return Number.isFinite(hours) && hours > 0 ? hours * 60 * 60 * 1000 : null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (/^\d+$/.test(minute) && hour === '*') return 60 * 60 * 1000
|
|
53
|
+
if (/^\d+$/.test(minute) && /^\d+$/.test(hour)) return 24 * 60 * 60 * 1000
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function round(value, digits = 1) {
|
|
58
|
+
if (!Number.isFinite(value)) return null
|
|
59
|
+
const factor = 10 ** digits
|
|
60
|
+
return Math.round(value * factor) / factor
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function loadOpenClawActivitySummary(options = {}) {
|
|
64
|
+
const homeDir = options.homeDir || os.homedir()
|
|
65
|
+
const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now()
|
|
66
|
+
const windowMs = Number.isFinite(options.windowMs) ? options.windowMs : DAY_WINDOW_MS
|
|
67
|
+
const cutoffMs = nowMs - windowMs
|
|
68
|
+
const openclawDir = path.join(homeDir, '.openclaw')
|
|
69
|
+
const jobsPath = path.join(openclawDir, 'cron', 'jobs.json')
|
|
70
|
+
const runsDir = path.join(openclawDir, 'cron', 'runs')
|
|
71
|
+
|
|
72
|
+
const jobsPayload = readJson(jobsPath)
|
|
73
|
+
const jobs = Array.isArray(jobsPayload?.jobs) ? jobsPayload.jobs : []
|
|
74
|
+
|
|
75
|
+
if (jobs.length === 0 || !fs.existsSync(runsDir)) return null
|
|
76
|
+
|
|
77
|
+
const jobsById = new Map(jobs.map((job) => [String(job.id || ''), job]).filter(([id]) => id))
|
|
78
|
+
const summaries = new Map()
|
|
79
|
+
|
|
80
|
+
for (const entry of fs.readdirSync(runsDir, { withFileTypes: true })) {
|
|
81
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue
|
|
82
|
+
|
|
83
|
+
const jobId = entry.name.replace(/\.jsonl$/, '')
|
|
84
|
+
const job = jobsById.get(jobId)
|
|
85
|
+
const runs = readJsonLines(path.join(runsDir, entry.name))
|
|
86
|
+
|
|
87
|
+
for (const run of runs) {
|
|
88
|
+
const runAtMs = Number(run.runAtMs ?? run.ts ?? 0)
|
|
89
|
+
const durationMs = Number(run.durationMs ?? 0)
|
|
90
|
+
const runStatus = String(run.status || 'unknown')
|
|
91
|
+
|
|
92
|
+
if (!Number.isFinite(runAtMs) || runAtMs < cutoffMs) continue
|
|
93
|
+
if (!Number.isFinite(durationMs) || durationMs <= 0) continue
|
|
94
|
+
if (run.action && run.action !== 'finished') continue
|
|
95
|
+
|
|
96
|
+
const intervalMs = parseCronIntervalMs(job?.schedule?.expr)
|
|
97
|
+
const summary = summaries.get(jobId) || {
|
|
98
|
+
id: jobId,
|
|
99
|
+
label: String(job?.name || run.summary || jobId).trim() || jobId,
|
|
100
|
+
kind: String(job?.schedule?.kind || '').trim() || 'cron',
|
|
101
|
+
intervalMs,
|
|
102
|
+
enabled: Boolean(job?.enabled),
|
|
103
|
+
totalDurationMs: 0,
|
|
104
|
+
runCount: 0,
|
|
105
|
+
okCount: 0,
|
|
106
|
+
errorCount: 0,
|
|
107
|
+
maxDurationMs: 0,
|
|
108
|
+
lastRunAtMs: 0,
|
|
109
|
+
lastStatus: 'unknown'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
summary.totalDurationMs += durationMs
|
|
113
|
+
summary.runCount += 1
|
|
114
|
+
summary.maxDurationMs = Math.max(summary.maxDurationMs, durationMs)
|
|
115
|
+
summary.lastRunAtMs = Math.max(summary.lastRunAtMs, runAtMs)
|
|
116
|
+
summary.lastStatus = runStatus
|
|
117
|
+
|
|
118
|
+
if (runStatus === 'error') summary.errorCount += 1
|
|
119
|
+
else summary.okCount += 1
|
|
120
|
+
|
|
121
|
+
summaries.set(jobId, summary)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const jobsSummary = [...summaries.values()]
|
|
126
|
+
.filter((item) => item.runCount > 0 && item.totalDurationMs > 0)
|
|
127
|
+
.sort((a, b) => b.totalDurationMs - a.totalDurationMs)
|
|
128
|
+
.map((item) => {
|
|
129
|
+
const avgDurationMs = item.totalDurationMs / item.runCount
|
|
130
|
+
const cycleOccupancyPct = item.intervalMs && item.intervalMs > 0
|
|
131
|
+
? round(Math.min(100, (avgDurationMs / item.intervalMs) * 100), 1)
|
|
132
|
+
: null
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
id: item.id,
|
|
136
|
+
label: item.label,
|
|
137
|
+
kind: item.kind,
|
|
138
|
+
enabled: item.enabled,
|
|
139
|
+
intervalMs: item.intervalMs,
|
|
140
|
+
runCount: item.runCount,
|
|
141
|
+
okCount: item.okCount,
|
|
142
|
+
errorCount: item.errorCount,
|
|
143
|
+
seconds: round(item.totalDurationMs / 1000, 1) ?? 0,
|
|
144
|
+
avgDurationMs: round(avgDurationMs, 0) ?? 0,
|
|
145
|
+
maxDurationMs: round(item.maxDurationMs, 0) ?? 0,
|
|
146
|
+
cycleOccupancyPct,
|
|
147
|
+
lastRunAtMs: item.lastRunAtMs,
|
|
148
|
+
lastStatus: item.lastStatus
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
if (jobsSummary.length === 0) return null
|
|
153
|
+
|
|
154
|
+
const totalActiveSeconds = jobsSummary.reduce((sum, item) => sum + Math.max(0, Number(item.seconds || 0)), 0)
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
source: 'openclaw-cron-runs',
|
|
158
|
+
windowMs,
|
|
159
|
+
totalActiveSeconds: round(totalActiveSeconds, 1) ?? 0,
|
|
160
|
+
idleSeconds: round(Math.max(0, windowMs / 1000 - totalActiveSeconds), 1) ?? 0,
|
|
161
|
+
jobs: jobsSummary
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/openclaw-usage.js
CHANGED
|
@@ -49,6 +49,67 @@ function pickString(...vals) {
|
|
|
49
49
|
return null
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
function normalizeProviderName(value) {
|
|
53
|
+
const normalized = pickString(value)?.toLowerCase()
|
|
54
|
+
if (!normalized) return null
|
|
55
|
+
|
|
56
|
+
if (['openai', 'anthropic', 'google', 'local', 'other'].includes(normalized)) return normalized
|
|
57
|
+
if (normalized === 'claude') return 'anthropic'
|
|
58
|
+
if (normalized === 'gemini') return 'google'
|
|
59
|
+
if (normalized === 'ollama') return 'local'
|
|
60
|
+
return normalized
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function inferProviderFromModel(model) {
|
|
64
|
+
const normalized = pickString(model)?.toLowerCase()
|
|
65
|
+
if (!normalized) return null
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
normalized.startsWith('openai/') ||
|
|
69
|
+
normalized.startsWith('gpt-') ||
|
|
70
|
+
normalized.startsWith('chatgpt') ||
|
|
71
|
+
normalized.startsWith('codex') ||
|
|
72
|
+
/^o[1345](?:$|[-.:/])/.test(normalized)
|
|
73
|
+
) return 'openai'
|
|
74
|
+
|
|
75
|
+
if (normalized.startsWith('anthropic/') || normalized.includes('claude')) return 'anthropic'
|
|
76
|
+
if (normalized.startsWith('google/') || normalized.includes('gemini')) return 'google'
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
normalized.startsWith('ollama/') ||
|
|
80
|
+
normalized.includes('qwen') ||
|
|
81
|
+
normalized.includes('llama') ||
|
|
82
|
+
normalized.includes('mistral') ||
|
|
83
|
+
normalized.includes('mixtral') ||
|
|
84
|
+
normalized.includes('deepseek') ||
|
|
85
|
+
normalized.includes('gemma') ||
|
|
86
|
+
normalized.includes('phi') ||
|
|
87
|
+
normalized.includes('qwq') ||
|
|
88
|
+
/:[0-9]+[a-z]?$/.test(normalized)
|
|
89
|
+
) return 'local'
|
|
90
|
+
|
|
91
|
+
return 'other'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function deriveRemainingTokens(totalTokens, contextTokens) {
|
|
95
|
+
if (!Number.isFinite(totalTokens) || !Number.isFinite(contextTokens)) return null
|
|
96
|
+
if (contextTokens < totalTokens) return null
|
|
97
|
+
return contextTokens - totalTokens
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function derivePercentUsed(totalTokens, contextTokens, remainingTokens = null) {
|
|
101
|
+
if (!Number.isFinite(contextTokens) || contextTokens <= 0) return null
|
|
102
|
+
|
|
103
|
+
const usedTokens = Number.isFinite(totalTokens)
|
|
104
|
+
? totalTokens
|
|
105
|
+
: Number.isFinite(remainingTokens)
|
|
106
|
+
? contextTokens - remainingTokens
|
|
107
|
+
: null
|
|
108
|
+
|
|
109
|
+
if (!Number.isFinite(usedTokens) || usedTokens < 0) return null
|
|
110
|
+
return Math.max(0, Math.min(100, Math.round((usedTokens / contextTokens) * 100)))
|
|
111
|
+
}
|
|
112
|
+
|
|
52
113
|
function isFreshTokenMarker(value) {
|
|
53
114
|
if (value === false || value === 0) return false
|
|
54
115
|
if (value === null || typeof value === 'undefined') return true
|
|
@@ -615,6 +676,40 @@ function parseFromStatusJson(parsed) {
|
|
|
615
676
|
deriveTokensPerMinute(session),
|
|
616
677
|
pickNumber(session?.derived?.tokensPerMinute, session?.derived?.tpm)
|
|
617
678
|
)
|
|
679
|
+
const contextTokens = pickNumber(
|
|
680
|
+
session.contextTokens,
|
|
681
|
+
session.context_tokens,
|
|
682
|
+
session?.usage?.contextTokens,
|
|
683
|
+
session?.usage?.context_tokens,
|
|
684
|
+
defaults.contextTokens,
|
|
685
|
+
defaults.context_tokens,
|
|
686
|
+
parsed?.contextTokens,
|
|
687
|
+
parsed?.context_tokens,
|
|
688
|
+
parsed?.status?.contextTokens,
|
|
689
|
+
parsed?.status?.context_tokens
|
|
690
|
+
)
|
|
691
|
+
const remainingTokens = pickNumber(
|
|
692
|
+
session.remainingTokens,
|
|
693
|
+
session.remaining_tokens,
|
|
694
|
+
session?.usage?.remainingTokens,
|
|
695
|
+
session?.usage?.remaining_tokens,
|
|
696
|
+
parsed?.remainingTokens,
|
|
697
|
+
parsed?.remaining_tokens,
|
|
698
|
+
parsed?.status?.remainingTokens,
|
|
699
|
+
parsed?.status?.remaining_tokens,
|
|
700
|
+
deriveRemainingTokens(totalTokens, contextTokens)
|
|
701
|
+
)
|
|
702
|
+
const percentUsed = pickNumber(
|
|
703
|
+
session.percentUsed,
|
|
704
|
+
session.percent_used,
|
|
705
|
+
session?.usage?.percentUsed,
|
|
706
|
+
session?.usage?.percent_used,
|
|
707
|
+
parsed?.percentUsed,
|
|
708
|
+
parsed?.percent_used,
|
|
709
|
+
parsed?.status?.percentUsed,
|
|
710
|
+
parsed?.status?.percent_used,
|
|
711
|
+
derivePercentUsed(totalTokens, contextTokens, remainingTokens)
|
|
712
|
+
)
|
|
618
713
|
const sessionAgeMs = pickNumber(session.age, session.ageMs)
|
|
619
714
|
const usageTimestampMs =
|
|
620
715
|
pickTimestamp(
|
|
@@ -676,11 +771,31 @@ function parseFromStatusJson(parsed) {
|
|
|
676
771
|
|
|
677
772
|
return {
|
|
678
773
|
model,
|
|
774
|
+
provider: normalizeProviderName(
|
|
775
|
+
pickString(
|
|
776
|
+
session.provider,
|
|
777
|
+
session.providerName,
|
|
778
|
+
session.provider_name,
|
|
779
|
+
session?.usage?.provider,
|
|
780
|
+
session?.usage?.providerName,
|
|
781
|
+
session?.usage?.provider_name,
|
|
782
|
+
parsed?.provider,
|
|
783
|
+
parsed?.providerName,
|
|
784
|
+
parsed?.provider_name,
|
|
785
|
+
parsed?.status?.provider,
|
|
786
|
+
parsed?.status?.providerName,
|
|
787
|
+
parsed?.status?.provider_name
|
|
788
|
+
)
|
|
789
|
+
) ?? inferProviderFromModel(model),
|
|
679
790
|
totalTokens,
|
|
680
791
|
tokensPerMin,
|
|
792
|
+
remainingTokens,
|
|
793
|
+
percentUsed,
|
|
794
|
+
contextTokens,
|
|
681
795
|
sessionId: pickString(session.sessionId, session.id, session?.usage?.sessionId, session?.usage?.id, session?.session_id),
|
|
682
796
|
agentId: pickString(session.agentId, session?.usage?.agentId, session?.agent_id),
|
|
683
797
|
usageTimestampMs,
|
|
798
|
+
budgetKind: contextTokens !== null ? 'context-window' : null,
|
|
684
799
|
integrationStatus: hasStrongUsage ? 'ok' : 'partial'
|
|
685
800
|
}
|
|
686
801
|
}
|
|
@@ -837,13 +952,73 @@ function parseGenericUsage(parsed) {
|
|
|
837
952
|
parsed?.rps,
|
|
838
953
|
parsed?.requestsPerMinute
|
|
839
954
|
)
|
|
955
|
+
const contextTokens = pickNumber(
|
|
956
|
+
usageRecord?.contextTokens,
|
|
957
|
+
usageRecord?.context_tokens,
|
|
958
|
+
usageRecord?.usage?.contextTokens,
|
|
959
|
+
usageRecord?.usage?.context_tokens,
|
|
960
|
+
usageTotals?.contextTokens,
|
|
961
|
+
usageTotals?.context_tokens,
|
|
962
|
+
parsed?.contextTokens,
|
|
963
|
+
parsed?.context_tokens,
|
|
964
|
+
parsed?.status?.contextTokens,
|
|
965
|
+
parsed?.status?.context_tokens
|
|
966
|
+
)
|
|
967
|
+
const remainingTokens = pickNumber(
|
|
968
|
+
usageRecord?.remainingTokens,
|
|
969
|
+
usageRecord?.remaining_tokens,
|
|
970
|
+
usageRecord?.usage?.remainingTokens,
|
|
971
|
+
usageRecord?.usage?.remaining_tokens,
|
|
972
|
+
usageTotals?.remainingTokens,
|
|
973
|
+
usageTotals?.remaining_tokens,
|
|
974
|
+
parsed?.remainingTokens,
|
|
975
|
+
parsed?.remaining_tokens,
|
|
976
|
+
parsed?.status?.remainingTokens,
|
|
977
|
+
parsed?.status?.remaining_tokens,
|
|
978
|
+
deriveRemainingTokens(totalTokens, contextTokens)
|
|
979
|
+
)
|
|
980
|
+
const percentUsed = pickNumber(
|
|
981
|
+
usageRecord?.percentUsed,
|
|
982
|
+
usageRecord?.percent_used,
|
|
983
|
+
usageRecord?.usage?.percentUsed,
|
|
984
|
+
usageRecord?.usage?.percent_used,
|
|
985
|
+
usageTotals?.percentUsed,
|
|
986
|
+
usageTotals?.percent_used,
|
|
987
|
+
parsed?.percentUsed,
|
|
988
|
+
parsed?.percent_used,
|
|
989
|
+
parsed?.status?.percentUsed,
|
|
990
|
+
parsed?.status?.percent_used,
|
|
991
|
+
derivePercentUsed(totalTokens, contextTokens, remainingTokens)
|
|
992
|
+
)
|
|
840
993
|
|
|
841
994
|
if (model === null && totalTokens === null && tokensPerMin === null) return null
|
|
842
995
|
|
|
843
996
|
return {
|
|
844
997
|
model,
|
|
998
|
+
provider: normalizeProviderName(
|
|
999
|
+
pickString(
|
|
1000
|
+
usageRecord?.provider,
|
|
1001
|
+
usageRecord?.providerName,
|
|
1002
|
+
usageRecord?.provider_name,
|
|
1003
|
+
usageRecord?.usage?.provider,
|
|
1004
|
+
usageRecord?.usage?.providerName,
|
|
1005
|
+
usageRecord?.usage?.provider_name,
|
|
1006
|
+
usageTotals?.provider,
|
|
1007
|
+
usageTotals?.providerName,
|
|
1008
|
+
usageTotals?.provider_name,
|
|
1009
|
+
parsed?.provider,
|
|
1010
|
+
parsed?.providerName,
|
|
1011
|
+
parsed?.provider_name,
|
|
1012
|
+
parsed?.status?.provider,
|
|
1013
|
+
parsed?.status?.providerName,
|
|
1014
|
+
parsed?.status?.provider_name
|
|
1015
|
+
)
|
|
1016
|
+
) ?? inferProviderFromModel(model),
|
|
845
1017
|
totalTokens,
|
|
846
1018
|
tokensPerMin,
|
|
1019
|
+
remainingTokens,
|
|
1020
|
+
percentUsed,
|
|
1021
|
+
contextTokens,
|
|
847
1022
|
sessionId: pickString(
|
|
848
1023
|
parsed?.sessionId,
|
|
849
1024
|
parsed?.session_id,
|
|
@@ -935,6 +1110,7 @@ function parseGenericUsage(parsed) {
|
|
|
935
1110
|
parsed?.status?.usage_time,
|
|
936
1111
|
parsed?.status?.usageTsMs
|
|
937
1112
|
),
|
|
1113
|
+
budgetKind: contextTokens !== null ? 'context-window' : null,
|
|
938
1114
|
integrationStatus: 'ok'
|
|
939
1115
|
}
|
|
940
1116
|
}
|
|
@@ -947,6 +1123,8 @@ function usageCandidateScore(usage) {
|
|
|
947
1123
|
if (usage.model !== null) score += 2
|
|
948
1124
|
if (usage.totalTokens !== null) score += 2
|
|
949
1125
|
if (usage.tokensPerMin !== null) score += 1
|
|
1126
|
+
if (usage.remainingTokens !== null) score += 1
|
|
1127
|
+
if (usage.contextTokens !== null) score += 1
|
|
950
1128
|
if (usage.sessionId !== null) score += 1
|
|
951
1129
|
if (usage.agentId !== null) score += 1
|
|
952
1130
|
if (usage.usageTimestampMs !== null) score += 1
|
package/src/telemetry-mapping.js
CHANGED
|
@@ -26,7 +26,12 @@ export function enrichWithOpenClawFleetTelemetry(sample, context = {}) {
|
|
|
26
26
|
},
|
|
27
27
|
usage: {
|
|
28
28
|
model: sample.openclawModel ?? null,
|
|
29
|
+
provider: sample.openclawProvider ?? null,
|
|
29
30
|
totalTokens: sample.openclawTotalTokens ?? null,
|
|
31
|
+
remainingTokens: sample.openclawRemainingTokens ?? null,
|
|
32
|
+
percentUsed: sample.openclawPercentUsed ?? null,
|
|
33
|
+
contextTokens: sample.openclawContextTokens ?? null,
|
|
34
|
+
budgetKind: sample.openclawBudgetKind ?? null,
|
|
30
35
|
tokensPerMin: sample.tokensPerMin ?? null,
|
|
31
36
|
sessionId: sample.openclawSessionId ?? null,
|
|
32
37
|
agentId: sample.openclawAgentId ?? null,
|
package/tui/src/main.rs
CHANGED
|
@@ -701,6 +701,6 @@ fn main() -> Result<()> {
|
|
|
701
701
|
println!("Saved device name: {}", device_name);
|
|
702
702
|
println!("You can rerun this TUI anytime to update device name, API key, or metrics.");
|
|
703
703
|
println!("Next step: idlewatch-agent --once");
|
|
704
|
-
println!("For background startup on macOS:
|
|
704
|
+
println!("For background startup on macOS: /Applications/IdleWatch.app/Contents/Resources/payload/package/scripts/install-macos-launch-agent.sh");
|
|
705
705
|
Ok(())
|
|
706
706
|
}
|