idlewatch 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +12 -11
  2. package/bin/idlewatch-agent.js +404 -19
  3. package/package.json +12 -1
  4. package/scripts/install-macos-launch-agent.sh +1 -0
  5. package/scripts/validate-onboarding.mjs +57 -22
  6. package/src/enrollment.js +48 -7
  7. package/tui/src/main.rs +338 -28
  8. package/.github/workflows/ci.yml +0 -99
  9. package/.github/workflows/release-macos-trusted.yml +0 -103
  10. package/docs/onboarding-external.md +0 -58
  11. package/docs/packaging/macos-dmg.md +0 -199
  12. package/docs/packaging/macos-launch-agent.md +0 -70
  13. package/docs/qa/archive/mac-qa-log-2026-02-17.md +0 -5838
  14. package/docs/qa/mac-qa-log.md +0 -2864
  15. package/docs/telemetry/idle-stale-policy.md +0 -57
  16. package/docs/telemetry/openclaw-mapping.md +0 -80
  17. package/test/config.test.mjs +0 -112
  18. package/test/fixtures/gpu-agx.txt +0 -2
  19. package/test/fixtures/gpu-iogpu.txt +0 -2
  20. package/test/fixtures/gpu-top-grep.txt +0 -2
  21. package/test/fixtures/openclaw-fleet-sample-v1.json +0 -68
  22. package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-iso-ts.txt +0 -2
  23. package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-newest.txt +0 -2
  24. package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-string-ts.txt +0 -2
  25. package/test/fixtures/openclaw-mixed-status-then-generic-output.txt +0 -2
  26. package/test/fixtures/openclaw-stats-current-wrapper.json +0 -12
  27. package/test/fixtures/openclaw-stats-current-wrapper2.json +0 -15
  28. package/test/fixtures/openclaw-stats-data-wrapper.json +0 -21
  29. package/test/fixtures/openclaw-stats-nested-session-wrapper.json +0 -23
  30. package/test/fixtures/openclaw-stats-payload-wrapper.json +0 -1
  31. package/test/fixtures/openclaw-stats-status-current-wrapper.json +0 -19
  32. package/test/fixtures/openclaw-stats.json +0 -17
  33. package/test/fixtures/openclaw-status-ansi-complex-noise.txt +0 -3
  34. package/test/fixtures/openclaw-status-ansi-noise.txt +0 -2
  35. package/test/fixtures/openclaw-status-control-noise.txt +0 -1
  36. package/test/fixtures/openclaw-status-data-wrapper.json +0 -20
  37. package/test/fixtures/openclaw-status-dcs-noise.txt +0 -1
  38. package/test/fixtures/openclaw-status-epoch-seconds.json +0 -15
  39. package/test/fixtures/openclaw-status-mixed-noise.txt +0 -1
  40. package/test/fixtures/openclaw-status-multi-json.txt +0 -3
  41. package/test/fixtures/openclaw-status-nested-recent.json +0 -19
  42. package/test/fixtures/openclaw-status-noisy-default-then-usage.txt +0 -2
  43. package/test/fixtures/openclaw-status-noisy.txt +0 -3
  44. package/test/fixtures/openclaw-status-osc-noise.txt +0 -1
  45. package/test/fixtures/openclaw-status-result-session.json +0 -15
  46. package/test/fixtures/openclaw-status-session-map-with-defaults.json +0 -23
  47. package/test/fixtures/openclaw-status-session-map.json +0 -28
  48. package/test/fixtures/openclaw-status-session-model-name.json +0 -18
  49. package/test/fixtures/openclaw-status-snake-session-wrapper.json +0 -13
  50. package/test/fixtures/openclaw-status-stats-current-sessions-snake-tokens.json +0 -25
  51. package/test/fixtures/openclaw-status-stats-current-sessions.json +0 -28
  52. package/test/fixtures/openclaw-status-stats-current-usage-time-camelcase.json +0 -19
  53. package/test/fixtures/openclaw-status-stats-session-default-model.json +0 -27
  54. package/test/fixtures/openclaw-status-status-wrapper.json +0 -13
  55. package/test/fixtures/openclaw-status-strings.json +0 -38
  56. package/test/fixtures/openclaw-status-ts-ms-alias.json +0 -14
  57. package/test/fixtures/openclaw-status-updated-at-ms-alias.json +0 -14
  58. package/test/fixtures/openclaw-status-usage-timestamp-ms-alias.json +0 -14
  59. package/test/fixtures/openclaw-status-usage-ts-alias.json +0 -14
  60. package/test/fixtures/openclaw-status-wrap-session-object.json +0 -24
  61. package/test/fixtures/openclaw-status.json +0 -41
  62. package/test/fixtures/openclaw-usage-model-name-generic.json +0 -9
  63. package/test/gpu.test.mjs +0 -58
  64. package/test/memory.test.mjs +0 -35
  65. package/test/openclaw-cache.test.mjs +0 -48
  66. package/test/openclaw-env.test.mjs +0 -365
  67. package/test/openclaw-usage.test.mjs +0 -555
  68. package/test/telemetry-mapping.test.mjs +0 -69
  69. package/test/telemetry-row-parser.test.mjs +0 -44
  70. package/test/usage-alert.test.mjs +0 -73
  71. package/test/usage-freshness.test.mjs +0 -63
  72. package/test/validate-dry-run-schema.test.mjs +0 -146
package/README.md CHANGED
@@ -1,20 +1,20 @@
1
- # idlewatch-skill
1
+ # idlewatch
2
2
 
3
3
  Telemetry collector for IdleWatch.
4
4
 
5
5
  ## Install / Run
6
6
 
7
7
  ```bash
8
- npm install
9
- npm start
8
+ npm install -g idlewatch
9
+ idlewatch --help
10
10
  ```
11
11
 
12
- With npx (after publish):
12
+ Or run it directly with npx:
13
13
 
14
14
  ```bash
15
- npx idlewatch-skill --help
16
- npx idlewatch-skill quickstart
17
- npx idlewatch-skill --dry-run
15
+ npx idlewatch --help
16
+ npx idlewatch quickstart
17
+ npx idlewatch --dry-run
18
18
  ```
19
19
 
20
20
  `quickstart` runs a first-run setup wizard that writes a local env file and (for production mode) stores a locked-down copy of the service-account key under `~/.idlewatch/`. On hosts with Rust/Cargo available, quickstart launches a ratatui-powered onboarding flow first; otherwise it falls back to the text wizard.
@@ -54,7 +54,7 @@ Use `gpuSource` + `gpuConfidence` in dashboards to decide whether to trust value
54
54
  ### Recommended: guided enrollment (external users)
55
55
 
56
56
  ```bash
57
- npx idlewatch-skill quickstart
57
+ npx idlewatch quickstart
58
58
  ```
59
59
 
60
60
  The wizard supports:
@@ -62,13 +62,14 @@ The wizard supports:
62
62
  - **Emulator mode**: writes `FIREBASE_PROJECT_ID` + `FIRESTORE_EMULATOR_HOST` only.
63
63
  - **Local-only mode**: writes no Firebase credentials.
64
64
 
65
- Then load generated env and run:
65
+ Then run a one-shot publish check:
66
66
 
67
67
  ```bash
68
- set -a; source "$HOME/.idlewatch/idlewatch.env"; set +a
69
- idlewatch-agent --once
68
+ idlewatch --once
70
69
  ```
71
70
 
71
+ The saved config is auto-loaded from `~/.idlewatch/idlewatch.env`, so you should not need to manually source the env file in normal use.
72
+
72
73
  ### Manual wiring
73
74
 
74
75
  ```bash
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'fs'
3
3
  import { accessSync, constants } from 'node:fs'
4
+ import http from 'node:http'
4
5
  import os from 'os'
5
6
  import path from 'path'
6
7
  import process from 'process'
@@ -17,7 +18,7 @@ import { enrichWithOpenClawFleetTelemetry } from '../src/telemetry-mapping.js'
17
18
  import pkg from '../package.json' with { type: 'json' }
18
19
 
19
20
  function printHelp() {
20
- console.log(`idlewatch-agent\n\nUsage:\n idlewatch-agent [quickstart] [--dry-run] [--once] [--help]\n\nOptions:\n quickstart Run first-run enrollment wizard and generate secure env config\n --dry-run Collect and print one telemetry sample, then exit without Firebase writes\n --once Collect and publish one telemetry sample, then exit\n --help Show this help message\n\nEnvironment:\n IDLEWATCH_HOST Optional custom host label (default: hostname)\n IDLEWATCH_INTERVAL_MS Sampling interval in ms (default: 10000)\n IDLEWATCH_LOCAL_LOG_PATH Optional NDJSON file path for local sample durability\n IDLEWATCH_OPENCLAW_USAGE OpenClaw usage lookup mode: auto|off (default: auto)\n IDLEWATCH_OPENCLAW_PROBE_TIMEOUT_MS OpenClaw command timeout per probe in ms (default: 2500)\n IDLEWATCH_OPENCLAW_PROBE_RETRIES Extra OpenClaw probe sweep retries after first pass (default: 1)\n IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES Max per-command OpenClaw probe output capture in bytes before truncation (default: 2097152 / 2MB)\n IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES_HARD_CAP Hard cap for auto-retry output capture escalation (default: 16777216 / 16MB)\n IDLEWATCH_USAGE_STALE_MS Mark OpenClaw usage stale beyond this age in ms (default: max(interval*3,60000))\n IDLEWATCH_USAGE_NEAR_STALE_MS Mark OpenClaw usage as aging beyond this age in ms (default: floor((stale+grace)*0.85))\n IDLEWATCH_USAGE_STALE_GRACE_MS Extra grace window before status becomes stale (default: min(interval,10000))\n IDLEWATCH_USAGE_REFRESH_REPROBES Forced uncached reprobes when usage crosses stale threshold (default: 1)\n IDLEWATCH_USAGE_REFRESH_DELAY_MS Delay between forced stale-threshold reprobes in ms (default: 250)\n IDLEWATCH_USAGE_REFRESH_ON_NEAR_STALE Trigger refresh when usage is near-stale: 1|0 (default: 1)\n IDLEWATCH_USAGE_IDLE_AFTER_MS Downgrade stale usage alerts to idle notice beyond this age in ms (default: 21600000)\n IDLEWATCH_OPENCLAW_LAST_GOOD_MAX_AGE_MS Reuse last successful usage snapshot after probe failures up to this age in ms\n IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH Persist/reuse last successful usage snapshot across restarts (default: ~/.idlewatch/cache/<host>-openclaw-last-good.json)\n IDLEWATCH_REQUIRE_FIREBASE_WRITES Require Firebase publish path in --once mode: 1|0 (default: 0)\n FIREBASE_PROJECT_ID Firebase project id\n FIREBASE_SERVICE_ACCOUNT_FILE Path to service account JSON file (preferred for production)\n FIREBASE_SERVICE_ACCOUNT_JSON Raw JSON service account (supported, less secure than file path)\n FIREBASE_SERVICE_ACCOUNT_B64 Base64-encoded JSON service account (legacy)\n FIRESTORE_EMULATOR_HOST Optional Firestore emulator host; allows local writes without service-account creds\n IDLEWATCH_CLOUD_INGEST_URL Optional cloud ingest endpoint (e.g. https://idlewatch.com/api/ingest)\n IDLEWATCH_CLOUD_API_KEY Cloud API key from dashboard for device linking\n IDLEWATCH_REQUIRE_CLOUD_WRITES Require cloud publish path in --once mode: 1|0 (default: 0)\n`)
21
+ console.log(`idlewatch\n\nUsage:\n idlewatch [quickstart|configure|dashboard|run] [--dry-run] [--once] [--help]\n\nOptions:\n quickstart Run first-run setup TUI and save local IdleWatch config\n configure Alias for quickstart; reopen setup to change device name, API key, or metrics\n dashboard Launch local dashboard from local IdleWatch logs\n run Start the background collector using saved local config\n --dry-run Collect and print one telemetry sample, then exit without cloud/Firebase writes\n --once Collect and publish one telemetry sample, then exit\n --help Show this help message\n\nEnvironment:\n IDLEWATCH_HOST Optional custom host label (default: hostname)\n IDLEWATCH_INTERVAL_MS Sampling interval in ms (default: 10000)\n IDLEWATCH_LOCAL_LOG_PATH Optional NDJSON file path for local sample durability\n IDLEWATCH_DASHBOARD_PORT Local dashboard HTTP port (default: 4373)\n IDLEWATCH_OPENCLAW_USAGE OpenClaw usage lookup mode: auto|off (default: auto)\n IDLEWATCH_OPENCLAW_PROBE_TIMEOUT_MS OpenClaw command timeout per probe in ms (default: 2500)\n IDLEWATCH_OPENCLAW_PROBE_RETRIES Extra OpenClaw probe sweep retries after first pass (default: 1)\n IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES Max per-command OpenClaw probe output capture in bytes before truncation (default: 2097152 / 2MB)\n IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES_HARD_CAP Hard cap for auto-retry output capture escalation (default: 16777216 / 16MB)\n IDLEWATCH_USAGE_STALE_MS Mark OpenClaw usage stale beyond this age in ms (default: max(interval*3,60000))\n IDLEWATCH_USAGE_NEAR_STALE_MS Mark OpenClaw usage as aging beyond this age in ms (default: floor((stale+grace)*0.85))\n IDLEWATCH_USAGE_STALE_GRACE_MS Extra grace window before status becomes stale (default: min(interval,10000))\n IDLEWATCH_USAGE_REFRESH_REPROBES Forced uncached reprobes when usage crosses stale threshold (default: 1)\n IDLEWATCH_USAGE_REFRESH_DELAY_MS Delay between forced stale-threshold reprobes in ms (default: 250)\n IDLEWATCH_USAGE_REFRESH_ON_NEAR_STALE Trigger refresh when usage is near-stale: 1|0 (default: 1)\n IDLEWATCH_USAGE_IDLE_AFTER_MS Downgrade stale usage alerts to idle notice beyond this age in ms (default: 21600000)\n IDLEWATCH_OPENCLAW_LAST_GOOD_MAX_AGE_MS Reuse last successful usage snapshot after probe failures up to this age in ms\n IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH Persist/reuse last successful usage snapshot across restarts (default: ~/.idlewatch/cache/<host>-openclaw-last-good.json)\n IDLEWATCH_REQUIRE_FIREBASE_WRITES Require Firebase publish path in --once mode: 1|0 (default: 0)\n FIREBASE_PROJECT_ID Firebase project id\n FIREBASE_SERVICE_ACCOUNT_FILE Path to service account JSON file (preferred for production)\n FIREBASE_SERVICE_ACCOUNT_JSON Raw JSON service account (supported, less secure than file path)\n FIREBASE_SERVICE_ACCOUNT_B64 Base64-encoded JSON service account (legacy)\n FIRESTORE_EMULATOR_HOST Optional Firestore emulator host; allows local writes without service-account creds\n IDLEWATCH_CLOUD_INGEST_URL Optional cloud ingest endpoint (e.g. https://idlewatch.com/api/ingest)\n IDLEWATCH_CLOUD_API_KEY Cloud API key from dashboard for device linking\n IDLEWATCH_REQUIRE_CLOUD_WRITES Require cloud publish path in --once mode: 1|0 (default: 0)\n`)
21
22
  }
22
23
 
23
24
  const require = createRequire(import.meta.url)
@@ -37,6 +38,41 @@ function parseEnvFileToObject(envFilePath) {
37
38
  return env
38
39
  }
39
40
 
41
+ function expandSupportedPathVars(value) {
42
+ if (typeof value !== 'string' || !value) return value
43
+
44
+ const home = process.env.HOME || os.homedir()
45
+ const tmpdir = process.env.TMPDIR || os.tmpdir()
46
+
47
+ return value
48
+ .replace(/^~(?=$|\/)/, home)
49
+ .replace(/\$\{HOME\}|\$HOME/g, home)
50
+ .replace(/\$\{TMPDIR\}|\$TMPDIR/g, tmpdir)
51
+ }
52
+
53
+ function resolveEnvPath(value) {
54
+ return path.resolve(expandSupportedPathVars(value))
55
+ }
56
+
57
+ function loadPersistedEnvIntoProcess() {
58
+ const envFile = path.join(os.homedir(), '.idlewatch', 'idlewatch.env')
59
+ if (!fs.existsSync(envFile)) return null
60
+
61
+ try {
62
+ const parsed = parseEnvFileToObject(envFile)
63
+ for (const [key, value] of Object.entries(parsed)) {
64
+ if (process.env[key]) continue
65
+ process.env[key] = key.endsWith('_PATH') ? expandSupportedPathVars(value) : value
66
+ }
67
+ return { envFile, parsed }
68
+ } catch (error) {
69
+ console.error(`Failed to load persisted IdleWatch config from ${envFile}: ${error.message}`)
70
+ return null
71
+ }
72
+ }
73
+
74
+ const persistedEnv = loadPersistedEnvIntoProcess()
75
+
40
76
  function parseMonitorTargets(raw) {
41
77
  const allowed = new Set(['cpu', 'memory', 'gpu', 'openclaw'])
42
78
  const fallback = ['cpu', 'memory', 'openclaw', 'gpu']
@@ -54,18 +90,289 @@ function parseMonitorTargets(raw) {
54
90
  return new Set(parsed)
55
91
  }
56
92
 
93
+ function formatBytes(bytes) {
94
+ if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
95
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
96
+ let value = bytes
97
+ let unit = 0
98
+ while (value >= 1024 && unit < units.length - 1) {
99
+ value /= 1024
100
+ unit += 1
101
+ }
102
+ const fixed = value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)
103
+ return `${fixed} ${units[unit]}`
104
+ }
105
+
106
+ function resolveDashboardLogPath(host) {
107
+ if (process.env.IDLEWATCH_LOCAL_LOG_PATH) {
108
+ return resolveEnvPath(process.env.IDLEWATCH_LOCAL_LOG_PATH)
109
+ }
110
+
111
+ const envFile = path.join(os.homedir(), '.idlewatch', 'idlewatch.env')
112
+ if (fs.existsSync(envFile)) {
113
+ try {
114
+ const parsed = parseEnvFileToObject(envFile)
115
+ if (parsed.IDLEWATCH_LOCAL_LOG_PATH) {
116
+ return resolveEnvPath(parsed.IDLEWATCH_LOCAL_LOG_PATH)
117
+ }
118
+ } catch {
119
+ // ignore malformed env file and fallback
120
+ }
121
+ }
122
+
123
+ const safeHost = host.replace(/[^a-zA-Z0-9_.-]/g, '_')
124
+ return path.join(os.homedir(), '.idlewatch', 'logs', `${safeHost}-metrics.ndjson`)
125
+ }
126
+
127
+ function parseLocalRows(logPath, maxLines = 2500) {
128
+ if (!fs.existsSync(logPath)) return []
129
+
130
+ try {
131
+ const raw = fs.readFileSync(logPath, 'utf8')
132
+ const lines = raw.split(/\r?\n/).filter(Boolean)
133
+ const selected = lines.slice(Math.max(0, lines.length - maxLines))
134
+
135
+ return selected
136
+ .map((line) => {
137
+ try {
138
+ return JSON.parse(line)
139
+ } catch {
140
+ return null
141
+ }
142
+ })
143
+ .filter((item) => item && Number.isFinite(Number(item.ts)))
144
+ .sort((a, b) => Number(a.ts) - Number(b.ts))
145
+ } catch {
146
+ return []
147
+ }
148
+ }
149
+
150
+ function buildTokenDailyEstimate(rows) {
151
+ if (!Array.isArray(rows) || rows.length === 0) return []
152
+
153
+ const totals = new Map()
154
+ const previousBySource = new Map()
155
+
156
+ for (const row of rows) {
157
+ const ts = Number(row.ts || 0)
158
+ const tokensPerMin = Math.max(0, Number(row.tokensPerMin || 0))
159
+ const sourceKey = `${row.hostId || row.host || 'host'}::${row.deviceId || row.device || 'device'}`
160
+ const prevTs = previousBySource.get(sourceKey)
161
+
162
+ if (Number.isFinite(prevTs) && ts > prevTs && tokensPerMin > 0) {
163
+ const deltaMinutes = Math.max(0, Math.min(10, (ts - prevTs) / 60000))
164
+ const estimate = tokensPerMin * deltaMinutes
165
+ const day = new Date(ts)
166
+ const dayKey = `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`
167
+ totals.set(dayKey, (totals.get(dayKey) || 0) + estimate)
168
+ }
169
+
170
+ previousBySource.set(sourceKey, ts)
171
+ }
172
+
173
+ return [...totals.entries()]
174
+ .sort(([a], [b]) => a.localeCompare(b))
175
+ .map(([day, tokens]) => ({ day: day.slice(5), tokens: Math.round(tokens) }))
176
+ }
177
+
178
+ function buildLocalDashboardPayload(logPath) {
179
+ const rows = parseLocalRows(logPath)
180
+ let bytes = 0
181
+ try {
182
+ bytes = fs.statSync(logPath).size
183
+ } catch {
184
+ bytes = 0
185
+ }
186
+
187
+ const series = rows.map((row) => ({
188
+ ts: Number(row.ts || 0),
189
+ cpu: Number(row.cpuPct || 0),
190
+ memory: Number(row.memPct || 0),
191
+ gpu: Number(row.gpuPct || 0),
192
+ tokens: Number(row.tokensPerMin || 0)
193
+ }))
194
+
195
+ return {
196
+ logPath,
197
+ logBytes: bytes,
198
+ logSizeHuman: formatBytes(bytes),
199
+ sampleCount: rows.length,
200
+ latestTs: rows.length ? Number(rows[rows.length - 1].ts || 0) : null,
201
+ series,
202
+ tokenDaily: buildTokenDailyEstimate(rows)
203
+ }
204
+ }
205
+
206
+ function renderLocalDashboardHtml() {
207
+ return `<!doctype html>
208
+ <html>
209
+ <head>
210
+ <meta charset="utf-8" />
211
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
212
+ <title>IdleWatch local dashboard</title>
213
+ <style>
214
+ body { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; margin:0; background:#0a0c1a; color:#eef1ff; }
215
+ .wrap { max-width: 1080px; margin: 30px auto; padding: 0 16px; }
216
+ .grid { display:grid; grid-template-columns: repeat(auto-fit,minmax(220px,1fr)); gap:10px; }
217
+ .card { background:#121633; border:1px solid #2a2f5b; border-radius:12px; padding:12px; }
218
+ .label { color:#9ba5d8; font-size:12px; text-transform:uppercase; letter-spacing:.08em; }
219
+ .value { margin-top:6px; font-size:18px; }
220
+ .chart { margin-top:10px; background:#121633; border:1px solid #2a2f5b; border-radius:12px; padding:12px; }
221
+ canvas { width:100% !important; height:280px !important; }
222
+ code { background:#1a1f42; border:1px solid #2f356e; border-radius:6px; padding:2px 6px; }
223
+ .sub { color:#98a2d6; font-size:13px; }
224
+ a { color:#7ce4ff; }
225
+ </style>
226
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
227
+ </head>
228
+ <body>
229
+ <main class="wrap">
230
+ <h1>IdleWatch local dashboard</h1>
231
+ <p class="sub">Live view from your local NDJSON log file.</p>
232
+ <div class="grid">
233
+ <div class="card"><div class="label">Log path</div><div class="value"><code id="log-path">—</code></div></div>
234
+ <div class="card"><div class="label">Storage</div><div class="value" id="log-size">—</div></div>
235
+ <div class="card"><div class="label">Samples</div><div class="value" id="sample-count">—</div></div>
236
+ <div class="card"><div class="label">Last sample</div><div class="value" id="last-sample">—</div></div>
237
+ </div>
238
+ <div class="chart"><h3>System load (%)</h3><canvas id="system-chart"></canvas></div>
239
+ <div class="chart"><h3>Tokens / min</h3><canvas id="tokens-chart"></canvas></div>
240
+ <div class="chart"><h3>Token / day (estimate)</h3><canvas id="daily-chart"></canvas></div>
241
+ </main>
242
+ <script>
243
+ const fmt = (n) => Number.isFinite(n) ? n.toLocaleString() : '—';
244
+ const fmtTs = (ts) => Number.isFinite(ts) && ts > 0 ? new Date(ts).toLocaleString() : '—';
245
+ let systemChart, tokensChart, dailyChart;
246
+
247
+ function draw(payload) {
248
+ document.getElementById('log-path').textContent = payload.logPath || '—';
249
+ document.getElementById('log-size').textContent = payload.logSizeHuman || '0 B';
250
+ document.getElementById('sample-count').textContent = fmt(payload.sampleCount || 0);
251
+ document.getElementById('last-sample').textContent = fmtTs(payload.latestTs);
252
+
253
+ const labels = (payload.series || []).map((r) => new Date(r.ts).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}));
254
+ const cpu = (payload.series || []).map((r) => r.cpu ?? 0);
255
+ const memory = (payload.series || []).map((r) => r.memory ?? 0);
256
+ const gpu = (payload.series || []).map((r) => r.gpu ?? 0);
257
+ const tokens = (payload.series || []).map((r) => r.tokens ?? 0);
258
+ const dayLabels = (payload.tokenDaily || []).map((r) => r.day);
259
+ const dayTokens = (payload.tokenDaily || []).map((r) => r.tokens);
260
+
261
+ if (systemChart) systemChart.destroy();
262
+ if (tokensChart) tokensChart.destroy();
263
+ if (dailyChart) dailyChart.destroy();
264
+
265
+ systemChart = new Chart(document.getElementById('system-chart'), {
266
+ type: 'line',
267
+ data: {
268
+ labels,
269
+ datasets: [
270
+ { label: 'CPU', data: cpu, borderColor: '#00c853', tension: 0.2, pointRadius: 0 },
271
+ { label: 'Memory', data: memory, borderColor: '#2979ff', tension: 0.2, pointRadius: 0 },
272
+ { label: 'GPU', data: gpu, borderColor: '#a855f7', tension: 0.2, pointRadius: 0 }
273
+ ]
274
+ },
275
+ options: { responsive:true, plugins:{legend:{labels:{color:'#d8defa'}}}, scales:{x:{ticks:{color:'#9aa2d8'}}, y:{min:0,max:100,ticks:{color:'#9aa2d8'}}} }
276
+ });
277
+
278
+ tokensChart = new Chart(document.getElementById('tokens-chart'), {
279
+ type: 'line',
280
+ data: { labels, datasets: [{ label: 'Tokens/min', data: tokens, borderColor: '#ff7a18', tension: 0.2, pointRadius: 0 }] },
281
+ options: { responsive:true, plugins:{legend:{labels:{color:'#d8defa'}}}, scales:{x:{ticks:{color:'#9aa2d8'}}, y:{ticks:{color:'#9aa2d8'}}} }
282
+ });
283
+
284
+ dailyChart = new Chart(document.getElementById('daily-chart'), {
285
+ type: 'bar',
286
+ data: { labels: dayLabels, datasets: [{ label: 'Tokens/day', data: dayTokens, backgroundColor: '#22d3ee' }] },
287
+ options: { responsive:true, plugins:{legend:{labels:{color:'#d8defa'}}}, scales:{x:{ticks:{color:'#9aa2d8'}}, y:{ticks:{color:'#9aa2d8'}}} }
288
+ });
289
+ }
290
+
291
+ async function refresh() {
292
+ const response = await fetch('/api/local-status', { cache: 'no-store' });
293
+ const payload = await response.json();
294
+ draw(payload);
295
+ }
296
+
297
+ refresh();
298
+ setInterval(refresh, 10000);
299
+ </script>
300
+ </body>
301
+ </html>`
302
+ }
303
+
304
+ function openUrl(url) {
305
+ try {
306
+ if (process.platform === 'darwin') {
307
+ spawnSync('open', [url], { stdio: 'ignore' })
308
+ } else if (process.platform === 'win32') {
309
+ spawnSync('cmd', ['/c', 'start', '', url], { stdio: 'ignore' })
310
+ } else {
311
+ spawnSync('xdg-open', [url], { stdio: 'ignore' })
312
+ }
313
+ } catch {
314
+ // no-op
315
+ }
316
+ }
317
+
318
+ function runLocalDashboard({ host }) {
319
+ const logPath = resolveDashboardLogPath(host)
320
+ const portRaw = Number(process.env.IDLEWATCH_DASHBOARD_PORT || 4373)
321
+ const port = Number.isFinite(portRaw) && portRaw > 0 ? portRaw : 4373
322
+
323
+ const server = http.createServer((req, res) => {
324
+ if (req.url === '/api/local-status') {
325
+ const payload = buildLocalDashboardPayload(logPath)
326
+ res.writeHead(200, { 'content-type': 'application/json' })
327
+ res.end(JSON.stringify(payload))
328
+ return
329
+ }
330
+
331
+ if (req.url === '/health') {
332
+ const payload = buildLocalDashboardPayload(logPath)
333
+ res.writeHead(200, { 'content-type': 'application/json' })
334
+ res.end(JSON.stringify({ ok: true, logPath: payload.logPath, samples: payload.sampleCount }))
335
+ return
336
+ }
337
+
338
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
339
+ res.end(renderLocalDashboardHtml())
340
+ })
341
+
342
+ server.listen(port, '127.0.0.1', () => {
343
+ const url = `http://127.0.0.1:${port}`
344
+ const payload = buildLocalDashboardPayload(logPath)
345
+ console.log(`idlewatch local dashboard ready: ${url}`)
346
+ console.log(`log file: ${payload.logPath} (${payload.logSizeHuman})`)
347
+ openUrl(url)
348
+ })
349
+
350
+ server.on('error', (err) => {
351
+ console.error(`Local dashboard failed: ${err.message}`)
352
+ process.exit(1)
353
+ })
354
+ }
355
+
57
356
  const argv = process.argv.slice(2)
58
- const quickstartRequested = argv[0] === 'quickstart' || argv.includes('--quickstart')
59
357
  const args = new Set(argv)
358
+ const dashboardRequested = argv[0] === 'dashboard' || argv.includes('--dashboard')
359
+ const runRequested = argv[0] === 'run' || argv.includes('--run')
360
+ const interactiveDefaultRequested = argv.length === 0 && process.stdin.isTTY && process.stdout.isTTY
361
+ const quickstartRequested = argv[0] === 'quickstart' || argv[0] === 'configure' || argv.includes('--quickstart') || argv.includes('--configure') || (interactiveDefaultRequested && !dashboardRequested && !runRequested)
60
362
  if (args.has('--help') || args.has('-h')) {
61
363
  printHelp()
62
364
  process.exit(0)
63
365
  }
64
366
 
367
+ if (dashboardRequested) {
368
+ const host = process.env.IDLEWATCH_HOST || os.hostname()
369
+ runLocalDashboard({ host })
370
+ await new Promise(() => {})
371
+ }
372
+
65
373
  if (quickstartRequested) {
66
374
  try {
67
375
  const result = await runEnrollmentWizard()
68
- console.log(`Enrollment complete. Mode=${result.mode} envFile=${result.outputEnvFile}`)
69
376
 
70
377
  const enrolledEnv = parseEnvFileToObject(result.outputEnvFile)
71
378
  const onceRun = spawnSync(process.execPath, [process.argv[1], '--once'], {
@@ -77,12 +384,15 @@ if (quickstartRequested) {
77
384
  })
78
385
 
79
386
  if (onceRun.status === 0) {
80
- console.log('✅ Initial telemetry sample sent successfully.')
387
+ console.log(`✅ Setup complete. Mode=${result.mode} device=${result.deviceName} envFile=${result.outputEnvFile}`)
388
+ console.log('Initial telemetry sample sent successfully.')
81
389
  process.exit(0)
82
390
  }
83
391
 
84
- console.log('⚠️ Initial --once sample did not complete successfully.')
85
- console.log(`You can retry with: set -a; source "${result.outputEnvFile}"; set +a && idlewatch-agent --once`)
392
+ console.error(`⚠️ Setup is not finished yet. Mode=${result.mode} device=${result.deviceName} envFile=${result.outputEnvFile}`)
393
+ console.error('The first required telemetry sample did not publish successfully, so this device may not be linked yet.')
394
+ console.error(`Retry with: idlewatch --once`)
395
+ console.error('Or rerun: idlewatch quickstart')
86
396
  process.exit(onceRun.status ?? 1)
87
397
  } catch (err) {
88
398
  console.error(`Enrollment failed: ${err.message}`)
@@ -92,8 +402,14 @@ if (quickstartRequested) {
92
402
 
93
403
  const DRY_RUN = args.has('--dry-run')
94
404
  const ONCE = args.has('--once')
405
+ const DEVICE_NAME = (process.env.IDLEWATCH_DEVICE_NAME || process.env.IDLEWATCH_HOST || os.hostname()).trim()
406
+ const DEVICE_ID = (process.env.IDLEWATCH_DEVICE_ID || DEVICE_NAME)
407
+ .trim()
408
+ .toLowerCase()
409
+ .replace(/[^a-z0-9_.-]+/g, '-')
410
+ .replace(/^-+|-+$/g, '') || 'device'
95
411
  const HOST = process.env.IDLEWATCH_HOST || os.hostname()
96
- const SAFE_HOST = HOST.replace(/[^a-zA-Z0-9_.-]/g, '_')
412
+ const SAFE_HOST = DEVICE_ID.replace(/[^a-zA-Z0-9_.-]/g, '_')
97
413
  const INTERVAL_MS = Number(process.env.IDLEWATCH_INTERVAL_MS || 10000)
98
414
  const PROJECT_ID = process.env.FIREBASE_PROJECT_ID
99
415
  const CREDS_FILE = process.env.FIREBASE_SERVICE_ACCOUNT_FILE
@@ -108,9 +424,12 @@ const MONITOR_GPU = MONITOR_TARGETS.has('gpu')
108
424
  const MONITOR_OPENCLAW = MONITOR_TARGETS.has('openclaw')
109
425
  const EFFECTIVE_OPENCLAW_MODE = MONITOR_OPENCLAW ? OPENCLAW_USAGE_MODE : 'off'
110
426
  const REQUIRE_FIREBASE_WRITES = process.env.IDLEWATCH_REQUIRE_FIREBASE_WRITES === '1'
111
- const CLOUD_INGEST_URL = process.env.IDLEWATCH_CLOUD_INGEST_URL
112
- const CLOUD_API_KEY = process.env.IDLEWATCH_CLOUD_API_KEY
427
+ const CLOUD_INGEST_URL = (process.env.IDLEWATCH_CLOUD_INGEST_URL || '').trim()
428
+ const CLOUD_API_KEY = (process.env.IDLEWATCH_CLOUD_API_KEY || '').trim().replace(/^['"]|['"]$/g, '')
113
429
  const REQUIRE_CLOUD_WRITES = process.env.IDLEWATCH_REQUIRE_CLOUD_WRITES === '1'
430
+ let cloudIngestKickedOut = false
431
+ let cloudIngestKickoutReason = null
432
+ let cloudIngestKickoutNotified = false
114
433
  const OPENCLAW_PROBE_TIMEOUT_MS = Number(process.env.IDLEWATCH_OPENCLAW_PROBE_TIMEOUT_MS || 2500)
115
434
  const OPENCLAW_PROBE_MAX_OUTPUT_BYTES = process.env.IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES
116
435
  ? Number(process.env.IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES)
@@ -125,7 +444,7 @@ const OPENCLAW_PROBE_RETRIES = process.env.IDLEWATCH_OPENCLAW_PROBE_RETRIES
125
444
  const BASE_DIR = path.join(os.homedir(), '.idlewatch')
126
445
 
127
446
  const LOCAL_LOG_PATH = process.env.IDLEWATCH_LOCAL_LOG_PATH
128
- ? path.resolve(process.env.IDLEWATCH_LOCAL_LOG_PATH)
447
+ ? resolveEnvPath(process.env.IDLEWATCH_LOCAL_LOG_PATH)
129
448
  : path.join(BASE_DIR, 'logs', `${SAFE_HOST}-metrics.ndjson`)
130
449
 
131
450
  if (!Number.isFinite(INTERVAL_MS) || INTERVAL_MS <= 0) {
@@ -248,7 +567,7 @@ if (process.env.IDLEWATCH_OPENCLAW_LAST_GOOD_MAX_AGE_MS && (!Number.isFinite(OPE
248
567
  }
249
568
 
250
569
  const OPENCLAW_LAST_GOOD_CACHE_PATH = process.env.IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH
251
- ? path.resolve(process.env.IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH)
570
+ ? resolveEnvPath(process.env.IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH)
252
571
  : path.join(BASE_DIR, 'cache', `${SAFE_HOST}-openclaw-last-good.json`)
253
572
 
254
573
  let appReady = false
@@ -305,9 +624,9 @@ if (firebaseConfigError) {
305
624
  process.exit(1)
306
625
  }
307
626
 
308
- if (!appReady) {
627
+ if (!appReady && !(CLOUD_INGEST_URL && CLOUD_API_KEY)) {
309
628
  console.error(
310
- 'Firebase is not configured. Running in local-only mode (stdout logging only). Set FIREBASE_PROJECT_ID + FIREBASE_SERVICE_ACCOUNT_FILE (preferred, or FIREBASE_SERVICE_ACCOUNT_JSON / FIREBASE_SERVICE_ACCOUNT_B64), or use FIREBASE_PROJECT_ID + FIRESTORE_EMULATOR_HOST for emulator writes.'
629
+ 'Firebase is not configured. Running without Firebase writes. Set FIREBASE_PROJECT_ID + FIREBASE_SERVICE_ACCOUNT_FILE (preferred, or FIREBASE_SERVICE_ACCOUNT_JSON / FIREBASE_SERVICE_ACCOUNT_B64), use FIREBASE_PROJECT_ID + FIRESTORE_EMULATOR_HOST for emulator writes, or configure cloud ingest via idlewatch setup.'
311
630
  )
312
631
  }
313
632
 
@@ -322,6 +641,21 @@ function ensureDirFor(filePath) {
322
641
  fs.mkdirSync(path.dirname(filePath), { recursive: true })
323
642
  }
324
643
 
644
+ function getLocalLogUsage() {
645
+ try {
646
+ const stat = fs.statSync(LOCAL_LOG_PATH)
647
+ return {
648
+ path: LOCAL_LOG_PATH,
649
+ bytes: Number(stat.size || 0)
650
+ }
651
+ } catch {
652
+ return {
653
+ path: LOCAL_LOG_PATH,
654
+ bytes: 0
655
+ }
656
+ }
657
+ }
658
+
325
659
  function appendLocal(row) {
326
660
  try {
327
661
  ensureDirFor(LOCAL_LOG_PATH)
@@ -329,6 +663,7 @@ function appendLocal(row) {
329
663
  } catch (err) {
330
664
  console.error(`Local log append failed (${LOCAL_LOG_PATH}): ${err.message}`)
331
665
  }
666
+ return getLocalLogUsage()
332
667
  }
333
668
 
334
669
  function snapshotCpuTimes() {
@@ -789,6 +1124,8 @@ async function publish(row, retries = 2) {
789
1124
  if (DRY_RUN) return false
790
1125
 
791
1126
  if (CLOUD_INGEST_URL && CLOUD_API_KEY) {
1127
+ if (cloudIngestKickedOut) return false
1128
+
792
1129
  let attempt = 0
793
1130
  while (attempt <= retries) {
794
1131
  try {
@@ -800,11 +1137,31 @@ async function publish(row, retries = 2) {
800
1137
  },
801
1138
  body: JSON.stringify(row)
802
1139
  })
1140
+
803
1141
  if (!response.ok) {
1142
+ let detail = null
1143
+ try {
1144
+ const payload = await response.json()
1145
+ detail = payload?.detail || payload?.error || payload?.message || null
1146
+ } catch {
1147
+ try {
1148
+ detail = (await response.text())?.slice(0, 180) || null
1149
+ } catch {
1150
+ detail = null
1151
+ }
1152
+ }
1153
+
1154
+ if (response.status === 401 || response.status === 403) {
1155
+ cloudIngestKickedOut = true
1156
+ cloudIngestKickoutReason = detail || `http_${response.status}`
1157
+ return false
1158
+ }
1159
+
804
1160
  throw new Error(`cloud_ingest_failed_${response.status}`)
805
1161
  }
806
1162
  return true
807
1163
  } catch (err) {
1164
+ if (cloudIngestKickedOut) throw err
808
1165
  if (attempt >= retries) throw err
809
1166
  await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1)))
810
1167
  attempt += 1
@@ -951,7 +1308,11 @@ async function collectSample() {
951
1308
  usageStaleMsThreshold: USAGE_STALE_MS,
952
1309
  usageNearStaleMsThreshold: USAGE_NEAR_STALE_MS,
953
1310
  usageStaleGraceMs: USAGE_STALE_GRACE_MS,
954
- memPressureSource: memPressure.source
1311
+ memPressureSource: memPressure.source,
1312
+ cloudIngestionStatus: CLOUD_INGEST_URL && CLOUD_API_KEY
1313
+ ? cloudIngestKickedOut ? 'kicked-out' : 'enabled'
1314
+ : 'disabled',
1315
+ cloudIngestionReason: cloudIngestKickoutReason
955
1316
  }
956
1317
 
957
1318
  const usageAlert = deriveUsageAlert(source, { usageAgeMs: usageFreshness.usageAgeMs, idleAfterMs: USAGE_IDLE_AFTER_MS })
@@ -960,6 +1321,10 @@ async function collectSample() {
960
1321
 
961
1322
  const row = {
962
1323
  host: HOST,
1324
+ hostId: HOST,
1325
+ hostName: HOST,
1326
+ deviceId: DEVICE_ID,
1327
+ deviceName: DEVICE_NAME,
963
1328
  ts: sampleAtMs,
964
1329
  cpuPct: MONITOR_CPU ? cpuPct() : null,
965
1330
  memPct: MONITOR_MEMORY ? usedMemPct : null,
@@ -977,6 +1342,8 @@ async function collectSample() {
977
1342
  openclawAgentId: MONITOR_OPENCLAW ? (usage?.agentId ?? null) : null,
978
1343
  openclawUsageTs: MONITOR_OPENCLAW ? (usage?.usageTimestampMs ?? null) : null,
979
1344
  openclawUsageAgeMs: MONITOR_OPENCLAW ? usageFreshness.usageAgeMs : null,
1345
+ localLogPath: LOCAL_LOG_PATH,
1346
+ localLogBytes: null,
980
1347
  source
981
1348
  }
982
1349
 
@@ -990,13 +1357,31 @@ async function collectSample() {
990
1357
 
991
1358
  async function tick() {
992
1359
  const row = await collectSample()
1360
+ const localUsage = appendLocal(row)
1361
+ row.localLogPath = localUsage.path
1362
+ row.localLogBytes = localUsage.bytes
1363
+
993
1364
  console.log(JSON.stringify(row))
994
- appendLocal(row)
1365
+
995
1366
  const published = await publish(row)
1367
+
1368
+ if (cloudIngestKickedOut && !cloudIngestKickoutNotified) {
1369
+ cloudIngestKickoutNotified = true
1370
+ console.error(
1371
+ `Cloud ingest disabled: API key rejected (${cloudIngestKickoutReason || 'unauthorized'}). Run idlewatch quickstart to link a new key.`
1372
+ )
1373
+ }
1374
+
996
1375
  if (REQUIRE_FIREBASE_WRITES && ONCE && !published) {
997
1376
  throw new Error('Firebase write was required but not executed. Check Firebase configuration and connectivity.')
998
1377
  }
1378
+
999
1379
  if (REQUIRE_CLOUD_WRITES && ONCE && !published) {
1380
+ if (cloudIngestKickedOut) {
1381
+ throw new Error(
1382
+ `Cloud API key was rejected (${cloudIngestKickoutReason || 'unauthorized'}). This device was disconnected. Run idlewatch quickstart with a new API key.`
1383
+ )
1384
+ }
1000
1385
  throw new Error('Cloud write was required but not executed. Check API key and cloud connectivity.')
1001
1386
  }
1002
1387
  }
@@ -1024,10 +1409,10 @@ async function gracefulShutdown(signal) {
1024
1409
  if (stopped) return
1025
1410
  stopped = true
1026
1411
  if (inflightTick) {
1027
- console.log(`idlewatch-agent received ${signal}, waiting for in-flight sample…`)
1412
+ console.log(`idlewatch received ${signal}, waiting for in-flight sample…`)
1028
1413
  try { await inflightTick } catch { /* already logged */ }
1029
1414
  }
1030
- console.log('idlewatch-agent stopped')
1415
+ console.log('idlewatch stopped')
1031
1416
  process.exit(0)
1032
1417
  }
1033
1418
 
@@ -1037,7 +1422,7 @@ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
1037
1422
  if (DRY_RUN || ONCE) {
1038
1423
  const mode = DRY_RUN ? 'dry-run' : 'once'
1039
1424
  console.log(
1040
- `idlewatch-agent ${mode} host=${HOST} intervalMs=${INTERVAL_MS} firebase=${appReady} localLog=${LOCAL_LOG_PATH}`
1425
+ `idlewatch ${mode} host=${HOST} device=${DEVICE_NAME} deviceId=${DEVICE_ID} intervalMs=${INTERVAL_MS} firebase=${appReady} localLog=${LOCAL_LOG_PATH} env=${persistedEnv?.envFile || 'process'}`
1041
1426
  )
1042
1427
  tick()
1043
1428
  .then(() => process.exit(0))
@@ -1047,7 +1432,7 @@ if (DRY_RUN || ONCE) {
1047
1432
  })
1048
1433
  } else {
1049
1434
  console.log(
1050
- `idlewatch-agent started host=${HOST} intervalMs=${INTERVAL_MS} firebase=${appReady} localLog=${LOCAL_LOG_PATH} monitorTargets=${[...MONITOR_TARGETS].join(',')} openclawUsage=${EFFECTIVE_OPENCLAW_MODE}`
1435
+ `idlewatch started host=${HOST} device=${DEVICE_NAME} deviceId=${DEVICE_ID} intervalMs=${INTERVAL_MS} firebase=${appReady} localLog=${LOCAL_LOG_PATH} monitorTargets=${[...MONITOR_TARGETS].join(',')} openclawUsage=${EFFECTIVE_OPENCLAW_MODE} env=${persistedEnv?.envFile || 'process'}`
1051
1436
  )
1052
1437
  loop()
1053
1438
  }
package/package.json CHANGED
@@ -1,8 +1,19 @@
1
1
  {
2
2
  "name": "idlewatch",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Host telemetry collector for IdleWatch",
5
5
  "type": "module",
6
+ "files": [
7
+ "bin",
8
+ "src",
9
+ "scripts",
10
+ "skill",
11
+ "tui/src",
12
+ "tui/Cargo.toml",
13
+ "tui/Cargo.lock",
14
+ "README.md",
15
+ ".env.example"
16
+ ],
6
17
  "bin": {
7
18
  "idlewatch": "bin/idlewatch-agent.js",
8
19
  "idlewatch-agent": "bin/idlewatch-agent.js",