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.
- package/README.md +12 -11
- package/bin/idlewatch-agent.js +404 -19
- package/package.json +12 -1
- package/scripts/install-macos-launch-agent.sh +1 -0
- package/scripts/validate-onboarding.mjs +57 -22
- package/src/enrollment.js +48 -7
- package/tui/src/main.rs +338 -28
- package/.github/workflows/ci.yml +0 -99
- package/.github/workflows/release-macos-trusted.yml +0 -103
- package/docs/onboarding-external.md +0 -58
- package/docs/packaging/macos-dmg.md +0 -199
- package/docs/packaging/macos-launch-agent.md +0 -70
- package/docs/qa/archive/mac-qa-log-2026-02-17.md +0 -5838
- package/docs/qa/mac-qa-log.md +0 -2864
- package/docs/telemetry/idle-stale-policy.md +0 -57
- package/docs/telemetry/openclaw-mapping.md +0 -80
- package/test/config.test.mjs +0 -112
- package/test/fixtures/gpu-agx.txt +0 -2
- package/test/fixtures/gpu-iogpu.txt +0 -2
- package/test/fixtures/gpu-top-grep.txt +0 -2
- package/test/fixtures/openclaw-fleet-sample-v1.json +0 -68
- package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-iso-ts.txt +0 -2
- package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-newest.txt +0 -2
- package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-string-ts.txt +0 -2
- package/test/fixtures/openclaw-mixed-status-then-generic-output.txt +0 -2
- package/test/fixtures/openclaw-stats-current-wrapper.json +0 -12
- package/test/fixtures/openclaw-stats-current-wrapper2.json +0 -15
- package/test/fixtures/openclaw-stats-data-wrapper.json +0 -21
- package/test/fixtures/openclaw-stats-nested-session-wrapper.json +0 -23
- package/test/fixtures/openclaw-stats-payload-wrapper.json +0 -1
- package/test/fixtures/openclaw-stats-status-current-wrapper.json +0 -19
- package/test/fixtures/openclaw-stats.json +0 -17
- package/test/fixtures/openclaw-status-ansi-complex-noise.txt +0 -3
- package/test/fixtures/openclaw-status-ansi-noise.txt +0 -2
- package/test/fixtures/openclaw-status-control-noise.txt +0 -1
- package/test/fixtures/openclaw-status-data-wrapper.json +0 -20
- package/test/fixtures/openclaw-status-dcs-noise.txt +0 -1
- package/test/fixtures/openclaw-status-epoch-seconds.json +0 -15
- package/test/fixtures/openclaw-status-mixed-noise.txt +0 -1
- package/test/fixtures/openclaw-status-multi-json.txt +0 -3
- package/test/fixtures/openclaw-status-nested-recent.json +0 -19
- package/test/fixtures/openclaw-status-noisy-default-then-usage.txt +0 -2
- package/test/fixtures/openclaw-status-noisy.txt +0 -3
- package/test/fixtures/openclaw-status-osc-noise.txt +0 -1
- package/test/fixtures/openclaw-status-result-session.json +0 -15
- package/test/fixtures/openclaw-status-session-map-with-defaults.json +0 -23
- package/test/fixtures/openclaw-status-session-map.json +0 -28
- package/test/fixtures/openclaw-status-session-model-name.json +0 -18
- package/test/fixtures/openclaw-status-snake-session-wrapper.json +0 -13
- package/test/fixtures/openclaw-status-stats-current-sessions-snake-tokens.json +0 -25
- package/test/fixtures/openclaw-status-stats-current-sessions.json +0 -28
- package/test/fixtures/openclaw-status-stats-current-usage-time-camelcase.json +0 -19
- package/test/fixtures/openclaw-status-stats-session-default-model.json +0 -27
- package/test/fixtures/openclaw-status-status-wrapper.json +0 -13
- package/test/fixtures/openclaw-status-strings.json +0 -38
- package/test/fixtures/openclaw-status-ts-ms-alias.json +0 -14
- package/test/fixtures/openclaw-status-updated-at-ms-alias.json +0 -14
- package/test/fixtures/openclaw-status-usage-timestamp-ms-alias.json +0 -14
- package/test/fixtures/openclaw-status-usage-ts-alias.json +0 -14
- package/test/fixtures/openclaw-status-wrap-session-object.json +0 -24
- package/test/fixtures/openclaw-status.json +0 -41
- package/test/fixtures/openclaw-usage-model-name-generic.json +0 -9
- package/test/gpu.test.mjs +0 -58
- package/test/memory.test.mjs +0 -35
- package/test/openclaw-cache.test.mjs +0 -48
- package/test/openclaw-env.test.mjs +0 -365
- package/test/openclaw-usage.test.mjs +0 -555
- package/test/telemetry-mapping.test.mjs +0 -69
- package/test/telemetry-row-parser.test.mjs +0 -44
- package/test/usage-alert.test.mjs +0 -73
- package/test/usage-freshness.test.mjs +0 -63
- package/test/validate-dry-run-schema.test.mjs +0 -146
package/README.md
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
# idlewatch
|
|
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
|
-
|
|
8
|
+
npm install -g idlewatch
|
|
9
|
+
idlewatch --help
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Or run it directly with npx:
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
npx idlewatch
|
|
16
|
-
npx idlewatch
|
|
17
|
-
npx idlewatch
|
|
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
|
|
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
|
|
65
|
+
Then run a one-shot publish check:
|
|
66
66
|
|
|
67
67
|
```bash
|
|
68
|
-
|
|
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
|
package/bin/idlewatch-agent.js
CHANGED
|
@@ -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
|
|
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(
|
|
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.
|
|
85
|
-
console.
|
|
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 =
|
|
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
|
-
?
|
|
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
|
-
?
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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",
|