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.
- package/.env.example +73 -0
- package/.github/workflows/ci.yml +99 -0
- package/.github/workflows/release-macos-trusted.yml +103 -0
- package/README.md +336 -0
- package/bin/idlewatch-agent.js +1053 -0
- package/docs/onboarding-external.md +58 -0
- package/docs/packaging/macos-dmg.md +199 -0
- package/docs/packaging/macos-launch-agent.md +70 -0
- package/docs/qa/archive/mac-qa-log-2026-02-17.md +5838 -0
- package/docs/qa/mac-qa-log.md +2864 -0
- package/docs/telemetry/idle-stale-policy.md +57 -0
- package/docs/telemetry/openclaw-mapping.md +80 -0
- package/package.json +76 -0
- package/scripts/build-dmg.sh +65 -0
- package/scripts/install-macos-launch-agent.sh +78 -0
- package/scripts/lib/telemetry-row-parser.mjs +100 -0
- package/scripts/package-macos.sh +228 -0
- package/scripts/uninstall-macos-launch-agent.sh +30 -0
- package/scripts/validate-all.sh +142 -0
- package/scripts/validate-bin.mjs +25 -0
- package/scripts/validate-dmg-checksum.sh +37 -0
- package/scripts/validate-dmg-install.sh +155 -0
- package/scripts/validate-dry-run-schema.mjs +257 -0
- package/scripts/validate-onboarding.mjs +63 -0
- package/scripts/validate-openclaw-cache-recovery-e2e.mjs +113 -0
- package/scripts/validate-openclaw-release-gates.mjs +51 -0
- package/scripts/validate-openclaw-stats-ingestion.mjs +372 -0
- package/scripts/validate-openclaw-usage-health.mjs +95 -0
- package/scripts/validate-packaged-artifact.mjs +233 -0
- package/scripts/validate-packaged-bundled-runtime.sh +191 -0
- package/scripts/validate-packaged-metadata.sh +43 -0
- package/scripts/validate-packaged-openclaw-cache-recovery-e2e.mjs +153 -0
- package/scripts/validate-packaged-openclaw-release-gates.mjs +72 -0
- package/scripts/validate-packaged-openclaw-stats-ingestion.mjs +402 -0
- package/scripts/validate-packaged-sourcemaps.mjs +82 -0
- package/scripts/validate-packaged-usage-alert-rate-e2e.mjs +98 -0
- package/scripts/validate-packaged-usage-probe-noise-e2e.mjs +87 -0
- package/scripts/validate-packaged-usage-recovery-e2e.mjs +90 -0
- package/scripts/validate-trusted-prereqs.sh +44 -0
- package/scripts/validate-usage-alert-rate-e2e.mjs +91 -0
- package/scripts/validate-usage-freshness-e2e.mjs +81 -0
- package/skill/SKILL.md +43 -0
- package/src/config.js +100 -0
- package/src/enrollment.js +176 -0
- package/src/gpu.js +115 -0
- package/src/memory.js +67 -0
- package/src/openclaw-cache.js +51 -0
- package/src/openclaw-usage.js +1020 -0
- package/src/telemetry-mapping.js +54 -0
- package/src/usage-alert.js +41 -0
- package/src/usage-freshness.js +31 -0
- package/test/config.test.mjs +112 -0
- package/test/fixtures/gpu-agx.txt +2 -0
- package/test/fixtures/gpu-iogpu.txt +2 -0
- package/test/fixtures/gpu-top-grep.txt +2 -0
- package/test/fixtures/openclaw-fleet-sample-v1.json +68 -0
- package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-iso-ts.txt +2 -0
- package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-newest.txt +2 -0
- package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-string-ts.txt +2 -0
- package/test/fixtures/openclaw-mixed-status-then-generic-output.txt +2 -0
- package/test/fixtures/openclaw-stats-current-wrapper.json +12 -0
- package/test/fixtures/openclaw-stats-current-wrapper2.json +15 -0
- package/test/fixtures/openclaw-stats-data-wrapper.json +21 -0
- package/test/fixtures/openclaw-stats-nested-session-wrapper.json +23 -0
- package/test/fixtures/openclaw-stats-payload-wrapper.json +1 -0
- package/test/fixtures/openclaw-stats-status-current-wrapper.json +19 -0
- package/test/fixtures/openclaw-stats.json +17 -0
- package/test/fixtures/openclaw-status-ansi-complex-noise.txt +3 -0
- package/test/fixtures/openclaw-status-ansi-noise.txt +2 -0
- package/test/fixtures/openclaw-status-control-noise.txt +1 -0
- package/test/fixtures/openclaw-status-data-wrapper.json +20 -0
- package/test/fixtures/openclaw-status-dcs-noise.txt +1 -0
- package/test/fixtures/openclaw-status-epoch-seconds.json +15 -0
- package/test/fixtures/openclaw-status-mixed-noise.txt +1 -0
- package/test/fixtures/openclaw-status-multi-json.txt +3 -0
- package/test/fixtures/openclaw-status-nested-recent.json +19 -0
- package/test/fixtures/openclaw-status-noisy-default-then-usage.txt +2 -0
- package/test/fixtures/openclaw-status-noisy.txt +3 -0
- package/test/fixtures/openclaw-status-osc-noise.txt +1 -0
- package/test/fixtures/openclaw-status-result-session.json +15 -0
- package/test/fixtures/openclaw-status-session-map-with-defaults.json +23 -0
- package/test/fixtures/openclaw-status-session-map.json +28 -0
- package/test/fixtures/openclaw-status-session-model-name.json +18 -0
- package/test/fixtures/openclaw-status-snake-session-wrapper.json +13 -0
- package/test/fixtures/openclaw-status-stats-current-sessions-snake-tokens.json +25 -0
- package/test/fixtures/openclaw-status-stats-current-sessions.json +28 -0
- package/test/fixtures/openclaw-status-stats-current-usage-time-camelcase.json +19 -0
- package/test/fixtures/openclaw-status-stats-session-default-model.json +27 -0
- package/test/fixtures/openclaw-status-status-wrapper.json +13 -0
- package/test/fixtures/openclaw-status-strings.json +38 -0
- package/test/fixtures/openclaw-status-ts-ms-alias.json +14 -0
- package/test/fixtures/openclaw-status-updated-at-ms-alias.json +14 -0
- package/test/fixtures/openclaw-status-usage-timestamp-ms-alias.json +14 -0
- package/test/fixtures/openclaw-status-usage-ts-alias.json +14 -0
- package/test/fixtures/openclaw-status-wrap-session-object.json +24 -0
- package/test/fixtures/openclaw-status.json +41 -0
- package/test/fixtures/openclaw-usage-model-name-generic.json +9 -0
- package/test/gpu.test.mjs +58 -0
- package/test/memory.test.mjs +35 -0
- package/test/openclaw-cache.test.mjs +48 -0
- package/test/openclaw-env.test.mjs +365 -0
- package/test/openclaw-usage.test.mjs +555 -0
- package/test/telemetry-mapping.test.mjs +69 -0
- package/test/telemetry-row-parser.test.mjs +44 -0
- package/test/usage-alert.test.mjs +73 -0
- package/test/usage-freshness.test.mjs +63 -0
- package/test/validate-dry-run-schema.test.mjs +146 -0
- package/tui/Cargo.lock +801 -0
- package/tui/Cargo.toml +11 -0
- 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
|
+
}
|