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
package/tui/Cargo.toml ADDED
@@ -0,0 +1,11 @@
1
+ [package]
2
+ name = "idlewatch-setup"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+
6
+ [dependencies]
7
+ anyhow = "1"
8
+ crossterm = "0.28"
9
+ ratatui = "0.29"
10
+ serde_json = "1"
11
+ dirs = "5"
@@ -0,0 +1,368 @@
1
+ use anyhow::{anyhow, Result};
2
+ use crossterm::{
3
+ event::{self, Event, KeyCode},
4
+ execute,
5
+ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
6
+ };
7
+ use ratatui::{
8
+ prelude::*,
9
+ widgets::{Block, Borders, List, ListItem, Paragraph},
10
+ };
11
+ use std::{
12
+ fs,
13
+ io::{self, Write},
14
+ path::{Path, PathBuf},
15
+ process::{Command, Stdio},
16
+ time::Duration,
17
+ };
18
+
19
+ #[derive(Clone)]
20
+ struct MonitorTarget {
21
+ key: &'static str,
22
+ label: &'static str,
23
+ available: bool,
24
+ selected: bool,
25
+ }
26
+
27
+ fn default_config_dir() -> PathBuf {
28
+ if let Ok(dir) = std::env::var("IDLEWATCH_ENROLL_CONFIG_DIR") {
29
+ return PathBuf::from(dir);
30
+ }
31
+ dirs::home_dir()
32
+ .unwrap_or_else(|| PathBuf::from("."))
33
+ .join(".idlewatch")
34
+ }
35
+
36
+ fn sanitize_host(host: &str) -> String {
37
+ host.chars()
38
+ .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') { c } else { '_' })
39
+ .collect()
40
+ }
41
+
42
+ fn write_secure_file(path: &Path, content: &str) -> Result<()> {
43
+ if let Some(parent) = path.parent() {
44
+ fs::create_dir_all(parent)?;
45
+ }
46
+ fs::write(path, content)?;
47
+ #[cfg(unix)]
48
+ {
49
+ use std::os::unix::fs::PermissionsExt;
50
+ fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
51
+ }
52
+ Ok(())
53
+ }
54
+
55
+ fn command_exists(cmd: &str, args: &[&str]) -> bool {
56
+ Command::new(cmd)
57
+ .args(args)
58
+ .stdout(Stdio::null())
59
+ .stderr(Stdio::null())
60
+ .status()
61
+ .map(|status| status.success())
62
+ .unwrap_or(false)
63
+ }
64
+
65
+ fn detect_monitor_targets() -> Vec<MonitorTarget> {
66
+ let openclaw_available = command_exists("openclaw", &["--help"]);
67
+ let gpu_available = cfg!(target_os = "macos") || command_exists("nvidia-smi", &["--help"]);
68
+
69
+ vec![
70
+ MonitorTarget {
71
+ key: "cpu",
72
+ label: "CPU usage",
73
+ available: true,
74
+ selected: true,
75
+ },
76
+ MonitorTarget {
77
+ key: "memory",
78
+ label: "Memory usage",
79
+ available: true,
80
+ selected: true,
81
+ },
82
+ MonitorTarget {
83
+ key: "gpu",
84
+ label: "GPU usage",
85
+ available: gpu_available,
86
+ selected: gpu_available,
87
+ },
88
+ MonitorTarget {
89
+ key: "openclaw",
90
+ label: "OpenClaw token telemetry",
91
+ available: openclaw_available,
92
+ selected: openclaw_available,
93
+ },
94
+ ]
95
+ }
96
+
97
+ fn render_mode_menu(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, selected: usize, cfg: &Path) -> Result<()> {
98
+ terminal.draw(|f| {
99
+ let chunks = Layout::default()
100
+ .direction(Direction::Vertical)
101
+ .margin(1)
102
+ .constraints([
103
+ Constraint::Length(4),
104
+ Constraint::Length(8),
105
+ Constraint::Length(3),
106
+ Constraint::Min(1),
107
+ ])
108
+ .split(f.area());
109
+
110
+ let title_block = Block::default()
111
+ .borders(Borders::ALL)
112
+ .title("IdleWatch")
113
+ .border_style(Style::default().fg(Color::Magenta));
114
+
115
+ let header = Paragraph::new("IdleWatch Setup")
116
+ .style(Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD))
117
+ .block(title_block);
118
+ f.render_widget(header, chunks[0]);
119
+
120
+ let items = [
121
+ "Managed cloud (recommended)",
122
+ "Local-only (no cloud writes)",
123
+ ]
124
+ .iter()
125
+ .enumerate()
126
+ .map(|(i, item)| {
127
+ if i == selected {
128
+ ListItem::new(format!("❯ {}", item)).style(
129
+ Style::default()
130
+ .fg(Color::Black)
131
+ .bg(Color::LightMagenta)
132
+ .add_modifier(Modifier::BOLD),
133
+ )
134
+ } else {
135
+ ListItem::new(format!(" {}", item)).style(Style::default().fg(Color::Cyan))
136
+ }
137
+ })
138
+ .collect::<Vec<_>>();
139
+
140
+ let list = List::new(items).block(
141
+ Block::default()
142
+ .borders(Borders::ALL)
143
+ .title("Mode")
144
+ .border_style(Style::default().fg(Color::Cyan)),
145
+ );
146
+ f.render_widget(list, chunks[1]);
147
+
148
+ let path = Paragraph::new(format!("Config dir: {}", cfg.display()))
149
+ .style(Style::default().fg(Color::White))
150
+ .block(
151
+ Block::default()
152
+ .borders(Borders::ALL)
153
+ .title("Storage")
154
+ .border_style(Style::default().fg(Color::Cyan)),
155
+ );
156
+ f.render_widget(path, chunks[2]);
157
+
158
+ let help = Paragraph::new("↑/↓ move • Enter select • q quit")
159
+ .style(Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD));
160
+ f.render_widget(help, chunks[3]);
161
+ })?;
162
+ Ok(())
163
+ }
164
+
165
+ fn render_monitor_menu(
166
+ terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
167
+ cursor: usize,
168
+ monitors: &[MonitorTarget],
169
+ ) -> Result<()> {
170
+ terminal.draw(|f| {
171
+ let chunks = Layout::default()
172
+ .direction(Direction::Vertical)
173
+ .margin(1)
174
+ .constraints([
175
+ Constraint::Length(4),
176
+ Constraint::Length(10),
177
+ Constraint::Min(1),
178
+ ])
179
+ .split(f.area());
180
+
181
+ let title = Paragraph::new("Choose what to monitor")
182
+ .style(Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD))
183
+ .block(
184
+ Block::default()
185
+ .borders(Borders::ALL)
186
+ .title("Monitor Targets")
187
+ .border_style(Style::default().fg(Color::Magenta)),
188
+ );
189
+ f.render_widget(title, chunks[0]);
190
+
191
+ let items = monitors
192
+ .iter()
193
+ .enumerate()
194
+ .map(|(idx, target)| {
195
+ let marker = if target.selected { "[x]" } else { "[ ]" };
196
+ let unavailable = if target.available { "" } else { " (not detected)" };
197
+ let line = format!("{} {}{}", marker, target.label, unavailable);
198
+
199
+ if idx == cursor {
200
+ ListItem::new(format!("❯ {}", line)).style(
201
+ Style::default()
202
+ .fg(Color::Black)
203
+ .bg(Color::LightMagenta)
204
+ .add_modifier(Modifier::BOLD),
205
+ )
206
+ } else if target.available {
207
+ ListItem::new(format!(" {}", line)).style(Style::default().fg(Color::Cyan))
208
+ } else {
209
+ ListItem::new(format!(" {}", line)).style(Style::default().fg(Color::DarkGray))
210
+ }
211
+ })
212
+ .collect::<Vec<_>>();
213
+
214
+ let list = List::new(items).block(
215
+ Block::default()
216
+ .borders(Borders::ALL)
217
+ .title("Targets")
218
+ .border_style(Style::default().fg(Color::Cyan)),
219
+ );
220
+ f.render_widget(list, chunks[1]);
221
+
222
+ let help = Paragraph::new("↑/↓ move • Space toggle • Enter continue")
223
+ .style(Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD));
224
+ f.render_widget(help, chunks[2]);
225
+ })?;
226
+
227
+ Ok(())
228
+ }
229
+
230
+ fn read_line(prompt: &str) -> Result<String> {
231
+ print!("{}", prompt);
232
+ io::stdout().flush()?;
233
+ let mut s = String::new();
234
+ io::stdin().read_line(&mut s)?;
235
+ Ok(s.trim().to_string())
236
+ }
237
+
238
+ fn main() -> Result<()> {
239
+ let config_dir = default_config_dir();
240
+ let env_file = std::env::var("IDLEWATCH_ENROLL_OUTPUT_ENV_FILE")
241
+ .map(PathBuf::from)
242
+ .unwrap_or_else(|_| config_dir.join("idlewatch.env"));
243
+ let host = sanitize_host(&std::env::var("HOSTNAME").unwrap_or_else(|_| "host".to_string()));
244
+
245
+ enable_raw_mode()?;
246
+ let mut stdout = io::stdout();
247
+ execute!(stdout, EnterAlternateScreen)?;
248
+ let backend = CrosstermBackend::new(stdout);
249
+ let mut terminal = Terminal::new(backend)?;
250
+
251
+ let mut selected_mode = 0usize;
252
+ loop {
253
+ render_mode_menu(&mut terminal, selected_mode, &config_dir)?;
254
+ if event::poll(Duration::from_millis(250))? {
255
+ if let Event::Key(key) = event::read()? {
256
+ match key.code {
257
+ KeyCode::Up => selected_mode = selected_mode.saturating_sub(1),
258
+ KeyCode::Down => selected_mode = (selected_mode + 1).min(1),
259
+ KeyCode::Enter => break,
260
+ KeyCode::Char('q') | KeyCode::Esc => {
261
+ disable_raw_mode()?;
262
+ execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
263
+ return Ok(());
264
+ }
265
+ _ => {}
266
+ }
267
+ }
268
+ }
269
+ }
270
+
271
+ let mut monitor_targets = detect_monitor_targets();
272
+ let mut monitor_cursor = 0usize;
273
+
274
+ loop {
275
+ render_monitor_menu(&mut terminal, monitor_cursor, &monitor_targets)?;
276
+ if event::poll(Duration::from_millis(250))? {
277
+ if let Event::Key(key) = event::read()? {
278
+ match key.code {
279
+ KeyCode::Up => monitor_cursor = monitor_cursor.saturating_sub(1),
280
+ KeyCode::Down => monitor_cursor = (monitor_cursor + 1).min(monitor_targets.len().saturating_sub(1)),
281
+ KeyCode::Char(' ') => {
282
+ if let Some(item) = monitor_targets.get_mut(monitor_cursor) {
283
+ if item.available {
284
+ item.selected = !item.selected;
285
+ }
286
+ }
287
+ }
288
+ KeyCode::Enter => {
289
+ let selected_count = monitor_targets.iter().filter(|item| item.selected && item.available).count();
290
+ if selected_count == 0 {
291
+ for item in monitor_targets.iter_mut() {
292
+ if item.key == "cpu" || item.key == "memory" {
293
+ item.selected = true;
294
+ }
295
+ }
296
+ }
297
+ break;
298
+ }
299
+ KeyCode::Char('q') | KeyCode::Esc => {
300
+ disable_raw_mode()?;
301
+ execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
302
+ return Ok(());
303
+ }
304
+ _ => {}
305
+ }
306
+ }
307
+ }
308
+ }
309
+
310
+ disable_raw_mode()?;
311
+ execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
312
+
313
+ let mode = match selected_mode {
314
+ 0 => "production",
315
+ _ => "local",
316
+ };
317
+
318
+ let selected_keys = monitor_targets
319
+ .iter()
320
+ .filter(|item| item.selected && item.available)
321
+ .map(|item| item.key)
322
+ .collect::<Vec<_>>();
323
+
324
+ let monitor_targets_csv = if selected_keys.is_empty() {
325
+ "cpu,memory".to_string()
326
+ } else {
327
+ selected_keys.join(",")
328
+ };
329
+
330
+ let monitor_openclaw = monitor_targets_csv.split(',').any(|item| item == "openclaw");
331
+
332
+ let mut env_lines = vec![
333
+ "# Generated by idlewatch-agent quickstart (Rust TUI)".to_string(),
334
+ format!("IDLEWATCH_MONITOR_TARGETS={}", monitor_targets_csv),
335
+ format!("IDLEWATCH_OPENCLAW_USAGE={}", if monitor_openclaw { "auto" } else { "off" }),
336
+ format!(
337
+ "IDLEWATCH_LOCAL_LOG_PATH={}",
338
+ config_dir.join("logs").join(format!("{}-metrics.ndjson", host)).display()
339
+ ),
340
+ format!(
341
+ "IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH={}",
342
+ config_dir
343
+ .join("cache")
344
+ .join(format!("{}-openclaw-last-good.json", host))
345
+ .display()
346
+ ),
347
+ ];
348
+
349
+ if mode == "local" {
350
+ env_lines.push("# Local-only mode (no cloud/Firebase writes).".to_string());
351
+ }
352
+
353
+ if mode == "production" {
354
+ let api_key = read_line("Cloud API key (from idlewatch.com/api): ")?;
355
+ if api_key.trim().is_empty() {
356
+ return Err(anyhow!("cloud API key is required"));
357
+ }
358
+ env_lines.push("IDLEWATCH_CLOUD_INGEST_URL=https://api.idlewatch.com/api/ingest".to_string());
359
+ env_lines.push(format!("IDLEWATCH_CLOUD_API_KEY={}", api_key.trim()));
360
+ env_lines.push("IDLEWATCH_REQUIRE_CLOUD_WRITES=1".to_string());
361
+ }
362
+
363
+ write_secure_file(&env_file, &format!("{}\n", env_lines.join("\n")))?;
364
+ println!("Enrollment complete. Mode={} envFile={}", mode, env_file.display());
365
+ println!("Next step: set -a; source \"{}\"; set +a", env_file.display());
366
+ println!("Then run: idlewatch-agent --once");
367
+ Ok(())
368
+ }