idlewatch 0.1.0 → 0.1.1

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.
@@ -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-agent\n\nUsage:\n idlewatch-agent [quickstart|dashboard] [--dry-run] [--once] [--help]\n\nOptions:\n quickstart Run first-run enrollment wizard and generate secure env config\n dashboard Launch local dashboard from local IdleWatch logs\n --dry-run Collect and print one telemetry sample, then exit without Firebase writes\n --once Collect and publish one telemetry sample, then exit\n --help Show this help message\n\nEnvironment:\n IDLEWATCH_HOST Optional custom host label (default: hostname)\n IDLEWATCH_INTERVAL_MS Sampling interval in ms (default: 10000)\n IDLEWATCH_LOCAL_LOG_PATH Optional NDJSON file path for local sample durability\n IDLEWATCH_DASHBOARD_PORT Local dashboard HTTP port (default: 4373)\n IDLEWATCH_OPENCLAW_USAGE OpenClaw usage lookup mode: auto|off (default: auto)\n IDLEWATCH_OPENCLAW_PROBE_TIMEOUT_MS OpenClaw command timeout per probe in ms (default: 2500)\n IDLEWATCH_OPENCLAW_PROBE_RETRIES Extra OpenClaw probe sweep retries after first pass (default: 1)\n IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES Max per-command OpenClaw probe output capture in bytes before truncation (default: 2097152 / 2MB)\n IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES_HARD_CAP Hard cap for auto-retry output capture escalation (default: 16777216 / 16MB)\n IDLEWATCH_USAGE_STALE_MS Mark OpenClaw usage stale beyond this age in ms (default: max(interval*3,60000))\n IDLEWATCH_USAGE_NEAR_STALE_MS Mark OpenClaw usage as aging beyond this age in ms (default: floor((stale+grace)*0.85))\n IDLEWATCH_USAGE_STALE_GRACE_MS Extra grace window before status becomes stale (default: min(interval,10000))\n IDLEWATCH_USAGE_REFRESH_REPROBES Forced uncached reprobes when usage crosses stale threshold (default: 1)\n IDLEWATCH_USAGE_REFRESH_DELAY_MS Delay between forced stale-threshold reprobes in ms (default: 250)\n IDLEWATCH_USAGE_REFRESH_ON_NEAR_STALE Trigger refresh when usage is near-stale: 1|0 (default: 1)\n IDLEWATCH_USAGE_IDLE_AFTER_MS Downgrade stale usage alerts to idle notice beyond this age in ms (default: 21600000)\n IDLEWATCH_OPENCLAW_LAST_GOOD_MAX_AGE_MS Reuse last successful usage snapshot after probe failures up to this age in ms\n IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH Persist/reuse last successful usage snapshot across restarts (default: ~/.idlewatch/cache/<host>-openclaw-last-good.json)\n IDLEWATCH_REQUIRE_FIREBASE_WRITES Require Firebase publish path in --once mode: 1|0 (default: 0)\n FIREBASE_PROJECT_ID Firebase project id\n FIREBASE_SERVICE_ACCOUNT_FILE Path to service account JSON file (preferred for production)\n FIREBASE_SERVICE_ACCOUNT_JSON Raw JSON service account (supported, less secure than file path)\n FIREBASE_SERVICE_ACCOUNT_B64 Base64-encoded JSON service account (legacy)\n FIRESTORE_EMULATOR_HOST Optional Firestore emulator host; allows local writes without service-account creds\n IDLEWATCH_CLOUD_INGEST_URL Optional cloud ingest endpoint (e.g. https://idlewatch.com/api/ingest)\n IDLEWATCH_CLOUD_API_KEY Cloud API key from dashboard for device linking\n IDLEWATCH_REQUIRE_CLOUD_WRITES Require cloud publish path in --once mode: 1|0 (default: 0)\n`)
21
22
  }
22
23
 
23
24
  const require = createRequire(import.meta.url)
@@ -54,14 +55,284 @@ function parseMonitorTargets(raw) {
54
55
  return new Set(parsed)
55
56
  }
56
57
 
58
+ function formatBytes(bytes) {
59
+ if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
60
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
61
+ let value = bytes
62
+ let unit = 0
63
+ while (value >= 1024 && unit < units.length - 1) {
64
+ value /= 1024
65
+ unit += 1
66
+ }
67
+ const fixed = value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)
68
+ return `${fixed} ${units[unit]}`
69
+ }
70
+
71
+ function resolveDashboardLogPath(host) {
72
+ if (process.env.IDLEWATCH_LOCAL_LOG_PATH) {
73
+ return path.resolve(process.env.IDLEWATCH_LOCAL_LOG_PATH)
74
+ }
75
+
76
+ const envFile = path.join(os.homedir(), '.idlewatch', 'idlewatch.env')
77
+ if (fs.existsSync(envFile)) {
78
+ try {
79
+ const parsed = parseEnvFileToObject(envFile)
80
+ if (parsed.IDLEWATCH_LOCAL_LOG_PATH) {
81
+ return path.resolve(parsed.IDLEWATCH_LOCAL_LOG_PATH)
82
+ }
83
+ } catch {
84
+ // ignore malformed env file and fallback
85
+ }
86
+ }
87
+
88
+ const safeHost = host.replace(/[^a-zA-Z0-9_.-]/g, '_')
89
+ return path.join(os.homedir(), '.idlewatch', 'logs', `${safeHost}-metrics.ndjson`)
90
+ }
91
+
92
+ function parseLocalRows(logPath, maxLines = 2500) {
93
+ if (!fs.existsSync(logPath)) return []
94
+
95
+ try {
96
+ const raw = fs.readFileSync(logPath, 'utf8')
97
+ const lines = raw.split(/\r?\n/).filter(Boolean)
98
+ const selected = lines.slice(Math.max(0, lines.length - maxLines))
99
+
100
+ return selected
101
+ .map((line) => {
102
+ try {
103
+ return JSON.parse(line)
104
+ } catch {
105
+ return null
106
+ }
107
+ })
108
+ .filter((item) => item && Number.isFinite(Number(item.ts)))
109
+ .sort((a, b) => Number(a.ts) - Number(b.ts))
110
+ } catch {
111
+ return []
112
+ }
113
+ }
114
+
115
+ function buildTokenDailyEstimate(rows) {
116
+ if (!Array.isArray(rows) || rows.length === 0) return []
117
+
118
+ const totals = new Map()
119
+ const previousBySource = new Map()
120
+
121
+ for (const row of rows) {
122
+ const ts = Number(row.ts || 0)
123
+ const tokensPerMin = Math.max(0, Number(row.tokensPerMin || 0))
124
+ const sourceKey = `${row.hostId || row.host || 'host'}::${row.deviceId || row.device || 'device'}`
125
+ const prevTs = previousBySource.get(sourceKey)
126
+
127
+ if (Number.isFinite(prevTs) && ts > prevTs && tokensPerMin > 0) {
128
+ const deltaMinutes = Math.max(0, Math.min(10, (ts - prevTs) / 60000))
129
+ const estimate = tokensPerMin * deltaMinutes
130
+ const day = new Date(ts)
131
+ const dayKey = `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`
132
+ totals.set(dayKey, (totals.get(dayKey) || 0) + estimate)
133
+ }
134
+
135
+ previousBySource.set(sourceKey, ts)
136
+ }
137
+
138
+ return [...totals.entries()]
139
+ .sort(([a], [b]) => a.localeCompare(b))
140
+ .map(([day, tokens]) => ({ day: day.slice(5), tokens: Math.round(tokens) }))
141
+ }
142
+
143
+ function buildLocalDashboardPayload(logPath) {
144
+ const rows = parseLocalRows(logPath)
145
+ let bytes = 0
146
+ try {
147
+ bytes = fs.statSync(logPath).size
148
+ } catch {
149
+ bytes = 0
150
+ }
151
+
152
+ const series = rows.map((row) => ({
153
+ ts: Number(row.ts || 0),
154
+ cpu: Number(row.cpuPct || 0),
155
+ memory: Number(row.memPct || 0),
156
+ gpu: Number(row.gpuPct || 0),
157
+ tokens: Number(row.tokensPerMin || 0)
158
+ }))
159
+
160
+ return {
161
+ logPath,
162
+ logBytes: bytes,
163
+ logSizeHuman: formatBytes(bytes),
164
+ sampleCount: rows.length,
165
+ latestTs: rows.length ? Number(rows[rows.length - 1].ts || 0) : null,
166
+ series,
167
+ tokenDaily: buildTokenDailyEstimate(rows)
168
+ }
169
+ }
170
+
171
+ function renderLocalDashboardHtml() {
172
+ return `<!doctype html>
173
+ <html>
174
+ <head>
175
+ <meta charset="utf-8" />
176
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
177
+ <title>IdleWatch local dashboard</title>
178
+ <style>
179
+ body { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; margin:0; background:#0a0c1a; color:#eef1ff; }
180
+ .wrap { max-width: 1080px; margin: 30px auto; padding: 0 16px; }
181
+ .grid { display:grid; grid-template-columns: repeat(auto-fit,minmax(220px,1fr)); gap:10px; }
182
+ .card { background:#121633; border:1px solid #2a2f5b; border-radius:12px; padding:12px; }
183
+ .label { color:#9ba5d8; font-size:12px; text-transform:uppercase; letter-spacing:.08em; }
184
+ .value { margin-top:6px; font-size:18px; }
185
+ .chart { margin-top:10px; background:#121633; border:1px solid #2a2f5b; border-radius:12px; padding:12px; }
186
+ canvas { width:100% !important; height:280px !important; }
187
+ code { background:#1a1f42; border:1px solid #2f356e; border-radius:6px; padding:2px 6px; }
188
+ .sub { color:#98a2d6; font-size:13px; }
189
+ a { color:#7ce4ff; }
190
+ </style>
191
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
192
+ </head>
193
+ <body>
194
+ <main class="wrap">
195
+ <h1>IdleWatch local dashboard</h1>
196
+ <p class="sub">Live view from your local NDJSON log file.</p>
197
+ <div class="grid">
198
+ <div class="card"><div class="label">Log path</div><div class="value"><code id="log-path">—</code></div></div>
199
+ <div class="card"><div class="label">Storage</div><div class="value" id="log-size">—</div></div>
200
+ <div class="card"><div class="label">Samples</div><div class="value" id="sample-count">—</div></div>
201
+ <div class="card"><div class="label">Last sample</div><div class="value" id="last-sample">—</div></div>
202
+ </div>
203
+ <div class="chart"><h3>System load (%)</h3><canvas id="system-chart"></canvas></div>
204
+ <div class="chart"><h3>Tokens / min</h3><canvas id="tokens-chart"></canvas></div>
205
+ <div class="chart"><h3>Token / day (estimate)</h3><canvas id="daily-chart"></canvas></div>
206
+ </main>
207
+ <script>
208
+ const fmt = (n) => Number.isFinite(n) ? n.toLocaleString() : '—';
209
+ const fmtTs = (ts) => Number.isFinite(ts) && ts > 0 ? new Date(ts).toLocaleString() : '—';
210
+ let systemChart, tokensChart, dailyChart;
211
+
212
+ function draw(payload) {
213
+ document.getElementById('log-path').textContent = payload.logPath || '—';
214
+ document.getElementById('log-size').textContent = payload.logSizeHuman || '0 B';
215
+ document.getElementById('sample-count').textContent = fmt(payload.sampleCount || 0);
216
+ document.getElementById('last-sample').textContent = fmtTs(payload.latestTs);
217
+
218
+ const labels = (payload.series || []).map((r) => new Date(r.ts).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}));
219
+ const cpu = (payload.series || []).map((r) => r.cpu ?? 0);
220
+ const memory = (payload.series || []).map((r) => r.memory ?? 0);
221
+ const gpu = (payload.series || []).map((r) => r.gpu ?? 0);
222
+ const tokens = (payload.series || []).map((r) => r.tokens ?? 0);
223
+ const dayLabels = (payload.tokenDaily || []).map((r) => r.day);
224
+ const dayTokens = (payload.tokenDaily || []).map((r) => r.tokens);
225
+
226
+ if (systemChart) systemChart.destroy();
227
+ if (tokensChart) tokensChart.destroy();
228
+ if (dailyChart) dailyChart.destroy();
229
+
230
+ systemChart = new Chart(document.getElementById('system-chart'), {
231
+ type: 'line',
232
+ data: {
233
+ labels,
234
+ datasets: [
235
+ { label: 'CPU', data: cpu, borderColor: '#00c853', tension: 0.2, pointRadius: 0 },
236
+ { label: 'Memory', data: memory, borderColor: '#2979ff', tension: 0.2, pointRadius: 0 },
237
+ { label: 'GPU', data: gpu, borderColor: '#a855f7', tension: 0.2, pointRadius: 0 }
238
+ ]
239
+ },
240
+ options: { responsive:true, plugins:{legend:{labels:{color:'#d8defa'}}}, scales:{x:{ticks:{color:'#9aa2d8'}}, y:{min:0,max:100,ticks:{color:'#9aa2d8'}}} }
241
+ });
242
+
243
+ tokensChart = new Chart(document.getElementById('tokens-chart'), {
244
+ type: 'line',
245
+ data: { labels, datasets: [{ label: 'Tokens/min', data: tokens, borderColor: '#ff7a18', tension: 0.2, pointRadius: 0 }] },
246
+ options: { responsive:true, plugins:{legend:{labels:{color:'#d8defa'}}}, scales:{x:{ticks:{color:'#9aa2d8'}}, y:{ticks:{color:'#9aa2d8'}}} }
247
+ });
248
+
249
+ dailyChart = new Chart(document.getElementById('daily-chart'), {
250
+ type: 'bar',
251
+ data: { labels: dayLabels, datasets: [{ label: 'Tokens/day', data: dayTokens, backgroundColor: '#22d3ee' }] },
252
+ options: { responsive:true, plugins:{legend:{labels:{color:'#d8defa'}}}, scales:{x:{ticks:{color:'#9aa2d8'}}, y:{ticks:{color:'#9aa2d8'}}} }
253
+ });
254
+ }
255
+
256
+ async function refresh() {
257
+ const response = await fetch('/api/local-status', { cache: 'no-store' });
258
+ const payload = await response.json();
259
+ draw(payload);
260
+ }
261
+
262
+ refresh();
263
+ setInterval(refresh, 10000);
264
+ </script>
265
+ </body>
266
+ </html>`
267
+ }
268
+
269
+ function openUrl(url) {
270
+ try {
271
+ if (process.platform === 'darwin') {
272
+ spawnSync('open', [url], { stdio: 'ignore' })
273
+ } else if (process.platform === 'win32') {
274
+ spawnSync('cmd', ['/c', 'start', '', url], { stdio: 'ignore' })
275
+ } else {
276
+ spawnSync('xdg-open', [url], { stdio: 'ignore' })
277
+ }
278
+ } catch {
279
+ // no-op
280
+ }
281
+ }
282
+
283
+ function runLocalDashboard({ host }) {
284
+ const logPath = resolveDashboardLogPath(host)
285
+ const portRaw = Number(process.env.IDLEWATCH_DASHBOARD_PORT || 4373)
286
+ const port = Number.isFinite(portRaw) && portRaw > 0 ? portRaw : 4373
287
+
288
+ const server = http.createServer((req, res) => {
289
+ if (req.url === '/api/local-status') {
290
+ const payload = buildLocalDashboardPayload(logPath)
291
+ res.writeHead(200, { 'content-type': 'application/json' })
292
+ res.end(JSON.stringify(payload))
293
+ return
294
+ }
295
+
296
+ if (req.url === '/health') {
297
+ const payload = buildLocalDashboardPayload(logPath)
298
+ res.writeHead(200, { 'content-type': 'application/json' })
299
+ res.end(JSON.stringify({ ok: true, logPath: payload.logPath, samples: payload.sampleCount }))
300
+ return
301
+ }
302
+
303
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
304
+ res.end(renderLocalDashboardHtml())
305
+ })
306
+
307
+ server.listen(port, '127.0.0.1', () => {
308
+ const url = `http://127.0.0.1:${port}`
309
+ const payload = buildLocalDashboardPayload(logPath)
310
+ console.log(`idlewatch local dashboard ready: ${url}`)
311
+ console.log(`log file: ${payload.logPath} (${payload.logSizeHuman})`)
312
+ openUrl(url)
313
+ })
314
+
315
+ server.on('error', (err) => {
316
+ console.error(`Local dashboard failed: ${err.message}`)
317
+ process.exit(1)
318
+ })
319
+ }
320
+
57
321
  const argv = process.argv.slice(2)
58
322
  const quickstartRequested = argv[0] === 'quickstart' || argv.includes('--quickstart')
323
+ const dashboardRequested = argv[0] === 'dashboard' || argv.includes('--dashboard')
59
324
  const args = new Set(argv)
60
325
  if (args.has('--help') || args.has('-h')) {
61
326
  printHelp()
62
327
  process.exit(0)
63
328
  }
64
329
 
330
+ if (dashboardRequested) {
331
+ const host = process.env.IDLEWATCH_HOST || os.hostname()
332
+ runLocalDashboard({ host })
333
+ await new Promise(() => {})
334
+ }
335
+
65
336
  if (quickstartRequested) {
66
337
  try {
67
338
  const result = await runEnrollmentWizard()
@@ -111,6 +382,9 @@ const REQUIRE_FIREBASE_WRITES = process.env.IDLEWATCH_REQUIRE_FIREBASE_WRITES ==
111
382
  const CLOUD_INGEST_URL = process.env.IDLEWATCH_CLOUD_INGEST_URL
112
383
  const CLOUD_API_KEY = process.env.IDLEWATCH_CLOUD_API_KEY
113
384
  const REQUIRE_CLOUD_WRITES = process.env.IDLEWATCH_REQUIRE_CLOUD_WRITES === '1'
385
+ let cloudIngestKickedOut = false
386
+ let cloudIngestKickoutReason = null
387
+ let cloudIngestKickoutNotified = false
114
388
  const OPENCLAW_PROBE_TIMEOUT_MS = Number(process.env.IDLEWATCH_OPENCLAW_PROBE_TIMEOUT_MS || 2500)
115
389
  const OPENCLAW_PROBE_MAX_OUTPUT_BYTES = process.env.IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES
116
390
  ? Number(process.env.IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES)
@@ -322,6 +596,21 @@ function ensureDirFor(filePath) {
322
596
  fs.mkdirSync(path.dirname(filePath), { recursive: true })
323
597
  }
324
598
 
599
+ function getLocalLogUsage() {
600
+ try {
601
+ const stat = fs.statSync(LOCAL_LOG_PATH)
602
+ return {
603
+ path: LOCAL_LOG_PATH,
604
+ bytes: Number(stat.size || 0)
605
+ }
606
+ } catch {
607
+ return {
608
+ path: LOCAL_LOG_PATH,
609
+ bytes: 0
610
+ }
611
+ }
612
+ }
613
+
325
614
  function appendLocal(row) {
326
615
  try {
327
616
  ensureDirFor(LOCAL_LOG_PATH)
@@ -329,6 +618,7 @@ function appendLocal(row) {
329
618
  } catch (err) {
330
619
  console.error(`Local log append failed (${LOCAL_LOG_PATH}): ${err.message}`)
331
620
  }
621
+ return getLocalLogUsage()
332
622
  }
333
623
 
334
624
  function snapshotCpuTimes() {
@@ -789,6 +1079,8 @@ async function publish(row, retries = 2) {
789
1079
  if (DRY_RUN) return false
790
1080
 
791
1081
  if (CLOUD_INGEST_URL && CLOUD_API_KEY) {
1082
+ if (cloudIngestKickedOut) return false
1083
+
792
1084
  let attempt = 0
793
1085
  while (attempt <= retries) {
794
1086
  try {
@@ -800,11 +1092,31 @@ async function publish(row, retries = 2) {
800
1092
  },
801
1093
  body: JSON.stringify(row)
802
1094
  })
1095
+
803
1096
  if (!response.ok) {
1097
+ let detail = null
1098
+ try {
1099
+ const payload = await response.json()
1100
+ detail = payload?.detail || payload?.error || payload?.message || null
1101
+ } catch {
1102
+ try {
1103
+ detail = (await response.text())?.slice(0, 180) || null
1104
+ } catch {
1105
+ detail = null
1106
+ }
1107
+ }
1108
+
1109
+ if (response.status === 401 || response.status === 403) {
1110
+ cloudIngestKickedOut = true
1111
+ cloudIngestKickoutReason = detail || `http_${response.status}`
1112
+ return false
1113
+ }
1114
+
804
1115
  throw new Error(`cloud_ingest_failed_${response.status}`)
805
1116
  }
806
1117
  return true
807
1118
  } catch (err) {
1119
+ if (cloudIngestKickedOut) throw err
808
1120
  if (attempt >= retries) throw err
809
1121
  await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1)))
810
1122
  attempt += 1
@@ -951,7 +1263,11 @@ async function collectSample() {
951
1263
  usageStaleMsThreshold: USAGE_STALE_MS,
952
1264
  usageNearStaleMsThreshold: USAGE_NEAR_STALE_MS,
953
1265
  usageStaleGraceMs: USAGE_STALE_GRACE_MS,
954
- memPressureSource: memPressure.source
1266
+ memPressureSource: memPressure.source,
1267
+ cloudIngestionStatus: CLOUD_INGEST_URL && CLOUD_API_KEY
1268
+ ? cloudIngestKickedOut ? 'kicked-out' : 'enabled'
1269
+ : 'disabled',
1270
+ cloudIngestionReason: cloudIngestKickoutReason
955
1271
  }
956
1272
 
957
1273
  const usageAlert = deriveUsageAlert(source, { usageAgeMs: usageFreshness.usageAgeMs, idleAfterMs: USAGE_IDLE_AFTER_MS })
@@ -977,6 +1293,8 @@ async function collectSample() {
977
1293
  openclawAgentId: MONITOR_OPENCLAW ? (usage?.agentId ?? null) : null,
978
1294
  openclawUsageTs: MONITOR_OPENCLAW ? (usage?.usageTimestampMs ?? null) : null,
979
1295
  openclawUsageAgeMs: MONITOR_OPENCLAW ? usageFreshness.usageAgeMs : null,
1296
+ localLogPath: LOCAL_LOG_PATH,
1297
+ localLogBytes: null,
980
1298
  source
981
1299
  }
982
1300
 
@@ -990,13 +1308,31 @@ async function collectSample() {
990
1308
 
991
1309
  async function tick() {
992
1310
  const row = await collectSample()
1311
+ const localUsage = appendLocal(row)
1312
+ row.localLogPath = localUsage.path
1313
+ row.localLogBytes = localUsage.bytes
1314
+
993
1315
  console.log(JSON.stringify(row))
994
- appendLocal(row)
1316
+
995
1317
  const published = await publish(row)
1318
+
1319
+ if (cloudIngestKickedOut && !cloudIngestKickoutNotified) {
1320
+ cloudIngestKickoutNotified = true
1321
+ console.error(
1322
+ `Cloud ingest disabled: API key rejected (${cloudIngestKickoutReason || 'unauthorized'}). Run idlewatch quickstart to link a new key.`
1323
+ )
1324
+ }
1325
+
996
1326
  if (REQUIRE_FIREBASE_WRITES && ONCE && !published) {
997
1327
  throw new Error('Firebase write was required but not executed. Check Firebase configuration and connectivity.')
998
1328
  }
1329
+
999
1330
  if (REQUIRE_CLOUD_WRITES && ONCE && !published) {
1331
+ if (cloudIngestKickedOut) {
1332
+ throw new Error(
1333
+ `Cloud API key was rejected (${cloudIngestKickoutReason || 'unauthorized'}). This device was disconnected. Run idlewatch quickstart with a new API key.`
1334
+ )
1335
+ }
1000
1336
  throw new Error('Cloud write was required but not executed. Check API key and cloud connectivity.')
1001
1337
  }
1002
1338
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idlewatch",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Host telemetry collector for IdleWatch",
5
5
  "type": "module",
6
6
  "bin": {
package/tui/src/main.rs CHANGED
@@ -10,7 +10,7 @@ use ratatui::{
10
10
  };
11
11
  use std::{
12
12
  fs,
13
- io::{self, Write},
13
+ io,
14
14
  path::{Path, PathBuf},
15
15
  process::{Command, Stdio},
16
16
  time::Duration,
@@ -127,8 +127,8 @@ fn render_mode_menu(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, selec
127
127
  if i == selected {
128
128
  ListItem::new(format!("❯ {}", item)).style(
129
129
  Style::default()
130
- .fg(Color::Black)
131
- .bg(Color::LightMagenta)
130
+ .fg(Color::White)
131
+ .bg(Color::Blue)
132
132
  .add_modifier(Modifier::BOLD),
133
133
  )
134
134
  } else {
@@ -199,8 +199,8 @@ fn render_monitor_menu(
199
199
  if idx == cursor {
200
200
  ListItem::new(format!("❯ {}", line)).style(
201
201
  Style::default()
202
- .fg(Color::Black)
203
- .bg(Color::LightMagenta)
202
+ .fg(Color::White)
203
+ .bg(Color::Blue)
204
204
  .add_modifier(Modifier::BOLD),
205
205
  )
206
206
  } else if target.available {
@@ -227,12 +227,57 @@ fn render_monitor_menu(
227
227
  Ok(())
228
228
  }
229
229
 
230
- fn read_line(prompt: &str) -> Result<String> {
231
- print!("{}", prompt);
232
- io::stdout().flush()?;
233
- let mut s = String::new();
234
- io::stdin().read_line(&mut s)?;
235
- Ok(s.trim().to_string())
230
+ fn render_api_key_prompt(
231
+ terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
232
+ input: &str,
233
+ ) -> Result<()> {
234
+ terminal.draw(|f| {
235
+ let chunks = Layout::default()
236
+ .direction(Direction::Vertical)
237
+ .margin(1)
238
+ .constraints([
239
+ Constraint::Length(4),
240
+ Constraint::Length(6),
241
+ Constraint::Min(1),
242
+ ])
243
+ .split(f.area());
244
+
245
+ let header = Paragraph::new("Link this device to your IdleWatch account")
246
+ .style(Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD))
247
+ .block(
248
+ Block::default()
249
+ .borders(Borders::ALL)
250
+ .title("Cloud API Key")
251
+ .border_style(Style::default().fg(Color::Magenta)),
252
+ );
253
+ f.render_widget(header, chunks[0]);
254
+
255
+ let content = if input.trim().is_empty() {
256
+ "Paste your API key from idlewatch.com/api"
257
+ } else {
258
+ input
259
+ };
260
+
261
+ let input_style = if input.trim().is_empty() {
262
+ Style::default().fg(Color::DarkGray)
263
+ } else {
264
+ Style::default().fg(Color::White)
265
+ };
266
+
267
+ let input_box = Paragraph::new(content).style(input_style).block(
268
+ Block::default()
269
+ .borders(Borders::ALL)
270
+ .title("API key")
271
+ .border_style(Style::default().fg(Color::Cyan)),
272
+ );
273
+ f.render_widget(input_box, chunks[1]);
274
+
275
+ let help = Paragraph::new("Paste key • Backspace edit • Enter continue • q quit")
276
+ .style(Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD));
277
+ f.render_widget(help, chunks[2]);
278
+ })?;
279
+
280
+ Ok(())
236
281
  }
237
282
 
238
283
  fn main() -> Result<()> {
@@ -307,14 +352,48 @@ fn main() -> Result<()> {
307
352
  }
308
353
  }
309
354
 
310
- disable_raw_mode()?;
311
- execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
312
-
313
355
  let mode = match selected_mode {
314
356
  0 => "production",
315
357
  _ => "local",
316
358
  };
317
359
 
360
+ let mut cloud_api_key_input = String::new();
361
+ if mode == "production" {
362
+ loop {
363
+ render_api_key_prompt(&mut terminal, &cloud_api_key_input)?;
364
+ if event::poll(Duration::from_millis(250))? {
365
+ match event::read()? {
366
+ Event::Key(key) => match key.code {
367
+ KeyCode::Enter => {
368
+ if !cloud_api_key_input.trim().is_empty() {
369
+ break;
370
+ }
371
+ }
372
+ KeyCode::Backspace => {
373
+ cloud_api_key_input.pop();
374
+ }
375
+ KeyCode::Char('q') | KeyCode::Esc => {
376
+ disable_raw_mode()?;
377
+ execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
378
+ return Ok(());
379
+ }
380
+ KeyCode::Char(c) => {
381
+ cloud_api_key_input.push(c);
382
+ }
383
+ _ => {}
384
+ },
385
+ Event::Paste(text) => {
386
+ cloud_api_key_input.push_str(&text);
387
+ }
388
+ _ => {}
389
+ }
390
+ }
391
+ }
392
+ }
393
+
394
+ disable_raw_mode()?;
395
+ execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
396
+
318
397
  let selected_keys = monitor_targets
319
398
  .iter()
320
399
  .filter(|item| item.selected && item.available)
@@ -351,12 +430,12 @@ fn main() -> Result<()> {
351
430
  }
352
431
 
353
432
  if mode == "production" {
354
- let api_key = read_line("Cloud API key (from idlewatch.com/api): ")?;
355
- if api_key.trim().is_empty() {
433
+ let api_key = cloud_api_key_input.trim().to_string();
434
+ if api_key.is_empty() {
356
435
  return Err(anyhow!("cloud API key is required"));
357
436
  }
358
437
  env_lines.push("IDLEWATCH_CLOUD_INGEST_URL=https://api.idlewatch.com/api/ingest".to_string());
359
- env_lines.push(format!("IDLEWATCH_CLOUD_API_KEY={}", api_key.trim()));
438
+ env_lines.push(format!("IDLEWATCH_CLOUD_API_KEY={}", api_key));
360
439
  env_lines.push("IDLEWATCH_REQUIRE_CLOUD_WRITES=1".to_string());
361
440
  }
362
441