idlewatch 0.1.7 → 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.
@@ -10,6 +10,7 @@ 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'
@@ -104,9 +105,12 @@ function buildSetupTestEnv(enrolledEnv) {
104
105
 
105
106
  const persistedEnv = loadPersistedEnvIntoProcess()
106
107
 
108
+ const OPENCLAW_AGENT_TARGETS = ['agent_activity', 'token_usage', 'runtime_state']
109
+ const OPENCLAW_DERIVED_TARGETS = [...OPENCLAW_AGENT_TARGETS]
110
+
107
111
  function parseMonitorTargets(raw) {
108
- const allowed = new Set(['cpu', 'memory', 'gpu', 'openclaw'])
109
- const fallback = ['cpu', 'memory', 'openclaw', 'gpu']
112
+ const allowed = new Set(['cpu', 'memory', 'gpu', 'temperature', 'openclaw', ...OPENCLAW_DERIVED_TARGETS])
113
+ const fallback = ['cpu', 'memory', 'gpu', 'temperature', ...OPENCLAW_DERIVED_TARGETS]
110
114
 
111
115
  if (!raw || typeof raw !== 'string') {
112
116
  return new Set(fallback)
@@ -116,6 +120,7 @@ function parseMonitorTargets(raw) {
116
120
  .split(',')
117
121
  .map((item) => item.trim().toLowerCase())
118
122
  .filter((item) => allowed.has(item))
123
+ .flatMap((item) => (item === 'openclaw' ? OPENCLAW_DERIVED_TARGETS : [item]))
119
124
 
120
125
  if (parsed.length === 0) return new Set(fallback)
121
126
  return new Set(parsed)
@@ -244,12 +249,20 @@ function buildRollingLoadSummary(logPath, nowMs = Date.now(), windowMs = DAY_WIN
244
249
  return Number((valid.reduce((sum, value) => sum + value, 0) / valid.length).toFixed(2))
245
250
  }
246
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
+
247
258
  return {
248
259
  windowMs,
249
260
  sampleCount: rows.length,
250
261
  cpuAvgPct: average(rows.map((row) => Number(row.cpuPct))),
251
262
  memAvgPct: average(rows.map((row) => Number(row.memPct))),
252
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))),
253
266
  tokensAvgPerMin: average(rows.map((row) => Number(row.tokensPerMin)))
254
267
  }
255
268
  }
@@ -493,8 +506,12 @@ const MONITOR_TARGETS = parseMonitorTargets(process.env.IDLEWATCH_MONITOR_TARGET
493
506
  const MONITOR_CPU = MONITOR_TARGETS.has('cpu')
494
507
  const MONITOR_MEMORY = MONITOR_TARGETS.has('memory')
495
508
  const MONITOR_GPU = MONITOR_TARGETS.has('gpu')
496
- const MONITOR_OPENCLAW = MONITOR_TARGETS.has('openclaw')
497
- const EFFECTIVE_OPENCLAW_MODE = MONITOR_OPENCLAW ? OPENCLAW_USAGE_MODE : 'off'
509
+ const MONITOR_TEMPERATURE = MONITOR_TARGETS.has('temperature')
510
+ const MONITOR_AGENT_ACTIVITY = MONITOR_TARGETS.has('agent_activity')
511
+ const MONITOR_TOKEN_USAGE = MONITOR_TARGETS.has('token_usage')
512
+ const MONITOR_RUNTIME_STATE = MONITOR_TARGETS.has('runtime_state')
513
+ const MONITOR_OPENCLAW_USAGE = MONITOR_TOKEN_USAGE || MONITOR_RUNTIME_STATE
514
+ const EFFECTIVE_OPENCLAW_MODE = MONITOR_OPENCLAW_USAGE ? OPENCLAW_USAGE_MODE : 'off'
498
515
  const REQUIRE_FIREBASE_WRITES = process.env.IDLEWATCH_REQUIRE_FIREBASE_WRITES === '1'
499
516
  const CLOUD_INGEST_URL = (process.env.IDLEWATCH_CLOUD_INGEST_URL || '').trim()
500
517
  const CLOUD_API_KEY = (process.env.IDLEWATCH_CLOUD_API_KEY || '').trim().replace(/^['"]|['"]$/g, '')
@@ -1320,8 +1337,8 @@ async function publish(row, retries = 2) {
1320
1337
  async function collectSample() {
1321
1338
  const sampleStartMs = Date.now()
1322
1339
  const dayLoadSummary = buildRollingLoadSummary(LOCAL_LOG_PATH, sampleStartMs)
1323
- const openclawEnabled = EFFECTIVE_OPENCLAW_MODE !== 'off'
1324
- const activitySummary = openclawEnabled ? loadOpenClawActivitySummary({ nowMs: sampleStartMs }) : null
1340
+ const openclawUsageEnabled = EFFECTIVE_OPENCLAW_MODE !== 'off'
1341
+ const activitySummary = MONITOR_AGENT_ACTIVITY ? loadOpenClawActivitySummary({ nowMs: sampleStartMs }) : null
1325
1342
 
1326
1343
  const disabledProbe = {
1327
1344
  result: 'disabled',
@@ -1334,7 +1351,7 @@ async function collectSample() {
1334
1351
  fallbackCacheSource: null
1335
1352
  }
1336
1353
 
1337
- let usageProbe = openclawEnabled ? loadOpenClawUsage() : { usage: null, probe: disabledProbe }
1354
+ let usageProbe = openclawUsageEnabled ? loadOpenClawUsage() : { usage: null, probe: disabledProbe }
1338
1355
  let usage = usageProbe.usage
1339
1356
  let usageFreshness = deriveUsageFreshness(usage, sampleStartMs, USAGE_STALE_MS, USAGE_NEAR_STALE_MS, USAGE_STALE_GRACE_MS)
1340
1357
  let usageRefreshAttempted = false
@@ -1346,7 +1363,7 @@ async function collectSample() {
1346
1363
  const shouldRefreshForNearStale = USAGE_REFRESH_ON_NEAR_STALE === 1 && usageFreshness.isNearStale
1347
1364
  const canRefreshFromCurrentState = usageProbe.probe.result === 'ok' || usageProbe.probe.result === 'fallback-cache'
1348
1365
 
1349
- if (openclawEnabled && usage && (usageFreshness.isPastStaleThreshold || shouldRefreshForNearStale) && canRefreshFromCurrentState) {
1366
+ if (openclawUsageEnabled && usage && (usageFreshness.isPastStaleThreshold || shouldRefreshForNearStale) && canRefreshFromCurrentState) {
1350
1367
  usageRefreshAttempted = true
1351
1368
  usageRefreshStartMs = Date.now()
1352
1369
 
@@ -1390,6 +1407,9 @@ async function collectSample() {
1390
1407
  : { pct: null, cls: 'disabled', source: 'disabled' }
1391
1408
 
1392
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' }
1393
1413
 
1394
1414
  const usageIntegrationStatus = usage
1395
1415
  ? usageFreshness.isStale
@@ -1397,20 +1417,20 @@ async function collectSample() {
1397
1417
  : usage?.integrationStatus === 'partial'
1398
1418
  ? 'ok'
1399
1419
  : (usage?.integrationStatus ?? 'ok')
1400
- : (openclawEnabled ? 'unavailable' : 'disabled')
1420
+ : (openclawUsageEnabled ? 'unavailable' : 'disabled')
1401
1421
 
1402
1422
  const source = {
1403
1423
  monitorTargets: [...MONITOR_TARGETS],
1404
- usage: usage ? 'openclaw' : openclawEnabled ? 'unavailable' : 'disabled',
1424
+ usage: usage ? 'openclaw' : openclawUsageEnabled ? 'unavailable' : 'disabled',
1405
1425
  usageIntegrationStatus,
1406
- usageIngestionStatus: openclawEnabled
1426
+ usageIngestionStatus: openclawUsageEnabled
1407
1427
  ? usage && ['ok', 'fallback-cache'].includes(usageProbe.probe.result)
1408
1428
  ? 'ok'
1409
1429
  : 'unavailable'
1410
1430
  : 'disabled',
1411
1431
  usageActivityStatus: usage
1412
1432
  ? usageFreshness.freshnessState
1413
- : (openclawEnabled ? 'unavailable' : 'disabled'),
1433
+ : (openclawUsageEnabled ? 'unavailable' : 'disabled'),
1414
1434
  usageProbeResult: usageProbe.probe.result,
1415
1435
  usageProbeAttempts: usageProbe.probe.attempts,
1416
1436
  usageProbeSweeps: usageProbe.probe.sweeps,
@@ -1421,7 +1441,7 @@ async function collectSample() {
1421
1441
  usageUsedFallbackCache: usageProbe.probe.usedFallbackCache,
1422
1442
  usageFallbackCacheAgeMs: usageProbe.probe.fallbackAgeMs,
1423
1443
  usageFallbackCacheSource: usageProbe.probe.fallbackCacheSource,
1424
- usageFreshnessState: openclawEnabled
1444
+ usageFreshnessState: openclawUsageEnabled
1425
1445
  ? usage
1426
1446
  ? usageFreshness.freshnessState
1427
1447
  : null
@@ -1441,8 +1461,10 @@ async function collectSample() {
1441
1461
  usageStaleMsThreshold: USAGE_STALE_MS,
1442
1462
  usageNearStaleMsThreshold: USAGE_NEAR_STALE_MS,
1443
1463
  usageStaleGraceMs: USAGE_STALE_GRACE_MS,
1444
- activitySource: activitySummary?.source ?? (openclawEnabled ? 'unavailable' : 'disabled'),
1445
- activityWindowMs: activitySummary?.windowMs ?? null,
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,
1446
1468
  memPressureSource: memPressure.source,
1447
1469
  cloudIngestionStatus: CLOUD_INGEST_URL && CLOUD_API_KEY
1448
1470
  ? cloudIngestKickedOut ? 'kicked-out' : 'enabled'
@@ -1467,30 +1489,37 @@ async function collectSample() {
1467
1489
  memPressurePct: MONITOR_MEMORY ? memPressure.pct : null,
1468
1490
  memPressureClass: MONITOR_MEMORY ? memPressure.cls : 'disabled',
1469
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',
1470
1495
  dayWindowMs: dayLoadSummary?.windowMs ?? DAY_WINDOW_MS,
1471
1496
  dayCpuAvgPct: MONITOR_CPU ? (dayLoadSummary?.cpuAvgPct ?? null) : null,
1472
1497
  dayMemAvgPct: MONITOR_MEMORY ? (dayLoadSummary?.memAvgPct ?? null) : null,
1473
1498
  dayGpuAvgPct: MONITOR_GPU ? (dayLoadSummary?.gpuAvgPct ?? null) : null,
1474
- dayTokensAvgPerMin: MONITOR_OPENCLAW ? (dayLoadSummary?.tokensAvgPerMin ?? 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,
1475
1502
  gpuSource: gpu.source,
1476
1503
  gpuConfidence: gpu.confidence,
1477
1504
  gpuSampleWindowMs: gpu.sampleWindowMs,
1478
- tokensPerMin: MONITOR_OPENCLAW ? (usage?.tokensPerMin ?? null) : null,
1479
- openclawModel: MONITOR_OPENCLAW ? (usage?.model ?? null) : null,
1480
- openclawProvider: MONITOR_OPENCLAW ? (usage?.provider ?? null) : null,
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,
1486
- openclawSessionId: MONITOR_OPENCLAW ? (usage?.sessionId ?? null) : null,
1487
- openclawAgentId: MONITOR_OPENCLAW ? (usage?.agentId ?? null) : null,
1488
- openclawUsageTs: MONITOR_OPENCLAW ? (usage?.usageTimestampMs ?? null) : null,
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 ?? []) : [],
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 ?? []) : [],
1494
1523
  localLogPath: LOCAL_LOG_PATH,
1495
1524
  localLogBytes: null,
1496
1525
  source
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idlewatch",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Host telemetry collector for IdleWatch",
5
5
  "type": "module",
6
6
  "files": [
@@ -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')
package/src/enrollment.js CHANGED
@@ -42,7 +42,9 @@ function writeSecureFile(filePath, content) {
42
42
  }
43
43
  }
44
44
 
45
- const MONITOR_TARGET_CHOICES = ['cpu', 'memory', 'gpu', 'openclaw']
45
+ const OPENCLAW_AGENT_TARGETS = ['agent_activity', 'token_usage', 'runtime_state']
46
+ const OPENCLAW_DERIVED_TARGETS = [...OPENCLAW_AGENT_TARGETS]
47
+ const MONITOR_TARGET_CHOICES = ['cpu', 'memory', 'gpu', 'temperature', 'openclaw', ...OPENCLAW_DERIVED_TARGETS]
46
48
  const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url))
47
49
  const PACKAGE_ROOT = path.resolve(MODULE_DIR, '..')
48
50
 
@@ -57,23 +59,28 @@ function detectAvailableMonitorTargets() {
57
59
  if (process.platform === 'darwin' || commandExists('nvidia-smi', ['--help'])) {
58
60
  available.add('gpu')
59
61
  }
62
+ if (process.platform === 'darwin') {
63
+ available.add('temperature')
64
+ }
60
65
 
61
66
  if (commandExists('openclaw', ['--help'])) {
62
- available.add('openclaw')
67
+ OPENCLAW_DERIVED_TARGETS.forEach((target) => available.add(target))
63
68
  }
64
69
 
65
70
  return [...available]
66
71
  }
67
72
 
68
73
  function normalizeMonitorTargets(raw, available) {
69
- const fallback = ['cpu', 'memory', ...(available.includes('openclaw') ? ['openclaw'] : []), ...(available.includes('gpu') ? ['gpu'] : [])]
74
+ const fallback = ['cpu', 'memory', ...(available.includes('gpu') ? ['gpu'] : []), ...(available.includes('temperature') ? ['temperature'] : []), ...OPENCLAW_DERIVED_TARGETS.filter((target) => available.includes(target))]
70
75
  if (!raw) return fallback
71
76
 
72
77
  const parsed = raw
73
78
  .split(',')
74
79
  .map((item) => item.trim().toLowerCase())
75
80
  .filter(Boolean)
76
- .filter((item) => MONITOR_TARGET_CHOICES.includes(item) && available.includes(item))
81
+ .filter((item) => MONITOR_TARGET_CHOICES.includes(item))
82
+ .flatMap((item) => (item === 'openclaw' ? OPENCLAW_DERIVED_TARGETS : [item]))
83
+ .filter((item) => available.includes(item))
77
84
 
78
85
  if (parsed.length === 0) return fallback
79
86
  return [...new Set(parsed)]
@@ -226,6 +233,10 @@ function promptModeText({ isReconfigure = false, currentMode = null } = {}) {
226
233
  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`
227
234
  }
228
235
 
236
+ function monitorTargetsNeedOpenClawUsage(monitorTargets) {
237
+ return monitorTargets.some((item) => OPENCLAW_AGENT_TARGETS.includes(item) && item !== 'agent_activity')
238
+ }
239
+
229
240
  export async function runEnrollmentWizard(options = {}) {
230
241
  const nonInteractive = options.nonInteractive || process.env.IDLEWATCH_ENROLL_NON_INTERACTIVE === '1'
231
242
  const noTui = options.noTui || process.env.IDLEWATCH_NO_TUI === '1'
@@ -348,7 +359,7 @@ export async function runEnrollmentWizard(options = {}) {
348
359
  `IDLEWATCH_DEVICE_NAME=${deviceName}`,
349
360
  `IDLEWATCH_DEVICE_ID=${safeDeviceId}`,
350
361
  `IDLEWATCH_MONITOR_TARGETS=${monitorTargets.join(',')}`,
351
- `IDLEWATCH_OPENCLAW_USAGE=${monitorTargets.includes('openclaw') ? 'auto' : 'off'}`,
362
+ `IDLEWATCH_OPENCLAW_USAGE=${monitorTargetsNeedOpenClawUsage(monitorTargets) ? 'auto' : 'off'}`,
352
363
  `IDLEWATCH_LOCAL_LOG_PATH=${localLogPath}`,
353
364
  `IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH=${localCachePath}`
354
365
  ]
@@ -166,6 +166,52 @@ function deriveTokensPerMinute(session) {
166
166
  return Number((totalTokens / minutes).toFixed(2))
167
167
  }
168
168
 
169
+ function pickTokenBreakdown(record, totals = {}) {
170
+ const inputTokens = pickNumber(
171
+ record?.inputTokens,
172
+ record?.input_tokens,
173
+ record?.promptTokens,
174
+ record?.prompt_tokens,
175
+ record?.tokens?.input,
176
+ record?.tokens?.prompt,
177
+ record?.tokenUsage?.input,
178
+ record?.token_usage?.input,
179
+ record?.usage?.inputTokens,
180
+ record?.usage?.input_tokens,
181
+ record?.usage?.tokens?.input,
182
+ record?.usage?.tokens?.prompt,
183
+ totals?.inputTokens,
184
+ totals?.input_tokens,
185
+ totals?.promptTokens,
186
+ totals?.prompt_tokens,
187
+ totals?.tokens?.input,
188
+ totals?.tokens?.prompt
189
+ )
190
+
191
+ const outputTokens = pickNumber(
192
+ record?.outputTokens,
193
+ record?.output_tokens,
194
+ record?.completionTokens,
195
+ record?.completion_tokens,
196
+ record?.tokens?.output,
197
+ record?.tokens?.completion,
198
+ record?.tokenUsage?.output,
199
+ record?.token_usage?.output,
200
+ record?.usage?.outputTokens,
201
+ record?.usage?.output_tokens,
202
+ record?.usage?.tokens?.output,
203
+ record?.usage?.tokens?.completion,
204
+ totals?.outputTokens,
205
+ totals?.output_tokens,
206
+ totals?.completionTokens,
207
+ totals?.completion_tokens,
208
+ totals?.tokens?.output,
209
+ totals?.tokens?.completion
210
+ )
211
+
212
+ return { inputTokens, outputTokens }
213
+ }
214
+
169
215
  function pickNewestSession(sessions = []) {
170
216
  return sessions.reduce((best, candidate) => {
171
217
  if (!best) return candidate
@@ -664,6 +710,7 @@ function parseFromStatusJson(parsed) {
664
710
  session?.usage?.tokenCount,
665
711
  session?.usage?.token_count
666
712
  )
713
+ const { inputTokens, outputTokens } = pickTokenBreakdown(session, session?.usage)
667
714
  const tokensPerMin = pickNumber(
668
715
  session.tokensPerMinute,
669
716
  session.tokens_per_minute,
@@ -768,26 +815,28 @@ function parseFromStatusJson(parsed) {
768
815
  (Number.isFinite(sessionAgeMs) && sessionAgeMs >= 0 ? Date.now() - sessionAgeMs : pickTimestamp(parsed?.ts, parsed?.time, parsed?.updatedAt, parsed?.updated_at, parsed?.updatedAtMs, parsed?.timestamp))
769
816
 
770
817
  const hasStrongUsage = model !== null || totalTokens !== null || tokensPerMin !== null
771
-
818
+ const provider = normalizeProviderName(
819
+ pickString(
820
+ session.provider,
821
+ session.providerName,
822
+ session.provider_name,
823
+ session?.usage?.provider,
824
+ session?.usage?.providerName,
825
+ session?.usage?.provider_name,
826
+ parsed?.provider,
827
+ parsed?.providerName,
828
+ parsed?.provider_name,
829
+ parsed?.status?.provider,
830
+ parsed?.status?.providerName,
831
+ parsed?.status?.provider_name
832
+ )
833
+ ) ?? inferProviderFromModel(model)
772
834
  return {
773
835
  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),
836
+ provider,
790
837
  totalTokens,
838
+ inputTokens,
839
+ outputTokens,
791
840
  tokensPerMin,
792
841
  remainingTokens,
793
842
  percentUsed,
@@ -937,6 +986,7 @@ function parseGenericUsage(parsed) {
937
986
  parsed?.totals?.tokenCount,
938
987
  parsed?.totals?.total_tokens
939
988
  )
989
+ const { inputTokens, outputTokens } = pickTokenBreakdown(usageRecord, usageTotals)
940
990
  const tokensPerMin = pickNumber(
941
991
  usageRecord?.tokensPerMinute,
942
992
  usageRecord?.tokens_per_minute,
@@ -992,29 +1042,31 @@ function parseGenericUsage(parsed) {
992
1042
  )
993
1043
 
994
1044
  if (model === null && totalTokens === null && tokensPerMin === null) return null
995
-
1045
+ const provider = normalizeProviderName(
1046
+ pickString(
1047
+ usageRecord?.provider,
1048
+ usageRecord?.providerName,
1049
+ usageRecord?.provider_name,
1050
+ usageRecord?.usage?.provider,
1051
+ usageRecord?.usage?.providerName,
1052
+ usageRecord?.usage?.provider_name,
1053
+ usageTotals?.provider,
1054
+ usageTotals?.providerName,
1055
+ usageTotals?.provider_name,
1056
+ parsed?.provider,
1057
+ parsed?.providerName,
1058
+ parsed?.provider_name,
1059
+ parsed?.status?.provider,
1060
+ parsed?.status?.providerName,
1061
+ parsed?.status?.provider_name
1062
+ )
1063
+ ) ?? inferProviderFromModel(model)
996
1064
  return {
997
1065
  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),
1066
+ provider,
1017
1067
  totalTokens,
1068
+ inputTokens,
1069
+ outputTokens,
1018
1070
  tokensPerMin,
1019
1071
  remainingTokens,
1020
1072
  percentUsed,
@@ -22,12 +22,17 @@ export function enrichWithOpenClawFleetTelemetry(sample, context = {}) {
22
22
  cpuPct: sample.cpuPct ?? null,
23
23
  memUsedPct: sample.memUsedPct ?? sample.memPct ?? null,
24
24
  memPressurePct: sample.memPressurePct ?? null,
25
- memPressureClass: sample.memPressureClass ?? 'unavailable'
25
+ memPressureClass: sample.memPressureClass ?? 'unavailable',
26
+ tempC: sample.deviceTempC ?? null,
27
+ thermalLevel: sample.thermalLevel ?? null,
28
+ thermalState: sample.thermalState ?? 'unavailable'
26
29
  },
27
30
  usage: {
28
31
  model: sample.openclawModel ?? null,
29
32
  provider: sample.openclawProvider ?? null,
30
33
  totalTokens: sample.openclawTotalTokens ?? null,
34
+ inputTokens: sample.openclawInputTokens ?? null,
35
+ outputTokens: sample.openclawOutputTokens ?? null,
31
36
  remainingTokens: sample.openclawRemainingTokens ?? null,
32
37
  percentUsed: sample.openclawPercentUsed ?? null,
33
38
  contextTokens: sample.openclawContextTokens ?? null,
package/src/thermal.js ADDED
@@ -0,0 +1,113 @@
1
+ import { execSync } from 'child_process'
2
+
3
+ function clampTempC(value) {
4
+ if (!Number.isFinite(value)) return null
5
+ return Number(value.toFixed(1))
6
+ }
7
+
8
+ function parseIstatsTemperature(text) {
9
+ if (!text) return null
10
+ const match = text.match(/(-?\d+(?:\.\d+)?)\s*(?:°\s*[CF]|deg(?:rees)?\s*[CF]?)/i) || text.match(/(-?\d+(?:\.\d+)?)/)
11
+ return match ? clampTempC(Number(match[1])) : null
12
+ }
13
+
14
+ function parseOsxCpuTemp(text) {
15
+ if (!text) return null
16
+ const match = text.match(/(-?\d+(?:\.\d+)?)\s*°?\s*C/i)
17
+ return match ? clampTempC(Number(match[1])) : null
18
+ }
19
+
20
+ function parsePmsetThermal(text) {
21
+ if (!text) return { level: null, state: 'unavailable' }
22
+ const normalized = text.toLowerCase()
23
+
24
+ if (normalized.includes('no thermal warning level has been recorded')) {
25
+ return { level: 0, state: 'nominal' }
26
+ }
27
+
28
+ const match = text.match(/thermal warning level[^0-9]*([0-9]+)/i)
29
+ if (!match) return { level: null, state: 'unavailable' }
30
+
31
+ const level = Number(match[1])
32
+ if (!Number.isFinite(level)) return { level: null, state: 'unavailable' }
33
+
34
+ if (level <= 0) return { level, state: 'nominal' }
35
+ if (level === 1) return { level, state: 'elevated' }
36
+ if (level === 2) return { level, state: 'high' }
37
+ return { level, state: 'critical' }
38
+ }
39
+
40
+ export function thermalSampleDarwin(exec = execSync) {
41
+ const probes = [
42
+ {
43
+ cmd: 'istats cpu temp',
44
+ source: 'istats',
45
+ parse: parseIstatsTemperature,
46
+ timeoutMs: 1200
47
+ },
48
+ {
49
+ cmd: 'osx-cpu-temp',
50
+ source: 'osx-cpu-temp',
51
+ parse: parseOsxCpuTemp,
52
+ timeoutMs: 1200
53
+ }
54
+ ]
55
+
56
+ for (const probe of probes) {
57
+ try {
58
+ const out = exec(probe.cmd, {
59
+ encoding: 'utf8',
60
+ stdio: ['ignore', 'pipe', 'ignore'],
61
+ timeout: probe.timeoutMs
62
+ })
63
+ const tempC = probe.parse(out)
64
+ if (tempC != null) {
65
+ const pressure = parsePmsetThermal(
66
+ exec('pmset -g therm', {
67
+ encoding: 'utf8',
68
+ stdio: ['ignore', 'pipe', 'ignore'],
69
+ timeout: 1000
70
+ })
71
+ )
72
+ return {
73
+ tempC,
74
+ source: probe.source,
75
+ thermalLevel: pressure.level,
76
+ thermalState: pressure.state
77
+ }
78
+ }
79
+ } catch {
80
+ // keep probing
81
+ }
82
+ }
83
+
84
+ try {
85
+ const pressure = parsePmsetThermal(
86
+ exec('pmset -g therm', {
87
+ encoding: 'utf8',
88
+ stdio: ['ignore', 'pipe', 'ignore'],
89
+ timeout: 1000
90
+ })
91
+ )
92
+ return {
93
+ tempC: null,
94
+ source: 'pmset-therm',
95
+ thermalLevel: pressure.level,
96
+ thermalState: pressure.state
97
+ }
98
+ } catch {
99
+ return {
100
+ tempC: null,
101
+ source: 'unavailable',
102
+ thermalLevel: null,
103
+ thermalState: 'unavailable'
104
+ }
105
+ }
106
+ }
107
+
108
+ export const __thermalTestUtils = {
109
+ clampTempC,
110
+ parseIstatsTemperature,
111
+ parseOsxCpuTemp,
112
+ parsePmsetThermal
113
+ }
package/tui/src/main.rs CHANGED
@@ -1,6 +1,6 @@
1
1
  use anyhow::{anyhow, Result};
2
2
  use crossterm::{
3
- event::{self, Event, KeyCode},
3
+ event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
4
4
  execute,
5
5
  terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
6
6
  };
@@ -19,7 +19,9 @@ use std::{
19
19
  #[derive(Clone)]
20
20
  struct MonitorTarget {
21
21
  key: &'static str,
22
+ group: &'static str,
22
23
  label: &'static str,
24
+ description: &'static str,
23
25
  available: bool,
24
26
  selected: bool,
25
27
  }
@@ -136,8 +138,9 @@ fn detect_monitor_targets(existing: &[String]) -> Vec<MonitorTarget> {
136
138
  let gpu_available = cfg!(target_os = "macos") || command_exists("nvidia-smi", &["--help"]);
137
139
  let has_existing = !existing.is_empty();
138
140
  let wants = |key: &str, fallback: bool| {
141
+ let has_legacy_openclaw = existing.iter().any(|item| item == "openclaw");
139
142
  if has_existing {
140
- existing.iter().any(|item| item == key)
143
+ existing.iter().any(|item| item == key) || (has_legacy_openclaw && matches!(key, "agent_activity" | "token_usage" | "runtime_state"))
141
144
  } else {
142
145
  fallback
143
146
  }
@@ -146,27 +149,59 @@ fn detect_monitor_targets(existing: &[String]) -> Vec<MonitorTarget> {
146
149
  vec![
147
150
  MonitorTarget {
148
151
  key: "cpu",
152
+ group: "Compute",
149
153
  label: "CPU usage",
154
+ description: "machine load",
150
155
  available: true,
151
156
  selected: wants("cpu", true),
152
157
  },
153
158
  MonitorTarget {
154
159
  key: "memory",
160
+ group: "Compute",
155
161
  label: "Memory usage",
162
+ description: "resident memory pressure",
156
163
  available: true,
157
164
  selected: wants("memory", true),
158
165
  },
159
166
  MonitorTarget {
160
167
  key: "gpu",
168
+ group: "Compute",
161
169
  label: "GPU usage",
170
+ description: "accelerator load",
162
171
  available: gpu_available,
163
172
  selected: wants("gpu", gpu_available),
164
173
  },
165
174
  MonitorTarget {
166
- key: "openclaw",
167
- label: "OpenClaw token telemetry",
175
+ key: "temperature",
176
+ group: "Compute",
177
+ label: "Temperature",
178
+ description: "cpu temp when available, thermal state otherwise",
179
+ available: cfg!(target_os = "macos"),
180
+ selected: wants("temperature", cfg!(target_os = "macos")),
181
+ },
182
+ MonitorTarget {
183
+ key: "agent_activity",
184
+ group: "OpenClaw Agents",
185
+ label: "Idle / awake time",
186
+ description: "cron run history and idle wedges",
187
+ available: openclaw_available,
188
+ selected: wants("agent_activity", openclaw_available),
189
+ },
190
+ MonitorTarget {
191
+ key: "token_usage",
192
+ group: "OpenClaw Agents",
193
+ label: "Token usage",
194
+ description: "total, input, output, rate",
195
+ available: openclaw_available,
196
+ selected: wants("token_usage", openclaw_available),
197
+ },
198
+ MonitorTarget {
199
+ key: "runtime_state",
200
+ group: "OpenClaw Agents",
201
+ label: "Runtime metadata",
202
+ description: "provider, model, session state",
168
203
  available: openclaw_available,
169
- selected: wants("openclaw", openclaw_available),
204
+ selected: wants("runtime_state", openclaw_available),
170
205
  },
171
206
  ]
172
207
  }
@@ -232,7 +267,7 @@ fn render_mode_menu(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, selec
232
267
  );
233
268
  f.render_widget(path, chunks[2]);
234
269
 
235
- let help = Paragraph::new("↑/↓ move • Enter select • q quit")
270
+ let help = Paragraph::new("↑/↓ move • Enter select • Ctrl+C quit")
236
271
  .style(Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD));
237
272
  f.render_widget(help, chunks[3]);
238
273
  })?;
@@ -250,7 +285,7 @@ fn render_monitor_menu(
250
285
  .margin(1)
251
286
  .constraints([
252
287
  Constraint::Length(4),
253
- Constraint::Length(10),
288
+ Constraint::Length(16),
254
289
  Constraint::Min(1),
255
290
  ])
256
291
  .split(f.area());
@@ -265,15 +300,22 @@ fn render_monitor_menu(
265
300
  );
266
301
  f.render_widget(title, chunks[0]);
267
302
 
268
- let items = monitors
269
- .iter()
270
- .enumerate()
271
- .map(|(idx, target)| {
303
+ let mut items = Vec::new();
304
+ let mut last_group = "";
305
+ for (idx, target) in monitors.iter().enumerate() {
306
+ if target.group != last_group {
307
+ last_group = target.group;
308
+ items.push(
309
+ ListItem::new(format!(" {}", target.group))
310
+ .style(Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD)),
311
+ );
312
+ }
313
+
272
314
  let marker = if target.selected { "[x]" } else { "[ ]" };
273
315
  let unavailable = if target.available { "" } else { " (not detected)" };
274
- let line = format!("{} {}{}", marker, target.label, unavailable);
316
+ let line = format!("{} {}{}{}", marker, target.label, target.description, unavailable);
275
317
 
276
- if idx == cursor {
318
+ let item = if idx == cursor {
277
319
  ListItem::new(format!("❯ {}", line)).style(
278
320
  Style::default()
279
321
  .fg(Color::White)
@@ -284,9 +326,9 @@ fn render_monitor_menu(
284
326
  ListItem::new(format!(" {}", line)).style(Style::default().fg(Color::Cyan))
285
327
  } else {
286
328
  ListItem::new(format!(" {}", line)).style(Style::default().fg(Color::DarkGray))
287
- }
288
- })
289
- .collect::<Vec<_>>();
329
+ };
330
+ items.push(item);
331
+ }
290
332
 
291
333
  let list = List::new(items).block(
292
334
  Block::default()
@@ -356,7 +398,7 @@ fn render_device_name_prompt(
356
398
  );
357
399
  f.render_widget(warning_widget, chunks[2]);
358
400
 
359
- let help = Paragraph::new("Type name • Backspace edit • Enter continue • q quit")
401
+ let help = Paragraph::new("Type name • Backspace edit • Enter continue • Ctrl+C quit")
360
402
  .style(Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD));
361
403
  f.render_widget(help, chunks[3]);
362
404
  })?;
@@ -421,7 +463,7 @@ fn render_api_key_prompt(
421
463
  );
422
464
  f.render_widget(warning_widget, chunks[2]);
423
465
 
424
- let help = Paragraph::new("Paste key • Backspace edit • Enter continue • q quit")
466
+ let help = Paragraph::new("Paste key • Backspace edit • Enter continue • Ctrl+C quit")
425
467
  .style(Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD));
426
468
  f.render_widget(help, chunks[3]);
427
469
  })?;
@@ -466,6 +508,11 @@ fn sanitize_device_id(raw: &str, fallback: &str) -> String {
466
508
  out.trim_matches('-').to_string()
467
509
  }
468
510
 
511
+ fn is_quit_key(key: &KeyEvent) -> bool {
512
+ matches!(key.code, KeyCode::Esc | KeyCode::Char('q'))
513
+ || (matches!(key.code, KeyCode::Char('c')) && key.modifiers.contains(KeyModifiers::CONTROL))
514
+ }
515
+
469
516
  fn main() -> Result<()> {
470
517
  let config_dir = default_config_dir();
471
518
  let env_file = std::env::var("IDLEWATCH_ENROLL_OUTPUT_ENV_FILE")
@@ -489,7 +536,7 @@ fn main() -> Result<()> {
489
536
  KeyCode::Up => selected_mode = selected_mode.saturating_sub(1),
490
537
  KeyCode::Down => selected_mode = (selected_mode + 1).min(1),
491
538
  KeyCode::Enter => break,
492
- KeyCode::Char('q') | KeyCode::Esc => {
539
+ _ if is_quit_key(&key) => {
493
540
  disable_raw_mode()?;
494
541
  execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
495
542
  return Err(anyhow!("setup_cancelled"));
@@ -528,7 +575,7 @@ fn main() -> Result<()> {
528
575
  }
529
576
  break;
530
577
  }
531
- KeyCode::Char('q') | KeyCode::Esc => {
578
+ _ if is_quit_key(&key) => {
532
579
  disable_raw_mode()?;
533
580
  execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
534
581
  return Err(anyhow!("setup_cancelled"));
@@ -570,7 +617,7 @@ fn main() -> Result<()> {
570
617
  device_name_input.pop();
571
618
  device_name_error = None;
572
619
  }
573
- KeyCode::Char('q') | KeyCode::Esc => {
620
+ _ if is_quit_key(&key) => {
574
621
  disable_raw_mode()?;
575
622
  execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
576
623
  return Err(anyhow!("setup_cancelled"));
@@ -617,7 +664,7 @@ fn main() -> Result<()> {
617
664
  cloud_api_key_input.pop();
618
665
  cloud_api_key_error = None;
619
666
  }
620
- KeyCode::Char('q') | KeyCode::Esc => {
667
+ _ if is_quit_key(&key) => {
621
668
  disable_raw_mode()?;
622
669
  execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
623
670
  return Err(anyhow!("setup_cancelled"));
@@ -653,7 +700,9 @@ fn main() -> Result<()> {
653
700
  selected_keys.join(",")
654
701
  };
655
702
 
656
- let monitor_openclaw = monitor_targets_csv.split(',').any(|item| item == "openclaw");
703
+ let monitor_openclaw_usage = monitor_targets_csv
704
+ .split(',')
705
+ .any(|item| matches!(item, "token_usage" | "runtime_state"));
657
706
  let device_name = normalize_device_name(&device_name_input, &host);
658
707
  let safe_device_id = {
659
708
  let candidate = sanitize_device_id(
@@ -668,7 +717,7 @@ fn main() -> Result<()> {
668
717
  format!("IDLEWATCH_DEVICE_NAME={}", device_name),
669
718
  format!("IDLEWATCH_DEVICE_ID={}", safe_device_id),
670
719
  format!("IDLEWATCH_MONITOR_TARGETS={}", monitor_targets_csv),
671
- format!("IDLEWATCH_OPENCLAW_USAGE={}", if monitor_openclaw { "auto" } else { "off" }),
720
+ format!("IDLEWATCH_OPENCLAW_USAGE={}", if monitor_openclaw_usage { "auto" } else { "off" }),
672
721
  format!(
673
722
  "IDLEWATCH_LOCAL_LOG_PATH={}",
674
723
  config_dir.join("logs").join(format!("{}-metrics.ndjson", safe_device_id)).display()