idlewatch 0.1.0

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 (110) hide show
  1. package/.env.example +73 -0
  2. package/.github/workflows/ci.yml +99 -0
  3. package/.github/workflows/release-macos-trusted.yml +103 -0
  4. package/README.md +336 -0
  5. package/bin/idlewatch-agent.js +1053 -0
  6. package/docs/onboarding-external.md +58 -0
  7. package/docs/packaging/macos-dmg.md +199 -0
  8. package/docs/packaging/macos-launch-agent.md +70 -0
  9. package/docs/qa/archive/mac-qa-log-2026-02-17.md +5838 -0
  10. package/docs/qa/mac-qa-log.md +2864 -0
  11. package/docs/telemetry/idle-stale-policy.md +57 -0
  12. package/docs/telemetry/openclaw-mapping.md +80 -0
  13. package/package.json +76 -0
  14. package/scripts/build-dmg.sh +65 -0
  15. package/scripts/install-macos-launch-agent.sh +78 -0
  16. package/scripts/lib/telemetry-row-parser.mjs +100 -0
  17. package/scripts/package-macos.sh +228 -0
  18. package/scripts/uninstall-macos-launch-agent.sh +30 -0
  19. package/scripts/validate-all.sh +142 -0
  20. package/scripts/validate-bin.mjs +25 -0
  21. package/scripts/validate-dmg-checksum.sh +37 -0
  22. package/scripts/validate-dmg-install.sh +155 -0
  23. package/scripts/validate-dry-run-schema.mjs +257 -0
  24. package/scripts/validate-onboarding.mjs +63 -0
  25. package/scripts/validate-openclaw-cache-recovery-e2e.mjs +113 -0
  26. package/scripts/validate-openclaw-release-gates.mjs +51 -0
  27. package/scripts/validate-openclaw-stats-ingestion.mjs +372 -0
  28. package/scripts/validate-openclaw-usage-health.mjs +95 -0
  29. package/scripts/validate-packaged-artifact.mjs +233 -0
  30. package/scripts/validate-packaged-bundled-runtime.sh +191 -0
  31. package/scripts/validate-packaged-metadata.sh +43 -0
  32. package/scripts/validate-packaged-openclaw-cache-recovery-e2e.mjs +153 -0
  33. package/scripts/validate-packaged-openclaw-release-gates.mjs +72 -0
  34. package/scripts/validate-packaged-openclaw-stats-ingestion.mjs +402 -0
  35. package/scripts/validate-packaged-sourcemaps.mjs +82 -0
  36. package/scripts/validate-packaged-usage-alert-rate-e2e.mjs +98 -0
  37. package/scripts/validate-packaged-usage-probe-noise-e2e.mjs +87 -0
  38. package/scripts/validate-packaged-usage-recovery-e2e.mjs +90 -0
  39. package/scripts/validate-trusted-prereqs.sh +44 -0
  40. package/scripts/validate-usage-alert-rate-e2e.mjs +91 -0
  41. package/scripts/validate-usage-freshness-e2e.mjs +81 -0
  42. package/skill/SKILL.md +43 -0
  43. package/src/config.js +100 -0
  44. package/src/enrollment.js +176 -0
  45. package/src/gpu.js +115 -0
  46. package/src/memory.js +67 -0
  47. package/src/openclaw-cache.js +51 -0
  48. package/src/openclaw-usage.js +1020 -0
  49. package/src/telemetry-mapping.js +54 -0
  50. package/src/usage-alert.js +41 -0
  51. package/src/usage-freshness.js +31 -0
  52. package/test/config.test.mjs +112 -0
  53. package/test/fixtures/gpu-agx.txt +2 -0
  54. package/test/fixtures/gpu-iogpu.txt +2 -0
  55. package/test/fixtures/gpu-top-grep.txt +2 -0
  56. package/test/fixtures/openclaw-fleet-sample-v1.json +68 -0
  57. package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-iso-ts.txt +2 -0
  58. package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-newest.txt +2 -0
  59. package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-string-ts.txt +2 -0
  60. package/test/fixtures/openclaw-mixed-status-then-generic-output.txt +2 -0
  61. package/test/fixtures/openclaw-stats-current-wrapper.json +12 -0
  62. package/test/fixtures/openclaw-stats-current-wrapper2.json +15 -0
  63. package/test/fixtures/openclaw-stats-data-wrapper.json +21 -0
  64. package/test/fixtures/openclaw-stats-nested-session-wrapper.json +23 -0
  65. package/test/fixtures/openclaw-stats-payload-wrapper.json +1 -0
  66. package/test/fixtures/openclaw-stats-status-current-wrapper.json +19 -0
  67. package/test/fixtures/openclaw-stats.json +17 -0
  68. package/test/fixtures/openclaw-status-ansi-complex-noise.txt +3 -0
  69. package/test/fixtures/openclaw-status-ansi-noise.txt +2 -0
  70. package/test/fixtures/openclaw-status-control-noise.txt +1 -0
  71. package/test/fixtures/openclaw-status-data-wrapper.json +20 -0
  72. package/test/fixtures/openclaw-status-dcs-noise.txt +1 -0
  73. package/test/fixtures/openclaw-status-epoch-seconds.json +15 -0
  74. package/test/fixtures/openclaw-status-mixed-noise.txt +1 -0
  75. package/test/fixtures/openclaw-status-multi-json.txt +3 -0
  76. package/test/fixtures/openclaw-status-nested-recent.json +19 -0
  77. package/test/fixtures/openclaw-status-noisy-default-then-usage.txt +2 -0
  78. package/test/fixtures/openclaw-status-noisy.txt +3 -0
  79. package/test/fixtures/openclaw-status-osc-noise.txt +1 -0
  80. package/test/fixtures/openclaw-status-result-session.json +15 -0
  81. package/test/fixtures/openclaw-status-session-map-with-defaults.json +23 -0
  82. package/test/fixtures/openclaw-status-session-map.json +28 -0
  83. package/test/fixtures/openclaw-status-session-model-name.json +18 -0
  84. package/test/fixtures/openclaw-status-snake-session-wrapper.json +13 -0
  85. package/test/fixtures/openclaw-status-stats-current-sessions-snake-tokens.json +25 -0
  86. package/test/fixtures/openclaw-status-stats-current-sessions.json +28 -0
  87. package/test/fixtures/openclaw-status-stats-current-usage-time-camelcase.json +19 -0
  88. package/test/fixtures/openclaw-status-stats-session-default-model.json +27 -0
  89. package/test/fixtures/openclaw-status-status-wrapper.json +13 -0
  90. package/test/fixtures/openclaw-status-strings.json +38 -0
  91. package/test/fixtures/openclaw-status-ts-ms-alias.json +14 -0
  92. package/test/fixtures/openclaw-status-updated-at-ms-alias.json +14 -0
  93. package/test/fixtures/openclaw-status-usage-timestamp-ms-alias.json +14 -0
  94. package/test/fixtures/openclaw-status-usage-ts-alias.json +14 -0
  95. package/test/fixtures/openclaw-status-wrap-session-object.json +24 -0
  96. package/test/fixtures/openclaw-status.json +41 -0
  97. package/test/fixtures/openclaw-usage-model-name-generic.json +9 -0
  98. package/test/gpu.test.mjs +58 -0
  99. package/test/memory.test.mjs +35 -0
  100. package/test/openclaw-cache.test.mjs +48 -0
  101. package/test/openclaw-env.test.mjs +365 -0
  102. package/test/openclaw-usage.test.mjs +555 -0
  103. package/test/telemetry-mapping.test.mjs +69 -0
  104. package/test/telemetry-row-parser.test.mjs +44 -0
  105. package/test/usage-alert.test.mjs +73 -0
  106. package/test/usage-freshness.test.mjs +63 -0
  107. package/test/validate-dry-run-schema.test.mjs +146 -0
  108. package/tui/Cargo.lock +801 -0
  109. package/tui/Cargo.toml +11 -0
  110. package/tui/src/main.rs +368 -0
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, writeFileSync, chmodSync, rmSync, readFileSync } from 'node:fs'
4
+ import { readTelemetryJsonRow } from './lib/telemetry-row-parser.mjs'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { execFileSync } from 'node:child_process'
8
+
9
+ const repoRoot = process.cwd()
10
+ const appBin = './dist/IdleWatch.app/Contents/MacOS/IdleWatch'
11
+ const tempDir = mkdtempSync(join(tmpdir(), 'idlewatch-packaged-recovery-'))
12
+ const mockBinPath = join(tempDir, 'openclaw-mock.sh')
13
+ const counterPath = join(tempDir, 'counter.txt')
14
+
15
+ function writeMockOpenClaw(binPath, callCounterPath) {
16
+ const script = `#!/usr/bin/env bash
17
+ set -euo pipefail
18
+ counter_file="${callCounterPath}"
19
+ if [[ ! -f "$counter_file" ]]; then
20
+ echo 0 > "$counter_file"
21
+ fi
22
+ n=$(cat "$counter_file")
23
+ next=$((n + 1))
24
+ echo "$next" > "$counter_file"
25
+
26
+ # First call returns post-threshold age, second call returns fresh age.
27
+ if [[ "$n" -eq 0 ]]; then
28
+ age_ms=65000
29
+ else
30
+ age_ms=2000
31
+ fi
32
+
33
+ now_ms=$(node -p 'Date.now()')
34
+ updated_at=$((now_ms - age_ms))
35
+ cat <<JSON
36
+ {"sessions":{"defaults":{"model":"gpt-5.3-codex"},"recent":[{"sessionId":"sess-packaged-recovery","agentId":"main","model":"gpt-5.3-codex","totalTokens":99999,"updatedAt":\${updated_at},"totalTokensFresh":true}]},"ts":\${now_ms}}
37
+ JSON
38
+ `
39
+ writeFileSync(binPath, script, 'utf8')
40
+ chmodSync(binPath, 0o755)
41
+ }
42
+
43
+ function readRow(output) {
44
+ return readTelemetryJsonRow(output)
45
+ }
46
+
47
+ try {
48
+ writeMockOpenClaw(mockBinPath, counterPath)
49
+
50
+ if (process.env.IDLEWATCH_SKIP_PACKAGE_MACOS !== '1') {
51
+ execFileSync('npm', ['run', 'package:macos', '--silent'], {
52
+ cwd: repoRoot,
53
+ encoding: 'utf8',
54
+ stdio: ['ignore', 'pipe', 'pipe']
55
+ })
56
+ }
57
+
58
+ const env = {
59
+ ...process.env,
60
+ IDLEWATCH_OPENCLAW_BIN: mockBinPath,
61
+ IDLEWATCH_USAGE_STALE_MS: '60000',
62
+ IDLEWATCH_USAGE_STALE_GRACE_MS: '10000',
63
+ IDLEWATCH_USAGE_REFRESH_REPROBES: '1',
64
+ IDLEWATCH_USAGE_REFRESH_DELAY_MS: '0',
65
+ IDLEWATCH_INTERVAL_MS: '1000'
66
+ }
67
+
68
+ const out = execFileSync(appBin, ['--dry-run'], {
69
+ cwd: repoRoot,
70
+ encoding: 'utf8',
71
+ env,
72
+ stdio: ['ignore', 'pipe', 'pipe']
73
+ })
74
+
75
+ const row = readRow(out)
76
+ assert.equal(row?.source?.usageIngestionStatus, 'ok')
77
+ assert.equal(row?.source?.usageRefreshAttempted, true)
78
+ assert.equal(row?.source?.usageRefreshRecovered, true)
79
+ assert.ok((row?.source?.usageRefreshAttempts ?? 0) >= 1, 'expected at least one refresh attempt during recovery pass')
80
+ assert.equal(row?.source?.usageActivityStatus, 'fresh')
81
+ assert.equal(row?.source?.usageAlertLevel, 'ok')
82
+ assert.equal(row?.source?.usageAlertReason, 'healthy')
83
+
84
+ const probeCalls = Number(readFileSync(counterPath, 'utf8').trim())
85
+ assert.ok(probeCalls >= 2, `expected mock OpenClaw to be invoked >=2 times, got ${probeCalls}`)
86
+
87
+ console.log('packaged-usage-recovery-e2e: ok (post-threshold sample recovered to fresh via forced reprobe)')
88
+ } finally {
89
+ rmSync(tempDir, { recursive: true, force: true })
90
+ }
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ CODESIGN_IDENTITY="${MACOS_CODESIGN_IDENTITY:-}"
5
+ NOTARY_PROFILE="${MACOS_NOTARY_PROFILE:-}"
6
+
7
+ if [[ -z "$CODESIGN_IDENTITY" ]]; then
8
+ echo "Missing MACOS_CODESIGN_IDENTITY. Example: Developer ID Application: Your Name (TEAMID)" >&2
9
+ exit 1
10
+ fi
11
+
12
+ if [[ -z "$NOTARY_PROFILE" ]]; then
13
+ echo "Missing MACOS_NOTARY_PROFILE. Create one with: xcrun notarytool store-credentials <profile> ..." >&2
14
+ exit 1
15
+ fi
16
+
17
+ if ! command -v codesign >/dev/null 2>&1; then
18
+ echo "codesign not found. Install Xcode Command Line Tools (xcode-select --install)." >&2
19
+ exit 1
20
+ fi
21
+
22
+ if ! command -v xcrun >/dev/null 2>&1; then
23
+ echo "xcrun not found. Install Xcode Command Line Tools (xcode-select --install)." >&2
24
+ exit 1
25
+ fi
26
+
27
+ if ! command -v security >/dev/null 2>&1; then
28
+ echo "security CLI not found; cannot validate signing identities." >&2
29
+ exit 1
30
+ fi
31
+
32
+ if ! security find-identity -v -p codesigning 2>/dev/null | grep -F "$CODESIGN_IDENTITY" >/dev/null 2>&1; then
33
+ echo "Signing identity not found in keychain: $CODESIGN_IDENTITY" >&2
34
+ echo "Run 'security find-identity -v -p codesigning' to list available identities." >&2
35
+ exit 1
36
+ fi
37
+
38
+ if ! xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" >/dev/null 2>&1; then
39
+ echo "Notary profile '$NOTARY_PROFILE' is unavailable or invalid." >&2
40
+ echo "Create/recreate it with: xcrun notarytool store-credentials $NOTARY_PROFILE --apple-id ... --team-id ... --password ..." >&2
41
+ exit 1
42
+ fi
43
+
44
+ echo "Trusted packaging prerequisites OK (codesign identity + notary profile)."
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, writeFileSync, chmodSync, rmSync } from 'node:fs'
4
+ import { readTelemetryJsonRow } from './lib/telemetry-row-parser.mjs'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { execFileSync } from 'node:child_process'
8
+
9
+ const repoRoot = process.cwd()
10
+ const tempDir = mkdtempSync(join(tmpdir(), 'idlewatch-openclaw-alert-rate-'))
11
+ const mockBinPath = join(tempDir, 'openclaw-mock.sh')
12
+
13
+ function writeMockOpenClaw(binPath) {
14
+ const script = `#!/usr/bin/env bash
15
+ set -euo pipefail
16
+ age_ms="\${MOCK_OPENCLAW_AGE_MS:-500}"
17
+ now_ms=$(node -p 'Date.now()')
18
+ updated_at=$((now_ms - age_ms))
19
+ cat <<JSON
20
+ {"sessions":{"defaults":{"model":"gpt-5.3-codex"},"recent":[{"sessionId":"sess-alert-rate","agentId":"main","model":"gpt-5.3-codex","totalTokens":12345,"updatedAt":\${updated_at},"totalTokensFresh":true}]},"ts":\${now_ms}}
21
+ JSON
22
+ `
23
+ writeFileSync(binPath, script, 'utf8')
24
+ chmodSync(binPath, 0o755)
25
+ }
26
+
27
+ function readRow(output) {
28
+ return readTelemetryJsonRow(output)
29
+ }
30
+
31
+ function runSample(ageMs, overrides = {}) {
32
+ const env = {
33
+ ...process.env,
34
+ IDLEWATCH_OPENCLAW_BIN: mockBinPath,
35
+ IDLEWATCH_USAGE_STALE_MS: '60000',
36
+ IDLEWATCH_USAGE_STALE_GRACE_MS: '10000',
37
+ IDLEWATCH_INTERVAL_MS: '1000',
38
+ MOCK_OPENCLAW_AGE_MS: String(ageMs),
39
+ ...overrides
40
+ }
41
+
42
+ const out = execFileSync('node', ['bin/idlewatch-agent.js', '--dry-run'], {
43
+ cwd: repoRoot,
44
+ encoding: 'utf8',
45
+ env,
46
+ stdio: ['ignore', 'pipe', 'pipe']
47
+ })
48
+
49
+ return readRow(out)
50
+ }
51
+
52
+ function collectAlertLevels(ageSeries, overrides = {}) {
53
+ return ageSeries.map((ageMs) => {
54
+ const row = runSample(ageMs, overrides)
55
+ return {
56
+ ageMs,
57
+ nearMs: row?.source?.usageNearStaleMsThreshold,
58
+ level: row?.source?.usageAlertLevel,
59
+ reason: row?.source?.usageAlertReason
60
+ }
61
+ })
62
+ }
63
+
64
+ try {
65
+ writeMockOpenClaw(mockBinPath)
66
+
67
+ const typicalAges = [28000, 33000, 39000, 45000, 50000, 54000]
68
+ const typical = collectAlertLevels(typicalAges)
69
+
70
+ const nonOkTypical = typical.filter((sample) => sample.level !== 'ok')
71
+ assert.equal(nonOkTypical.length, 0, `expected no non-ok alerts for typical low-traffic ages, got: ${JSON.stringify(nonOkTypical)}`)
72
+ assert.equal(typical[0].nearMs, 59500, 'expected default near-stale threshold to include stale+grace derivation (59500ms)')
73
+
74
+ const deterministicBoundaryAges = [1800, 3600, 5600]
75
+ const deterministicBoundary = collectAlertLevels(deterministicBoundaryAges, {
76
+ IDLEWATCH_USAGE_STALE_MS: '3000',
77
+ IDLEWATCH_USAGE_NEAR_STALE_MS: '1000',
78
+ IDLEWATCH_USAGE_STALE_GRACE_MS: '1000'
79
+ })
80
+
81
+ assert.equal(deterministicBoundary[0].level, 'notice')
82
+ assert.equal(deterministicBoundary[0].reason, 'activity-near-stale')
83
+ assert.equal(deterministicBoundary[1].level, 'warning')
84
+ assert.equal(deterministicBoundary[1].reason, 'activity-past-threshold')
85
+ assert.equal(deterministicBoundary[2].level, 'warning')
86
+ assert.equal(deterministicBoundary[2].reason, 'activity-past-threshold')
87
+
88
+ console.log('usage-alert-rate-e2e: ok (typical cadence stays ok; boundary states escalate notice -> warning -> warning)')
89
+ } finally {
90
+ rmSync(tempDir, { recursive: true, force: true })
91
+ }
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, writeFileSync, chmodSync, rmSync } from 'node:fs'
4
+ import { readTelemetryJsonRow } from './lib/telemetry-row-parser.mjs'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { execFileSync } from 'node:child_process'
8
+
9
+ const repoRoot = process.cwd()
10
+ const tempDir = mkdtempSync(join(tmpdir(), 'idlewatch-openclaw-mock-'))
11
+ const mockBinPath = join(tempDir, 'openclaw-mock.sh')
12
+
13
+ function writeMockOpenClaw(binPath) {
14
+ const script = `#!/usr/bin/env bash
15
+ set -euo pipefail
16
+ age_ms="\${MOCK_OPENCLAW_AGE_MS:-500}"
17
+ now_ms=$(node -p 'Date.now()')
18
+ updated_at=$((now_ms - age_ms))
19
+ cat <<JSON
20
+ {"sessions":{"defaults":{"model":"gpt-5.3-codex"},"recent":[{"sessionId":"sess-e2e","agentId":"main","model":"gpt-5.3-codex","totalTokens":12345,"updatedAt":\${updated_at},"totalTokensFresh":true}]},"ts":\${now_ms}}
21
+ JSON
22
+ `
23
+ writeFileSync(binPath, script, 'utf8')
24
+ chmodSync(binPath, 0o755)
25
+ }
26
+
27
+ function readRow(output) {
28
+ return readTelemetryJsonRow(output)
29
+ }
30
+
31
+ function runSample(ageMs) {
32
+ const env = {
33
+ ...process.env,
34
+ IDLEWATCH_OPENCLAW_BIN: mockBinPath,
35
+ IDLEWATCH_USAGE_STALE_MS: '2000',
36
+ IDLEWATCH_USAGE_NEAR_STALE_MS: '1000',
37
+ IDLEWATCH_USAGE_STALE_GRACE_MS: '500',
38
+ IDLEWATCH_INTERVAL_MS: '1000',
39
+ MOCK_OPENCLAW_AGE_MS: String(ageMs)
40
+ }
41
+
42
+ const out = execFileSync('node', ['bin/idlewatch-agent.js', '--dry-run'], {
43
+ cwd: repoRoot,
44
+ encoding: 'utf8',
45
+ env,
46
+ stdio: ['ignore', 'pipe', 'pipe']
47
+ })
48
+
49
+ return readRow(out)
50
+ }
51
+
52
+ try {
53
+ writeMockOpenClaw(mockBinPath)
54
+
55
+ const fresh = runSample(500)
56
+ assert.equal(fresh.source.usageIntegrationStatus, 'ok')
57
+ assert.equal(fresh.source.usageFreshnessState, 'fresh')
58
+ assert.equal(fresh.source.usageNearStale, false)
59
+ assert.equal(fresh.source.usagePastStaleThreshold, false)
60
+
61
+ const aging = runSample(1200)
62
+ assert.equal(aging.source.usageIntegrationStatus, 'ok')
63
+ assert.equal(aging.source.usageFreshnessState, 'aging')
64
+ assert.equal(aging.source.usageNearStale, true)
65
+ assert.equal(aging.source.usagePastStaleThreshold, false)
66
+
67
+ const postThresholdGrace = runSample(2200)
68
+ assert.equal(postThresholdGrace.source.usageIntegrationStatus, 'ok')
69
+ assert.equal(postThresholdGrace.source.usageFreshnessState, 'aging')
70
+ assert.equal(postThresholdGrace.source.usagePastStaleThreshold, true)
71
+
72
+ const stale = runSample(2600)
73
+ assert.equal(stale.source.usageIntegrationStatus, 'stale')
74
+ assert.equal(stale.source.usageFreshnessState, 'stale')
75
+ assert.equal(stale.source.usageNearStale, true)
76
+ assert.equal(stale.source.usagePastStaleThreshold, true)
77
+
78
+ console.log('usage-freshness-e2e: ok (fresh -> aging -> post-threshold-in-grace -> stale)')
79
+ } finally {
80
+ rmSync(tempDir, { recursive: true, force: true })
81
+ }
package/skill/SKILL.md ADDED
@@ -0,0 +1,43 @@
1
+ ---
2
+ name: idlewatch
3
+ description: Collect host CPU/memory/GPU and token telemetry and stream to Firebase Firestore.
4
+ ---
5
+
6
+ # IdleWatch Skill
7
+
8
+ Install (npm package):
9
+
10
+ ```bash
11
+ npx idlewatch-skill --help
12
+ ```
13
+
14
+ Run collector:
15
+
16
+ ```bash
17
+ idlewatch-agent
18
+ ```
19
+
20
+ Dry-run once (no Firestore write):
21
+
22
+ ```bash
23
+ idlewatch-agent --dry-run
24
+ ```
25
+
26
+ Environment:
27
+
28
+ - `IDLEWATCH_HOST` optional custom host label
29
+ - `IDLEWATCH_INTERVAL_MS` sampling interval (default 10000)
30
+ - `IDLEWATCH_LOCAL_LOG_PATH` optional local NDJSON durability log path
31
+ - `IDLEWATCH_OPENCLAW_USAGE` usage lookup mode (`auto` or `off`)
32
+ - `FIREBASE_PROJECT_ID`
33
+ - `FIREBASE_SERVICE_ACCOUNT_JSON` (preferred)
34
+ - `FIREBASE_SERVICE_ACCOUNT_B64` (legacy)
35
+
36
+ Output fields:
37
+
38
+ - `cpuPct`
39
+ - `memPct`
40
+ - `gpuPct` (darwin best-effort)
41
+ - `tokensPerMin` (OpenClaw usage when available)
42
+ - `openclawModel`
43
+ - `openclawTotalTokens`
package/src/config.js ADDED
@@ -0,0 +1,100 @@
1
+ import os from 'os'
2
+ import path from 'path'
3
+
4
+ /**
5
+ * Parse and validate a numeric environment variable.
6
+ * Returns the parsed value or the default.
7
+ * Throws if the value is set but invalid per the constraint function.
8
+ */
9
+ function parseNumericEnv(envName, defaultValue, constraint = null) {
10
+ const raw = process.env[envName]
11
+ if (raw === undefined || raw === '') return defaultValue
12
+ const value = Number(raw)
13
+ if (constraint && !constraint(value)) {
14
+ throw new Error(`Invalid ${envName}: ${raw}`)
15
+ }
16
+ return value
17
+ }
18
+
19
+ const isPositiveFinite = (v) => Number.isFinite(v) && v > 0
20
+ const isNonNegFinite = (v) => Number.isFinite(v) && v >= 0
21
+ const isNonNegInt = (v) => Number.isInteger(v) && v >= 0
22
+ const isBool01 = (v) => v === 0 || v === 1
23
+
24
+ /**
25
+ * Build the full IdleWatch configuration from environment variables.
26
+ * Throws on invalid values.
27
+ */
28
+ export function buildConfig() {
29
+ const HOST = process.env.IDLEWATCH_HOST || os.hostname()
30
+ const SAFE_HOST = HOST.replace(/[^a-zA-Z0-9_.-]/g, '_')
31
+
32
+ const INTERVAL_MS = parseNumericEnv('IDLEWATCH_INTERVAL_MS', 10000, isPositiveFinite)
33
+ const OPENCLAW_PROBE_TIMEOUT_MS = parseNumericEnv('IDLEWATCH_OPENCLAW_PROBE_TIMEOUT_MS', 2500, isPositiveFinite)
34
+ const OPENCLAW_PROBE_RETRIES = parseNumericEnv('IDLEWATCH_OPENCLAW_PROBE_RETRIES', 1, isNonNegInt)
35
+
36
+ const USAGE_STALE_MS = parseNumericEnv(
37
+ 'IDLEWATCH_USAGE_STALE_MS',
38
+ Math.max(INTERVAL_MS * 3, 60000),
39
+ isPositiveFinite
40
+ )
41
+
42
+ const USAGE_STALE_GRACE_MS = parseNumericEnv(
43
+ 'IDLEWATCH_USAGE_STALE_GRACE_MS',
44
+ Math.min(INTERVAL_MS, 10000),
45
+ isNonNegFinite
46
+ )
47
+
48
+ const USAGE_NEAR_STALE_MS = parseNumericEnv(
49
+ 'IDLEWATCH_USAGE_NEAR_STALE_MS',
50
+ Math.floor((USAGE_STALE_MS + USAGE_STALE_GRACE_MS) * 0.85),
51
+ isNonNegFinite
52
+ )
53
+
54
+ const USAGE_REFRESH_REPROBES = parseNumericEnv('IDLEWATCH_USAGE_REFRESH_REPROBES', 1, isNonNegInt)
55
+ const USAGE_REFRESH_DELAY_MS = parseNumericEnv('IDLEWATCH_USAGE_REFRESH_DELAY_MS', 250, isNonNegFinite)
56
+ const USAGE_REFRESH_ON_NEAR_STALE = parseNumericEnv('IDLEWATCH_USAGE_REFRESH_ON_NEAR_STALE', 1, isBool01)
57
+ const USAGE_IDLE_AFTER_MS = parseNumericEnv('IDLEWATCH_USAGE_IDLE_AFTER_MS', 21600000, isPositiveFinite)
58
+
59
+ const OPENCLAW_LAST_GOOD_MAX_AGE_MS = process.env.IDLEWATCH_OPENCLAW_LAST_GOOD_MAX_AGE_MS
60
+ ? parseNumericEnv('IDLEWATCH_OPENCLAW_LAST_GOOD_MAX_AGE_MS', 0, isPositiveFinite)
61
+ : Math.max(USAGE_STALE_MS + USAGE_STALE_GRACE_MS, 120000)
62
+
63
+ const BASE_DIR = path.join(os.homedir(), '.idlewatch')
64
+
65
+ const LOCAL_LOG_PATH = process.env.IDLEWATCH_LOCAL_LOG_PATH
66
+ ? path.resolve(process.env.IDLEWATCH_LOCAL_LOG_PATH)
67
+ : path.join(BASE_DIR, 'logs', `${SAFE_HOST}-metrics.ndjson`)
68
+
69
+ const OPENCLAW_LAST_GOOD_CACHE_PATH = process.env.IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH
70
+ ? path.resolve(process.env.IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH)
71
+ : path.join(BASE_DIR, 'cache', `${SAFE_HOST}-openclaw-last-good.json`)
72
+
73
+ return Object.freeze({
74
+ HOST,
75
+ SAFE_HOST,
76
+ INTERVAL_MS,
77
+ OPENCLAW_USAGE_MODE: (process.env.IDLEWATCH_OPENCLAW_USAGE || 'auto').toLowerCase(),
78
+ OPENCLAW_BIN_STRICT: process.env.IDLEWATCH_OPENCLAW_BIN_STRICT === '1',
79
+ OPENCLAW_PROBE_TIMEOUT_MS,
80
+ OPENCLAW_PROBE_RETRIES,
81
+ USAGE_STALE_MS,
82
+ USAGE_STALE_GRACE_MS,
83
+ USAGE_NEAR_STALE_MS,
84
+ USAGE_REFRESH_REPROBES,
85
+ USAGE_REFRESH_DELAY_MS,
86
+ USAGE_REFRESH_ON_NEAR_STALE,
87
+ USAGE_IDLE_AFTER_MS,
88
+ OPENCLAW_LAST_GOOD_MAX_AGE_MS,
89
+ OPENCLAW_LAST_GOOD_CACHE_PATH,
90
+ LOCAL_LOG_PATH,
91
+ REQUIRE_FIREBASE_WRITES: process.env.IDLEWATCH_REQUIRE_FIREBASE_WRITES === '1',
92
+ FIREBASE: {
93
+ PROJECT_ID: process.env.FIREBASE_PROJECT_ID,
94
+ CREDS_FILE: process.env.FIREBASE_SERVICE_ACCOUNT_FILE,
95
+ CREDS_JSON: process.env.FIREBASE_SERVICE_ACCOUNT_JSON,
96
+ CREDS_B64: process.env.FIREBASE_SERVICE_ACCOUNT_B64,
97
+ EMULATOR_HOST: process.env.FIRESTORE_EMULATOR_HOST
98
+ }
99
+ })
100
+ }
@@ -0,0 +1,176 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import readline from 'node:readline/promises'
5
+ import process from 'node:process'
6
+ import { spawnSync } from 'node:child_process'
7
+
8
+ function defaultConfigDir() {
9
+ return path.join(os.homedir(), '.idlewatch')
10
+ }
11
+
12
+ function ensureDir(dirPath) {
13
+ fs.mkdirSync(dirPath, { recursive: true })
14
+ }
15
+
16
+ function writeSecureFile(filePath, content) {
17
+ ensureDir(path.dirname(filePath))
18
+ fs.writeFileSync(filePath, content, { encoding: 'utf8', mode: 0o600 })
19
+ try {
20
+ fs.chmodSync(filePath, 0o600)
21
+ } catch {
22
+ // best effort on filesystems that ignore chmod
23
+ }
24
+ }
25
+
26
+ const MONITOR_TARGET_CHOICES = ['cpu', 'memory', 'gpu', 'openclaw']
27
+
28
+ function commandExists(bin, args = ['--version']) {
29
+ const result = spawnSync(bin, args, { stdio: 'ignore' })
30
+ return result.status === 0
31
+ }
32
+
33
+ function detectAvailableMonitorTargets() {
34
+ const available = new Set(['cpu', 'memory'])
35
+
36
+ if (process.platform === 'darwin' || commandExists('nvidia-smi', ['--help'])) {
37
+ available.add('gpu')
38
+ }
39
+
40
+ if (commandExists('openclaw', ['--help'])) {
41
+ available.add('openclaw')
42
+ }
43
+
44
+ return [...available]
45
+ }
46
+
47
+ function normalizeMonitorTargets(raw, available) {
48
+ const fallback = ['cpu', 'memory', ...(available.includes('openclaw') ? ['openclaw'] : []), ...(available.includes('gpu') ? ['gpu'] : [])]
49
+ if (!raw) return fallback
50
+
51
+ const parsed = raw
52
+ .split(',')
53
+ .map((item) => item.trim().toLowerCase())
54
+ .filter(Boolean)
55
+ .filter((item) => MONITOR_TARGET_CHOICES.includes(item) && available.includes(item))
56
+
57
+ if (parsed.length === 0) return fallback
58
+ return [...new Set(parsed)]
59
+ }
60
+
61
+ function tryRustTui({ configDir, outputEnvFile }) {
62
+ const disabled = process.env.IDLEWATCH_DISABLE_RUST_TUI === '1'
63
+ if (disabled) return false
64
+
65
+ const cargoProbe = spawnSync('cargo', ['--version'], { stdio: 'ignore' })
66
+ if (cargoProbe.status !== 0) return false
67
+
68
+ const manifestPath = path.resolve(process.cwd(), 'tui', 'Cargo.toml')
69
+ if (!fs.existsSync(manifestPath)) return false
70
+
71
+ const run = spawnSync('cargo', ['run', '--quiet', '--manifest-path', manifestPath], {
72
+ stdio: 'inherit',
73
+ env: {
74
+ ...process.env,
75
+ IDLEWATCH_ENROLL_CONFIG_DIR: configDir,
76
+ IDLEWATCH_ENROLL_OUTPUT_ENV_FILE: outputEnvFile
77
+ }
78
+ })
79
+
80
+ if (run.status === 0) {
81
+ return true
82
+ }
83
+
84
+ return false
85
+ }
86
+
87
+ function promptModeText() {
88
+ return `\n╭───────────────────────────────────────────────╮\n│ IdleWatch Setup Wizard │\n╰───────────────────────────────────────────────╯\n\nChoose setup mode:\n 1) Managed cloud (recommended)\n Link this device with an API key from idlewatch.com/api\n 2) Local-only (no cloud writes)\n`
89
+ }
90
+
91
+ export async function runEnrollmentWizard(options = {}) {
92
+ const nonInteractive = options.nonInteractive || process.env.IDLEWATCH_ENROLL_NON_INTERACTIVE === '1'
93
+ const configDir = path.resolve(options.configDir || process.env.IDLEWATCH_ENROLL_CONFIG_DIR || defaultConfigDir())
94
+ const outputEnvFile = path.resolve(options.outputEnvFile || process.env.IDLEWATCH_ENROLL_OUTPUT_ENV_FILE || path.join(configDir, 'idlewatch.env'))
95
+
96
+ let mode = options.mode || process.env.IDLEWATCH_ENROLL_MODE || null
97
+ let cloudApiKey = options.cloudApiKey || process.env.IDLEWATCH_CLOUD_API_KEY || null
98
+ let cloudIngestUrl = options.cloudIngestUrl || process.env.IDLEWATCH_CLOUD_INGEST_URL || 'https://api.idlewatch.com/api/ingest'
99
+
100
+ const availableMonitorTargets = detectAvailableMonitorTargets()
101
+ let monitorTargets = normalizeMonitorTargets(
102
+ options.monitorTargets || process.env.IDLEWATCH_MONITOR_TARGETS || '',
103
+ availableMonitorTargets
104
+ )
105
+
106
+ if (!nonInteractive && tryRustTui({ configDir, outputEnvFile })) {
107
+ return {
108
+ mode: 'tui',
109
+ configDir,
110
+ outputEnvFile
111
+ }
112
+ }
113
+
114
+ let rl = null
115
+ if (!nonInteractive) {
116
+ rl = readline.createInterface({ input: process.stdin, output: process.stdout })
117
+ console.log(promptModeText())
118
+ console.log(`Storage path: ${configDir}`)
119
+ console.log(`Environment file: ${outputEnvFile}`)
120
+ const modeInput = (await rl.question('\nMode [1/2] (default 1): ')).trim() || '1'
121
+ mode = modeInput === '2' ? 'local' : 'production'
122
+ }
123
+
124
+ if (!mode) mode = 'production'
125
+ if (!['production', 'local'].includes(mode)) {
126
+ throw new Error(`Invalid enrollment mode: ${mode}`)
127
+ }
128
+
129
+ if ((mode === 'production') && !cloudApiKey) {
130
+ if (!rl) throw new Error('Missing cloud API key (IDLEWATCH_CLOUD_API_KEY).')
131
+ console.log('\nPaste the API key from idlewatch.com/api.')
132
+ cloudApiKey = (await rl.question('Cloud API key: ')).trim()
133
+ }
134
+
135
+ if (!nonInteractive && rl) {
136
+ console.log(`\nDetected monitor targets on this machine: ${availableMonitorTargets.join(', ')}`)
137
+ const suggested = monitorTargets.join(',')
138
+ const monitorInput = (await rl.question(`Monitor targets [${suggested}]: `)).trim()
139
+ monitorTargets = normalizeMonitorTargets(monitorInput || suggested, availableMonitorTargets)
140
+ }
141
+
142
+ const localLogPath = path.join(configDir, 'logs', `${os.hostname().replace(/[^a-zA-Z0-9_.-]/g, '_')}-metrics.ndjson`)
143
+ const localCachePath = path.join(configDir, 'cache', `${os.hostname().replace(/[^a-zA-Z0-9_.-]/g, '_')}-openclaw-last-good.json`)
144
+
145
+ const envLines = [
146
+ '# Generated by idlewatch-agent quickstart',
147
+ `IDLEWATCH_MONITOR_TARGETS=${monitorTargets.join(',')}`,
148
+ `IDLEWATCH_OPENCLAW_USAGE=${monitorTargets.includes('openclaw') ? 'auto' : 'off'}`,
149
+ `IDLEWATCH_LOCAL_LOG_PATH=${localLogPath}`,
150
+ `IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH=${localCachePath}`
151
+ ]
152
+
153
+ if (mode === 'local') {
154
+ envLines.push('# Local-only mode (no cloud/Firebase writes).')
155
+ }
156
+
157
+ if (mode === 'production') {
158
+ if (!cloudApiKey) {
159
+ throw new Error('Cloud API key is required for production mode.')
160
+ }
161
+ envLines.push(`IDLEWATCH_CLOUD_INGEST_URL=${cloudIngestUrl}`)
162
+ envLines.push(`IDLEWATCH_CLOUD_API_KEY=${cloudApiKey}`)
163
+ envLines.push('IDLEWATCH_REQUIRE_CLOUD_WRITES=1')
164
+ }
165
+
166
+ writeSecureFile(outputEnvFile, `${envLines.join('\n')}\n`)
167
+
168
+ if (rl) rl.close()
169
+
170
+ return {
171
+ mode,
172
+ configDir,
173
+ outputEnvFile,
174
+ monitorTargets
175
+ }
176
+ }