@nocville/npc 0.2.1 → 0.2.3

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 (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -120
  3. package/dist/cjs/cli.js +55 -34
  4. package/dist/cjs/core/base-npc.js +8 -0
  5. package/dist/cjs/core/config.js +19 -2
  6. package/dist/cjs/core/errors.js +23 -0
  7. package/dist/cjs/core/http.js +121 -0
  8. package/dist/cjs/core/pixel-art.js +110 -0
  9. package/dist/cjs/core/polling-npc.js +99 -14
  10. package/dist/cjs/core/session-filter.js +3 -1
  11. package/dist/cjs/core/text.js +13 -0
  12. package/dist/cjs/core/thresholds.js +29 -0
  13. package/dist/cjs/index.js +10 -2
  14. package/dist/cjs/npcs/agent-common/session.js +1 -0
  15. package/dist/cjs/npcs/github-watcher/defaults.js +7 -1
  16. package/dist/cjs/npcs/github-watcher/github-api.js +82 -45
  17. package/dist/cjs/npcs/github-watcher/index.js +82 -36
  18. package/dist/cjs/npcs/internet-connectivity/checks.js +70 -67
  19. package/dist/cjs/npcs/internet-connectivity/defaults.js +5 -2
  20. package/dist/cjs/npcs/internet-connectivity/index.js +134 -131
  21. package/dist/cjs/npcs/log-watcher/buffer.js +61 -0
  22. package/dist/cjs/npcs/log-watcher/index.js +109 -103
  23. package/dist/cjs/npcs/log-watcher/tail.js +100 -53
  24. package/dist/cjs/npcs/process-watcher/index.js +61 -51
  25. package/dist/cjs/npcs/process-watcher/process-scanner.js +63 -69
  26. package/dist/cjs/npcs/receptionist/defaults.js +4 -0
  27. package/dist/cjs/npcs/receptionist/index.js +45 -19
  28. package/dist/cjs/npcs/receptionist/weather.js +42 -47
  29. package/dist/cjs/npcs/system-monitor/collectors.js +66 -17
  30. package/dist/cjs/npcs/system-monitor/index.js +52 -28
  31. package/dist/cjs/npcs/web-page-watcher/basic-mode.js +30 -14
  32. package/dist/cjs/npcs/web-page-watcher/capture.js +23 -1
  33. package/dist/cjs/npcs/web-page-watcher/defaults.js +44 -2
  34. package/dist/cjs/npcs/web-page-watcher/index.js +119 -68
  35. package/dist/cjs/npcs/web-page-watcher/pixelate.js +2 -1
  36. package/dist/cli.js +56 -35
  37. package/dist/core/base-npc.d.ts +5 -0
  38. package/dist/core/base-npc.js +8 -0
  39. package/dist/core/config.d.ts +5 -0
  40. package/dist/core/config.js +18 -2
  41. package/dist/core/errors.d.ts +11 -0
  42. package/dist/core/errors.js +18 -0
  43. package/dist/core/http.d.ts +52 -0
  44. package/dist/core/http.js +111 -0
  45. package/dist/core/pixel-art.d.ts +38 -0
  46. package/dist/core/pixel-art.js +105 -0
  47. package/dist/core/polling-npc.d.ts +22 -1
  48. package/dist/core/polling-npc.js +98 -13
  49. package/dist/core/session-filter.d.ts +2 -2
  50. package/dist/core/session-filter.js +3 -1
  51. package/dist/core/text.d.ts +6 -0
  52. package/dist/core/text.js +12 -0
  53. package/dist/core/thresholds.d.ts +9 -1
  54. package/dist/core/thresholds.js +27 -0
  55. package/dist/index.d.ts +5 -2
  56. package/dist/index.js +4 -2
  57. package/dist/npcs/agent-common/session.js +1 -0
  58. package/dist/npcs/github-watcher/defaults.d.ts +6 -0
  59. package/dist/npcs/github-watcher/defaults.js +7 -1
  60. package/dist/npcs/github-watcher/github-api.d.ts +21 -7
  61. package/dist/npcs/github-watcher/github-api.js +80 -45
  62. package/dist/npcs/github-watcher/index.d.ts +6 -0
  63. package/dist/npcs/github-watcher/index.js +83 -37
  64. package/dist/npcs/internet-connectivity/checks.d.ts +15 -18
  65. package/dist/npcs/internet-connectivity/checks.js +70 -67
  66. package/dist/npcs/internet-connectivity/defaults.d.ts +3 -0
  67. package/dist/npcs/internet-connectivity/defaults.js +5 -2
  68. package/dist/npcs/internet-connectivity/index.d.ts +19 -6
  69. package/dist/npcs/internet-connectivity/index.js +135 -133
  70. package/dist/npcs/log-watcher/buffer.d.ts +22 -0
  71. package/dist/npcs/log-watcher/buffer.js +58 -0
  72. package/dist/npcs/log-watcher/index.d.ts +22 -21
  73. package/dist/npcs/log-watcher/index.js +112 -106
  74. package/dist/npcs/log-watcher/tail.d.ts +23 -3
  75. package/dist/npcs/log-watcher/tail.js +101 -54
  76. package/dist/npcs/process-watcher/index.d.ts +1 -1
  77. package/dist/npcs/process-watcher/index.js +62 -52
  78. package/dist/npcs/process-watcher/process-scanner.d.ts +29 -1
  79. package/dist/npcs/process-watcher/process-scanner.js +61 -71
  80. package/dist/npcs/receptionist/defaults.d.ts +4 -0
  81. package/dist/npcs/receptionist/defaults.js +4 -0
  82. package/dist/npcs/receptionist/index.d.ts +12 -2
  83. package/dist/npcs/receptionist/index.js +46 -20
  84. package/dist/npcs/receptionist/weather.d.ts +8 -6
  85. package/dist/npcs/receptionist/weather.js +39 -45
  86. package/dist/npcs/system-monitor/collectors.d.ts +15 -3
  87. package/dist/npcs/system-monitor/collectors.js +64 -17
  88. package/dist/npcs/system-monitor/defaults.d.ts +1 -1
  89. package/dist/npcs/system-monitor/index.d.ts +2 -1
  90. package/dist/npcs/system-monitor/index.js +54 -30
  91. package/dist/npcs/web-page-watcher/basic-mode.d.ts +2 -2
  92. package/dist/npcs/web-page-watcher/basic-mode.js +30 -14
  93. package/dist/npcs/web-page-watcher/capture.d.ts +18 -0
  94. package/dist/npcs/web-page-watcher/capture.js +21 -1
  95. package/dist/npcs/web-page-watcher/defaults.d.ts +23 -0
  96. package/dist/npcs/web-page-watcher/defaults.js +41 -1
  97. package/dist/npcs/web-page-watcher/index.d.ts +18 -1
  98. package/dist/npcs/web-page-watcher/index.js +121 -70
  99. package/dist/npcs/web-page-watcher/pixelate.js +2 -1
  100. package/package.json +2 -7
@@ -1,6 +1,7 @@
1
1
  import { PollingNPC } from '../../core/polling-npc.js';
2
- import { worstStatus } from '../../core/thresholds.js';
3
- import { pushHistory } from '../../core/screen.js';
2
+ import { evaluateThreshold, worstStatus, moodForStatus } from '../../core/thresholds.js';
3
+ import { statusColor, pushHistory } from '../../core/screen.js';
4
+ import { NpcConfigError, assertPosix } from '../../core/errors.js';
4
5
  import { scanProcesses, detectTransitions } from './process-scanner.js';
5
6
  import { DEFAULTS } from './defaults.js';
6
7
  /** Max per-process panels on the companion dashboard. */
@@ -12,14 +13,17 @@ export class ProcessWatcherNPC extends PollingNPC {
12
13
  cpuHistories = new Map();
13
14
  previousRunning = null;
14
15
  constructor(config) {
15
- if (process.platform === 'win32') {
16
- throw new Error('process-watcher is not supported on Windows (relies on the POSIX `ps` command). ' +
17
- 'Run it under WSL/Linux, or use system-monitor instead.');
18
- }
16
+ assertPosix('process-watcher', 'It relies on the POSIX `ps` command; run it under WSL/Linux, or use system-monitor instead.');
19
17
  super(config);
20
- this.processes = config.processes;
18
+ this.processes = [...new Set(config.processes.map((p) => p.trim()).filter(Boolean))];
19
+ if (this.processes.length === 0) {
20
+ throw new NpcConfigError('process-watcher needs at least one process name (--processes)');
21
+ }
21
22
  this.cpuWarn = config.cpuWarn ?? DEFAULTS.cpuWarn;
22
23
  this.memWarn = config.memWarn ?? DEFAULTS.memWarn;
24
+ if (!(this.cpuWarn > 0) || !(this.memWarn > 0)) {
25
+ throw new NpcConfigError('--cpu-warn and --mem-warn must be positive numbers');
26
+ }
23
27
  }
24
28
  get name() {
25
29
  return 'process-watcher';
@@ -30,9 +34,6 @@ export class ProcessWatcherNPC extends PollingNPC {
30
34
  get archetype() {
31
35
  return 'service';
32
36
  }
33
- get heartbeatSeconds() {
34
- return 30;
35
- }
36
37
  get companion() {
37
38
  return {
38
39
  screenType: 'dashboard-wall',
@@ -42,22 +43,28 @@ export class ProcessWatcherNPC extends PollingNPC {
42
43
  };
43
44
  }
44
45
  async collect() {
45
- const infos = await scanProcesses(this.processes);
46
+ let infos;
47
+ try {
48
+ infos = await scanProcesses(this.processes);
49
+ }
50
+ catch (err) {
51
+ // Not knowing is different from "everything died": report unknown and
52
+ // leave the transition baseline alone so recovery doesn't say "is back".
53
+ return this.scanFailed(err);
54
+ }
46
55
  const statuses = [];
47
- const messageParts = [];
56
+ const problems = [];
48
57
  const metrics = {};
49
58
  const panels = [];
50
59
  let totalRunning = 0;
51
- let totalMissing = 0;
52
60
  for (const info of infos) {
53
61
  const safeKey = info.name.replace(/[^a-zA-Z0-9_]/g, '_');
54
62
  if (!info.running) {
55
63
  statuses.push('critical');
56
- messageParts.push(`${info.name}: NOT FOUND`);
64
+ problems.push(`${info.name} DOWN`);
57
65
  metrics[`${safeKey}_running`] = 0;
58
66
  metrics[`${safeKey}_cpu`] = 0;
59
67
  metrics[`${safeKey}_mem_mb`] = 0;
60
- totalMissing++;
61
68
  if (panels.length < MAX_PANELS) {
62
69
  panels.push({ label: info.name, value: 'DOWN', color: 'red' });
63
70
  }
@@ -65,72 +72,75 @@ export class ProcessWatcherNPC extends PollingNPC {
65
72
  }
66
73
  totalRunning++;
67
74
  const cpu = info.cpuPercent ?? 0;
75
+ const memMb = info.memMb ?? 0;
68
76
  metrics[`${safeKey}_running`] = 1;
69
77
  metrics[`${safeKey}_cpu`] = cpu;
70
- metrics[`${safeKey}_mem_mb`] = info.memMb ?? 0;
78
+ metrics[`${safeKey}_mem_mb`] = memMb;
71
79
  const history = this.cpuHistories.get(info.name) ?? [];
72
80
  pushHistory(history, cpu);
73
81
  this.cpuHistories.set(info.name, history);
74
- const cpuHigh = cpu >= this.cpuWarn;
75
- const memHigh = (info.memMb ?? 0) >= this.memWarn;
76
- if (cpuHigh || memHigh) {
77
- statuses.push('warning');
78
- const warnings = [];
79
- if (cpuHigh)
80
- warnings.push(`cpu=${cpu}%`);
81
- if (memHigh)
82
- warnings.push(`mem=${info.memMb}MB`);
83
- messageParts.push(`${info.name}: WARN (${warnings.join(', ')})`);
84
- }
85
- else {
86
- statuses.push('healthy');
87
- messageParts.push(`${info.name}: OK`);
82
+ // Single warning thresholds: no critical level for resource use.
83
+ const cpuStatus = evaluateThreshold(cpu, { warning: this.cpuWarn, critical: Infinity });
84
+ const memStatus = evaluateThreshold(memMb, { warning: this.memWarn, critical: Infinity });
85
+ const status = worstStatus([cpuStatus, memStatus]);
86
+ statuses.push(status);
87
+ if (status !== 'healthy') {
88
+ const high = [];
89
+ if (cpuStatus !== 'healthy')
90
+ high.push(`cpu ${cpu}%`);
91
+ if (memStatus !== 'healthy')
92
+ high.push(`mem ${Math.round(memMb)}MB`);
93
+ problems.push(`${info.name} high ${high.join(', ')}`);
88
94
  }
89
95
  if (panels.length < MAX_PANELS) {
90
96
  panels.push({
91
97
  label: info.name,
92
98
  value: `${cpu}%`,
93
- color: cpuHigh || memHigh ? 'amber' : 'green',
99
+ color: statusColor(status),
94
100
  sparkline: [...history],
95
101
  });
96
102
  }
97
103
  }
104
+ const totalMissing = infos.length - totalRunning;
98
105
  metrics['total_watched'] = this.processes.length;
99
106
  metrics['total_running'] = totalRunning;
100
107
  metrics['total_missing'] = totalMissing;
101
108
  // Transition speech: compare the running set to last poll's. On the first
102
109
  // poll we only seed the set (no bogus "is back" for already-running procs).
103
110
  const currentRunning = new Set(infos.filter((i) => i.running).map((i) => i.name));
104
- let transitions = [];
105
- if (this.previousRunning !== null) {
106
- transitions = detectTransitions(this.previousRunning, currentRunning);
107
- }
111
+ const transitions = this.previousRunning === null ? [] : detectTransitions(this.previousRunning, currentRunning);
108
112
  this.previousRunning = currentRunning;
109
113
  const status = worstStatus(statuses);
110
- const mood = deriveMood(status);
111
- // A death message takes over the bubble this cycle so it reads as an alert.
112
- const message = transitions.length > 0 ? transitions.join(' · ') : messageParts.join(' | ');
113
- const screenData = { layout: 'multi-metric', values: panels };
114
+ const summary = problems.length === 0
115
+ ? `All ${infos.length} running`
116
+ : `${totalRunning}/${infos.length} running · ${problems.join(' · ')}`;
117
+ // A death/return takes over the bubble this cycle so it reads as an alert.
118
+ const message = transitions.length > 0 ? transitions.join(' · ') : summary;
114
119
  return {
115
120
  status,
116
- mood,
121
+ mood: moodForStatus(status),
117
122
  message,
118
123
  subStatus: totalMissing > 0 ? 'process-down' : undefined,
119
- screenData,
124
+ screenData: { layout: 'multi-metric', values: panels },
120
125
  metrics,
121
126
  };
122
127
  }
123
- }
124
- function deriveMood(status) {
125
- switch (status) {
126
- case 'healthy':
127
- return 'happy';
128
- case 'warning':
129
- return 'concerned';
130
- case 'critical':
131
- return 'stressed';
132
- default:
133
- return 'idle';
128
+ scanFailed(err) {
129
+ const reason = err instanceof Error ? err.message : String(err);
130
+ this.logger.warn('Cannot list processes', { error: reason });
131
+ return {
132
+ status: 'unknown',
133
+ mood: moodForStatus('unknown'),
134
+ message: 'Cannot list processes (ps failed)',
135
+ subStatus: 'scan-failed',
136
+ screenData: {
137
+ layout: 'multi-metric',
138
+ values: this.processes
139
+ .slice(0, MAX_PANELS)
140
+ .map((name) => ({ label: name, value: '?', color: 'gray' })),
141
+ },
142
+ metrics: { total_watched: this.processes.length },
143
+ };
134
144
  }
135
145
  }
136
146
  //# sourceMappingURL=index.js.map
@@ -2,6 +2,8 @@ export interface ProcessInfo {
2
2
  name: string;
3
3
  running: boolean;
4
4
  pid?: number;
5
+ /** Instances matched (all are summed into cpu/mem). */
6
+ count?: number;
5
7
  cpuPercent?: number;
6
8
  memMb?: number;
7
9
  }
@@ -11,9 +13,35 @@ export interface ProcessInfo {
11
13
  * "<name> is back" when one returns.
12
14
  */
13
15
  export declare function detectTransitions(prev: Set<string>, curr: Set<string>): string[];
16
+ export interface PsEntry {
17
+ pid: number;
18
+ cpu: number;
19
+ memKb: number;
20
+ /** Executable name (Linux) or path (macOS); may contain spaces. */
21
+ comm: string;
22
+ }
23
+ /**
24
+ * Parse header-less `ps -eo pid=,pcpu=,rss=,comm=` output. COMMAND is the rest
25
+ * of the line, so macOS paths with spaces ("Google Chrome") survive intact.
26
+ */
27
+ export declare function parsePsOutput(stdout: string): PsEntry[];
28
+ /**
29
+ * Does a ps COMMAND entry match a watched name? Case-insensitive on the
30
+ * executable's basename:
31
+ * - exact: `nginx` matches `/usr/sbin/nginx`;
32
+ * - version suffix: `python` matches `python3` and `python3.11`;
33
+ * - Linux truncation: a name longer than 15 chars matches its 15-char prefix;
34
+ * - a name containing `/` must equal the full path (macOS reports paths).
35
+ * Unlike a substring match, `sh` does not match `bash` or `ssh-agent`.
36
+ */
37
+ export declare function matchesName(comm: string, searchName: string): boolean;
38
+ /** List every process. Throws if `ps` fails — callers must not read that as "all dead". */
39
+ export declare function listProcesses(): Promise<PsEntry[]>;
40
+ /** Summarise each watched name against a process list (CPU and memory summed per name). */
41
+ export declare function matchProcesses(names: string[], entries: PsEntry[]): ProcessInfo[];
14
42
  /**
15
43
  * Scan for processes by name and return their status and resource usage.
16
- * Works on macOS and Linux by using `ps` to list all processes.
44
+ * Works on macOS and Linux via `ps`. Throws when the process list is unavailable.
17
45
  */
18
46
  export declare function scanProcesses(names: string[]): Promise<ProcessInfo[]>;
19
47
  //# sourceMappingURL=process-scanner.d.ts.map
@@ -1,6 +1,11 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  const execFileAsync = promisify(execFile);
4
+ /** Bounds on the `ps` call: a wedged or enormous process table can't stall or flood us. */
5
+ const PS_TIMEOUT_MS = 5000;
6
+ const PS_MAX_BUFFER = 16 * 1024 * 1024;
7
+ /** Linux truncates `comm` to 15 characters (TASK_COMM_LEN - 1). */
8
+ const LINUX_COMM_LEN = 15;
4
9
  /**
5
10
  * Compare the previous and current running-process sets and return speech
6
11
  * strings for any transitions: "<name> died!" when a process disappears and
@@ -19,92 +24,77 @@ export function detectTransitions(prev, curr) {
19
24
  return messages;
20
25
  }
21
26
  /**
22
- * Parse the output of `ps` into structured entries.
23
- * Expects columns: PID, %CPU, RSS (KB), COMMAND
27
+ * Parse header-less `ps -eo pid=,pcpu=,rss=,comm=` output. COMMAND is the rest
28
+ * of the line, so macOS paths with spaces ("Google Chrome") survive intact.
24
29
  */
25
- function parsePsOutput(stdout) {
26
- const lines = stdout.trim().split('\n');
27
- // Skip the header line
30
+ export function parsePsOutput(stdout) {
28
31
  const entries = [];
29
- for (let i = 1; i < lines.length; i++) {
30
- const line = lines[i].trim();
31
- if (!line)
32
+ for (const line of stdout.split('\n')) {
33
+ const m = line.match(/^\s*(\d+)\s+([\d.]+)\s+(\d+)\s+(.+?)\s*$/);
34
+ if (!m)
32
35
  continue;
33
- // Fields are whitespace-separated: PID, %CPU, RSS, COMMAND (may contain spaces)
34
- const parts = line.split(/\s+/);
35
- if (parts.length < 4)
36
- continue;
37
- const pid = parseInt(parts[0], 10);
38
- const cpu = parseFloat(parts[1]);
39
- const memKb = parseInt(parts[2], 10);
40
- // COMMAND is everything from the 4th column onward
41
- const comm = parts.slice(3).join(' ');
42
- if (Number.isNaN(pid) || Number.isNaN(cpu) || Number.isNaN(memKb))
43
- continue;
44
- entries.push({ pid, cpu, memKb, comm });
36
+ entries.push({
37
+ pid: Number(m[1]),
38
+ cpu: Number(m[2]),
39
+ memKb: Number(m[3]),
40
+ comm: m[4],
41
+ });
45
42
  }
46
43
  return entries;
47
44
  }
48
45
  /**
49
- * Match a process name against a ps COMMAND column.
50
- * Matches if the basename of the command path equals the search name,
51
- * or if the command contains the search name.
46
+ * Does a ps COMMAND entry match a watched name? Case-insensitive on the
47
+ * executable's basename:
48
+ * - exact: `nginx` matches `/usr/sbin/nginx`;
49
+ * - version suffix: `python` matches `python3` and `python3.11`;
50
+ * - Linux truncation: a name longer than 15 chars matches its 15-char prefix;
51
+ * - a name containing `/` must equal the full path (macOS reports paths).
52
+ * Unlike a substring match, `sh` does not match `bash` or `ssh-agent`.
52
53
  */
53
- function matchesName(comm, searchName) {
54
- // Get the basename of the command (strip path and arguments)
55
- const basename = comm.split('/').pop()?.split(/\s/)[0] ?? '';
56
- if (basename === searchName)
54
+ export function matchesName(comm, searchName) {
55
+ if (searchName.includes('/'))
56
+ return comm === searchName;
57
+ const base = (comm.split('/').pop() ?? comm).toLowerCase();
58
+ const name = searchName.toLowerCase();
59
+ if (base === name)
57
60
  return true;
58
- // Also check if the full command string contains the name
59
- // (handles cases like "python my-script.py" when searching for "python")
60
- return comm.toLowerCase().includes(searchName.toLowerCase());
61
+ if (name.length > LINUX_COMM_LEN && base === name.slice(0, LINUX_COMM_LEN))
62
+ return true;
63
+ return base.startsWith(name) && /^\d[\d.]*$/.test(base.slice(name.length));
61
64
  }
62
- /**
63
- * Scan for processes by name and return their status and resource usage.
64
- * Works on macOS and Linux by using `ps` to list all processes.
65
- */
66
- export async function scanProcesses(names) {
67
- let psArgs;
68
- if (process.platform === 'darwin') {
69
- // macOS: -e selects all processes, -o specifies output columns
70
- psArgs = ['-eo', 'pid,pcpu,rss,comm'];
71
- }
72
- else {
73
- // Linux: -eo with similar columns
74
- psArgs = ['-eo', 'pid,pcpu,rss,comm'];
75
- }
76
- let entries;
77
- try {
78
- const { stdout } = await execFileAsync('ps', psArgs);
79
- entries = parsePsOutput(stdout);
80
- }
81
- catch {
82
- // If ps fails, report all processes as unknown / not running
83
- return names.map((name) => ({ name, running: false }));
84
- }
85
- const results = [];
86
- for (const name of names) {
87
- // Find all matching entries for this process name
65
+ /** List every process. Throws if `ps` fails — callers must not read that as "all dead". */
66
+ export async function listProcesses() {
67
+ const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,pcpu=,rss=,comm='], {
68
+ timeout: PS_TIMEOUT_MS,
69
+ maxBuffer: PS_MAX_BUFFER,
70
+ // Some locales print %CPU with a decimal comma ("1,5"); force "1.5".
71
+ env: { ...process.env, LC_ALL: 'C' },
72
+ });
73
+ return parsePsOutput(stdout);
74
+ }
75
+ /** Summarise each watched name against a process list (CPU and memory summed per name). */
76
+ export function matchProcesses(names, entries) {
77
+ return names.map((name) => {
88
78
  const matching = entries.filter((e) => matchesName(e.comm, name));
89
- if (matching.length === 0) {
90
- results.push({ name, running: false });
91
- continue;
92
- }
93
- // Aggregate: take the first matching PID, sum CPU and memory across all instances
94
- let totalCpu = 0;
95
- let totalMemKb = 0;
96
- for (const entry of matching) {
97
- totalCpu += entry.cpu;
98
- totalMemKb += entry.memKb;
99
- }
100
- results.push({
79
+ if (matching.length === 0)
80
+ return { name, running: false };
81
+ const totalCpu = matching.reduce((sum, e) => sum + e.cpu, 0);
82
+ const totalMemKb = matching.reduce((sum, e) => sum + e.memKb, 0);
83
+ return {
101
84
  name,
102
85
  running: true,
103
86
  pid: matching[0].pid,
87
+ count: matching.length,
104
88
  cpuPercent: Math.round(totalCpu * 100) / 100,
105
89
  memMb: Math.round((totalMemKb / 1024) * 100) / 100,
106
- });
107
- }
108
- return results;
90
+ };
91
+ });
92
+ }
93
+ /**
94
+ * Scan for processes by name and return their status and resource usage.
95
+ * Works on macOS and Linux via `ps`. Throws when the process list is unavailable.
96
+ */
97
+ export async function scanProcesses(names) {
98
+ return matchProcesses(names, await listProcesses());
109
99
  }
110
100
  //# sourceMappingURL=process-scanner.js.map
@@ -1,6 +1,10 @@
1
1
  export declare const DEFAULTS: {
2
2
  pollInterval: number;
3
3
  weatherRefreshMs: number;
4
+ /** Drop a reading this old (repeated refresh failures) rather than show stale weather. */
5
+ weatherMaxAgeMs: number;
6
+ /** Wait this long before retrying a failed geocode / IP geo / weather call. */
7
+ retryBackoffMs: number;
4
8
  fetchTimeoutMs: number;
5
9
  };
6
10
  //# sourceMappingURL=defaults.d.ts.map
@@ -1,6 +1,10 @@
1
1
  export const DEFAULTS = {
2
2
  pollInterval: 60000, // 60s clock tick
3
3
  weatherRefreshMs: 15 * 60 * 1000, // refresh weather every 15 minutes
4
+ /** Drop a reading this old (repeated refresh failures) rather than show stale weather. */
5
+ weatherMaxAgeMs: 2 * 60 * 60 * 1000,
6
+ /** Wait this long before retrying a failed geocode / IP geo / weather call. */
7
+ retryBackoffMs: 5 * 60 * 1000,
4
8
  fetchTimeoutMs: 5000,
5
9
  };
6
10
  //# sourceMappingURL=defaults.js.map
@@ -7,15 +7,25 @@ export interface ReceptionistConfig extends PollingNpcConfig {
7
7
  location?: string;
8
8
  /** Display temperature in Fahrenheit instead of Celsius. */
9
9
  fahrenheit?: boolean;
10
+ /**
11
+ * Fall back to IP geolocation (ipapi.co) when no --lat/--lon or --location
12
+ * is given. Default true; false keeps the receptionist in time-only mode
13
+ * rather than sending this machine's IP to a third party.
14
+ */
15
+ ipGeo?: boolean;
10
16
  }
11
17
  export declare class ReceptionistNPC extends PollingNPC {
12
18
  private explicitCoords;
13
19
  private location?;
14
20
  private fahrenheit;
21
+ private ipGeo;
15
22
  private coords;
16
- private geoAttempted;
23
+ /** Earliest time to retry geocoding / IP geo after a failure. */
24
+ private nextGeoAttempt;
17
25
  private weather;
18
- private lastWeatherFetch;
26
+ private weatherFetchedAt;
27
+ /** Earliest time to retry the weather after a failure. */
28
+ private nextWeatherAttempt;
19
29
  constructor(config: ReceptionistConfig);
20
30
  get name(): string;
21
31
  protected get defaultDisplayName(): string;
@@ -1,22 +1,34 @@
1
1
  import { PollingNPC } from '../../core/polling-npc.js';
2
+ import { NpcConfigError } from '../../core/errors.js';
2
3
  import { DEFAULTS } from './defaults.js';
3
- import { fetchWeather, geocodeLocation, autoGeo, formatClock, toFahrenheit } from './weather.js';
4
+ import { fetchWeather, geocodeLocation, autoGeo, formatClock, toFahrenheit, validCoords, } from './weather.js';
4
5
  export class ReceptionistNPC extends PollingNPC {
5
6
  explicitCoords;
6
7
  location;
7
8
  fahrenheit;
9
+ ipGeo;
8
10
  coords = null;
9
- geoAttempted = false;
11
+ /** Earliest time to retry geocoding / IP geo after a failure. */
12
+ nextGeoAttempt = 0;
10
13
  weather = null;
11
- lastWeatherFetch = 0;
14
+ weatherFetchedAt = 0;
15
+ /** Earliest time to retry the weather after a failure. */
16
+ nextWeatherAttempt = 0;
12
17
  constructor(config) {
13
18
  super({ ...config, pollInterval: config.pollInterval ?? DEFAULTS.pollInterval });
19
+ if ((config.lat === undefined) !== (config.lon === undefined)) {
20
+ throw new NpcConfigError('--lat and --lon must be given together');
21
+ }
22
+ if (config.lat !== undefined && !validCoords(config.lat, config.lon)) {
23
+ throw new NpcConfigError('--lat must be within ±90 and --lon within ±180');
24
+ }
14
25
  this.explicitCoords =
15
26
  config.lat !== undefined && config.lon !== undefined
16
27
  ? { lat: config.lat, lon: config.lon }
17
28
  : null;
18
- this.location = config.location;
29
+ this.location = config.location?.trim() || undefined;
19
30
  this.fahrenheit = config.fahrenheit ?? false;
31
+ this.ipGeo = config.ipGeo ?? true;
20
32
  }
21
33
  get name() {
22
34
  return 'receptionist';
@@ -75,41 +87,55 @@ export class ReceptionistNPC extends PollingNPC {
75
87
  };
76
88
  }
77
89
  async refreshWeather() {
78
- if (!this.coords) {
79
- await this.resolveCoords();
80
- }
90
+ const now = Date.now();
81
91
  if (!this.coords)
92
+ await this.resolveCoords(now);
93
+ // Don't show a reading that has gone stale through repeated failures.
94
+ if (this.weather && now - this.weatherFetchedAt > DEFAULTS.weatherMaxAgeMs) {
95
+ this.weather = null;
96
+ }
97
+ if (!this.coords || now < this.nextWeatherAttempt)
82
98
  return;
83
- const now = Date.now();
84
- if (this.weather && now - this.lastWeatherFetch < DEFAULTS.weatherRefreshMs)
99
+ if (this.weather && now - this.weatherFetchedAt < DEFAULTS.weatherRefreshMs)
85
100
  return;
86
101
  const w = await fetchWeather(this.coords, DEFAULTS.fetchTimeoutMs);
87
102
  if (w) {
88
103
  this.weather = w;
89
- this.lastWeatherFetch = now;
104
+ this.weatherFetchedAt = now;
105
+ }
106
+ else {
107
+ this.nextWeatherAttempt = now + DEFAULTS.retryBackoffMs;
90
108
  }
91
109
  }
92
- async resolveCoords() {
110
+ async resolveCoords(now) {
93
111
  if (this.explicitCoords) {
94
112
  this.coords = this.explicitCoords;
95
113
  return;
96
114
  }
97
- // Only attempt geocoding/geo-IP once per (failed) resolution to avoid
98
- // hammering the keyless endpoints every poll.
99
- if (this.geoAttempted)
115
+ // Back off after a failure rather than hitting the keyless endpoints every tick.
116
+ if (now < this.nextGeoAttempt)
100
117
  return;
101
- this.geoAttempted = true;
118
+ this.nextGeoAttempt = now + DEFAULTS.retryBackoffMs;
102
119
  if (this.location) {
103
120
  this.coords = await geocodeLocation(this.location, DEFAULTS.fetchTimeoutMs);
104
121
  if (this.coords)
105
122
  return;
106
- this.logger.warn(`Could not geocode "${this.location}", falling back to IP geo`);
123
+ this.logger.warn(`Could not geocode "${this.location}"` + (this.ipGeo ? ', falling back to IP geo' : ''));
124
+ }
125
+ if (!this.ipGeo) {
126
+ if (!this.location) {
127
+ this.nextGeoAttempt = Infinity; // nothing to resolve, ever
128
+ this.logger.info('No location set and IP geolocation disabled: time-only mode');
129
+ }
130
+ return;
107
131
  }
108
132
  this.coords = await autoGeo(DEFAULTS.fetchTimeoutMs);
109
- if (!this.coords) {
110
- this.logger.warn('Weather geo unavailable — running in time-only mode');
111
- // Allow a later retry.
112
- this.geoAttempted = false;
133
+ if (this.coords) {
134
+ this.logger.info("Weather location estimated from this machine's IP via ipapi.co " +
135
+ '(set --location or --lat/--lon, or pass --no-ip-geo to disable)');
136
+ }
137
+ else {
138
+ this.logger.warn('Weather geo unavailable — running in time-only mode for now');
113
139
  }
114
140
  }
115
141
  }
@@ -1,8 +1,3 @@
1
- /**
2
- * Keyless weather + geolocation via open-meteo, with ipapi.co IP fallback.
3
- * All network helpers return null on failure so the receptionist can degrade
4
- * to a time-only mode without erroring.
5
- */
6
1
  export interface Coords {
7
2
  lat: number;
8
3
  lon: number;
@@ -20,9 +15,16 @@ export declare function describeWmo(code: number): {
20
15
  text: string;
21
16
  icon: string;
22
17
  };
18
+ /** A finite, in-range latitude/longitude pair. */
19
+ export declare function validCoords(lat: unknown, lon: unknown): boolean;
23
20
  /** Resolve a place name to coordinates via the open-meteo geocoding API. */
24
21
  export declare function geocodeLocation(name: string, timeoutMs?: number): Promise<Coords | null>;
25
- /** Best-effort IP geolocation via ipapi.co (keyless). */
22
+ /**
23
+ * Best-effort IP geolocation via ipapi.co (keyless). This sends the machine's
24
+ * public IP to a third party, so the receptionist only calls it when no place
25
+ * was configured and the user hasn't opted out. Only the coordinates are used;
26
+ * nothing from the response is pushed to Nocville.
27
+ */
26
28
  export declare function autoGeo(timeoutMs?: number): Promise<Coords | null>;
27
29
  /** Fetch current temperature, weather code, and wind for coordinates. */
28
30
  export declare function fetchWeather(coords: Coords, timeoutMs?: number): Promise<Weather | null>;