@nocville/npc 0.2.2 → 0.3.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/README.md +55 -17
- package/dist/cjs/cli.js +94 -39
- package/dist/cjs/core/base-npc.js +15 -0
- package/dist/cjs/core/config.js +19 -2
- package/dist/cjs/core/errors.js +23 -0
- package/dist/cjs/core/http.js +121 -0
- package/dist/cjs/core/pixel-art.js +110 -0
- package/dist/cjs/core/polling-npc.js +99 -14
- package/dist/cjs/core/text.js +13 -0
- package/dist/cjs/core/thresholds.js +29 -0
- package/dist/cjs/index.js +21 -2
- package/dist/cjs/npcs/agent-common/session.js +1 -0
- package/dist/cjs/npcs/agent-common/shim.js +8 -2
- package/dist/cjs/npcs/github-watcher/defaults.js +7 -1
- package/dist/cjs/npcs/github-watcher/github-api.js +82 -45
- package/dist/cjs/npcs/github-watcher/index.js +82 -36
- package/dist/cjs/npcs/internet-connectivity/checks.js +70 -67
- package/dist/cjs/npcs/internet-connectivity/defaults.js +5 -2
- package/dist/cjs/npcs/internet-connectivity/index.js +134 -131
- package/dist/cjs/npcs/log-watcher/buffer.js +61 -0
- package/dist/cjs/npcs/log-watcher/index.js +109 -103
- package/dist/cjs/npcs/log-watcher/tail.js +100 -53
- package/dist/cjs/npcs/office-characters/index.js +92 -0
- package/dist/cjs/npcs/process-watcher/index.js +60 -51
- package/dist/cjs/npcs/process-watcher/process-scanner.js +100 -68
- package/dist/cjs/npcs/receptionist/defaults.js +4 -0
- package/dist/cjs/npcs/receptionist/index.js +6 -93
- package/dist/cjs/npcs/receptionist/weather.js +42 -47
- package/dist/cjs/npcs/security/index.js +26 -0
- package/dist/cjs/npcs/system-monitor/collectors.js +66 -17
- package/dist/cjs/npcs/system-monitor/index.js +52 -28
- package/dist/cjs/npcs/web-page-watcher/basic-mode.js +30 -14
- package/dist/cjs/npcs/web-page-watcher/capture.js +23 -1
- package/dist/cjs/npcs/web-page-watcher/defaults.js +44 -2
- package/dist/cjs/npcs/web-page-watcher/index.js +119 -68
- package/dist/cjs/npcs/web-page-watcher/pixelate.js +2 -1
- package/dist/cli.js +95 -40
- package/dist/core/base-npc.d.ts +5 -0
- package/dist/core/base-npc.js +15 -0
- package/dist/core/config.d.ts +5 -0
- package/dist/core/config.js +18 -2
- package/dist/core/errors.d.ts +11 -0
- package/dist/core/errors.js +18 -0
- package/dist/core/http.d.ts +52 -0
- package/dist/core/http.js +111 -0
- package/dist/core/pixel-art.d.ts +38 -0
- package/dist/core/pixel-art.js +105 -0
- package/dist/core/polling-npc.d.ts +22 -1
- package/dist/core/polling-npc.js +98 -13
- package/dist/core/text.d.ts +6 -0
- package/dist/core/text.js +12 -0
- package/dist/core/thresholds.d.ts +9 -1
- package/dist/core/thresholds.js +27 -0
- package/dist/core/types.d.ts +5 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +6 -2
- package/dist/npcs/agent-common/session.js +1 -0
- package/dist/npcs/agent-common/shim.js +8 -2
- package/dist/npcs/github-watcher/defaults.d.ts +6 -0
- package/dist/npcs/github-watcher/defaults.js +7 -1
- package/dist/npcs/github-watcher/github-api.d.ts +21 -7
- package/dist/npcs/github-watcher/github-api.js +80 -45
- package/dist/npcs/github-watcher/index.d.ts +6 -0
- package/dist/npcs/github-watcher/index.js +83 -37
- package/dist/npcs/internet-connectivity/checks.d.ts +15 -18
- package/dist/npcs/internet-connectivity/checks.js +70 -67
- package/dist/npcs/internet-connectivity/defaults.d.ts +3 -0
- package/dist/npcs/internet-connectivity/defaults.js +5 -2
- package/dist/npcs/internet-connectivity/index.d.ts +19 -6
- package/dist/npcs/internet-connectivity/index.js +135 -133
- package/dist/npcs/log-watcher/buffer.d.ts +22 -0
- package/dist/npcs/log-watcher/buffer.js +58 -0
- package/dist/npcs/log-watcher/index.d.ts +22 -21
- package/dist/npcs/log-watcher/index.js +112 -106
- package/dist/npcs/log-watcher/tail.d.ts +23 -3
- package/dist/npcs/log-watcher/tail.js +101 -54
- package/dist/npcs/office-characters/index.d.ts +50 -0
- package/dist/npcs/office-characters/index.js +82 -0
- package/dist/npcs/process-watcher/index.d.ts +1 -1
- package/dist/npcs/process-watcher/index.js +61 -52
- package/dist/npcs/process-watcher/process-scanner.d.ts +31 -1
- package/dist/npcs/process-watcher/process-scanner.js +97 -70
- package/dist/npcs/receptionist/defaults.d.ts +4 -0
- package/dist/npcs/receptionist/defaults.js +4 -0
- package/dist/npcs/receptionist/index.d.ts +3 -12
- package/dist/npcs/receptionist/index.js +6 -93
- package/dist/npcs/receptionist/weather.d.ts +8 -6
- package/dist/npcs/receptionist/weather.js +39 -45
- package/dist/npcs/security/index.d.ts +12 -0
- package/dist/npcs/security/index.js +22 -0
- package/dist/npcs/system-monitor/collectors.d.ts +15 -3
- package/dist/npcs/system-monitor/collectors.js +64 -17
- package/dist/npcs/system-monitor/defaults.d.ts +1 -1
- package/dist/npcs/system-monitor/index.d.ts +2 -1
- package/dist/npcs/system-monitor/index.js +54 -30
- package/dist/npcs/web-page-watcher/basic-mode.d.ts +2 -2
- package/dist/npcs/web-page-watcher/basic-mode.js +30 -14
- package/dist/npcs/web-page-watcher/capture.d.ts +18 -0
- package/dist/npcs/web-page-watcher/capture.js +21 -1
- package/dist/npcs/web-page-watcher/defaults.d.ts +23 -0
- package/dist/npcs/web-page-watcher/defaults.js +41 -1
- package/dist/npcs/web-page-watcher/index.d.ts +18 -1
- package/dist/npcs/web-page-watcher/index.js +121 -70
- package/dist/npcs/web-page-watcher/pixelate.js +2 -1
- package/package.json +2 -2
|
@@ -4,6 +4,7 @@ exports.ProcessWatcherNPC = void 0;
|
|
|
4
4
|
const polling_npc_js_1 = require("../../core/polling-npc.js");
|
|
5
5
|
const thresholds_js_1 = require("../../core/thresholds.js");
|
|
6
6
|
const screen_js_1 = require("../../core/screen.js");
|
|
7
|
+
const errors_js_1 = require("../../core/errors.js");
|
|
7
8
|
const process_scanner_js_1 = require("./process-scanner.js");
|
|
8
9
|
const defaults_js_1 = require("./defaults.js");
|
|
9
10
|
/** Max per-process panels on the companion dashboard. */
|
|
@@ -15,14 +16,16 @@ class ProcessWatcherNPC extends polling_npc_js_1.PollingNPC {
|
|
|
15
16
|
cpuHistories = new Map();
|
|
16
17
|
previousRunning = null;
|
|
17
18
|
constructor(config) {
|
|
18
|
-
if (process.platform === 'win32') {
|
|
19
|
-
throw new Error('process-watcher is not supported on Windows (relies on the POSIX `ps` command). ' +
|
|
20
|
-
'Run it under WSL/Linux, or use system-monitor instead.');
|
|
21
|
-
}
|
|
22
19
|
super(config);
|
|
23
|
-
this.processes = config.processes;
|
|
20
|
+
this.processes = [...new Set(config.processes.map((p) => p.trim()).filter(Boolean))];
|
|
21
|
+
if (this.processes.length === 0) {
|
|
22
|
+
throw new errors_js_1.NpcConfigError('process-watcher needs at least one process name (--processes)');
|
|
23
|
+
}
|
|
24
24
|
this.cpuWarn = config.cpuWarn ?? defaults_js_1.DEFAULTS.cpuWarn;
|
|
25
25
|
this.memWarn = config.memWarn ?? defaults_js_1.DEFAULTS.memWarn;
|
|
26
|
+
if (!(this.cpuWarn > 0) || !(this.memWarn > 0)) {
|
|
27
|
+
throw new errors_js_1.NpcConfigError('--cpu-warn and --mem-warn must be positive numbers');
|
|
28
|
+
}
|
|
26
29
|
}
|
|
27
30
|
get name() {
|
|
28
31
|
return 'process-watcher';
|
|
@@ -33,9 +36,6 @@ class ProcessWatcherNPC extends polling_npc_js_1.PollingNPC {
|
|
|
33
36
|
get archetype() {
|
|
34
37
|
return 'service';
|
|
35
38
|
}
|
|
36
|
-
get heartbeatSeconds() {
|
|
37
|
-
return 30;
|
|
38
|
-
}
|
|
39
39
|
get companion() {
|
|
40
40
|
return {
|
|
41
41
|
screenType: 'dashboard-wall',
|
|
@@ -45,22 +45,28 @@ class ProcessWatcherNPC extends polling_npc_js_1.PollingNPC {
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
async collect() {
|
|
48
|
-
|
|
48
|
+
let infos;
|
|
49
|
+
try {
|
|
50
|
+
infos = await (0, process_scanner_js_1.scanProcesses)(this.processes);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
// Not knowing is different from "everything died": report unknown and
|
|
54
|
+
// leave the transition baseline alone so recovery doesn't say "is back".
|
|
55
|
+
return this.scanFailed(err);
|
|
56
|
+
}
|
|
49
57
|
const statuses = [];
|
|
50
|
-
const
|
|
58
|
+
const problems = [];
|
|
51
59
|
const metrics = {};
|
|
52
60
|
const panels = [];
|
|
53
61
|
let totalRunning = 0;
|
|
54
|
-
let totalMissing = 0;
|
|
55
62
|
for (const info of infos) {
|
|
56
63
|
const safeKey = info.name.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
57
64
|
if (!info.running) {
|
|
58
65
|
statuses.push('critical');
|
|
59
|
-
|
|
66
|
+
problems.push(`${info.name} DOWN`);
|
|
60
67
|
metrics[`${safeKey}_running`] = 0;
|
|
61
68
|
metrics[`${safeKey}_cpu`] = 0;
|
|
62
69
|
metrics[`${safeKey}_mem_mb`] = 0;
|
|
63
|
-
totalMissing++;
|
|
64
70
|
if (panels.length < MAX_PANELS) {
|
|
65
71
|
panels.push({ label: info.name, value: 'DOWN', color: 'red' });
|
|
66
72
|
}
|
|
@@ -68,73 +74,76 @@ class ProcessWatcherNPC extends polling_npc_js_1.PollingNPC {
|
|
|
68
74
|
}
|
|
69
75
|
totalRunning++;
|
|
70
76
|
const cpu = info.cpuPercent ?? 0;
|
|
77
|
+
const memMb = info.memMb ?? 0;
|
|
71
78
|
metrics[`${safeKey}_running`] = 1;
|
|
72
79
|
metrics[`${safeKey}_cpu`] = cpu;
|
|
73
|
-
metrics[`${safeKey}_mem_mb`] =
|
|
80
|
+
metrics[`${safeKey}_mem_mb`] = memMb;
|
|
74
81
|
const history = this.cpuHistories.get(info.name) ?? [];
|
|
75
82
|
(0, screen_js_1.pushHistory)(history, cpu);
|
|
76
83
|
this.cpuHistories.set(info.name, history);
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
statuses.push('healthy');
|
|
90
|
-
messageParts.push(`${info.name}: OK`);
|
|
84
|
+
// Single warning thresholds: no critical level for resource use.
|
|
85
|
+
const cpuStatus = (0, thresholds_js_1.evaluateThreshold)(cpu, { warning: this.cpuWarn, critical: Infinity });
|
|
86
|
+
const memStatus = (0, thresholds_js_1.evaluateThreshold)(memMb, { warning: this.memWarn, critical: Infinity });
|
|
87
|
+
const status = (0, thresholds_js_1.worstStatus)([cpuStatus, memStatus]);
|
|
88
|
+
statuses.push(status);
|
|
89
|
+
if (status !== 'healthy') {
|
|
90
|
+
const high = [];
|
|
91
|
+
if (cpuStatus !== 'healthy')
|
|
92
|
+
high.push(`cpu ${cpu}%`);
|
|
93
|
+
if (memStatus !== 'healthy')
|
|
94
|
+
high.push(`mem ${Math.round(memMb)}MB`);
|
|
95
|
+
problems.push(`${info.name} high ${high.join(', ')}`);
|
|
91
96
|
}
|
|
92
97
|
if (panels.length < MAX_PANELS) {
|
|
93
98
|
panels.push({
|
|
94
99
|
label: info.name,
|
|
95
100
|
value: `${cpu}%`,
|
|
96
|
-
color:
|
|
101
|
+
color: (0, screen_js_1.statusColor)(status),
|
|
97
102
|
sparkline: [...history],
|
|
98
103
|
});
|
|
99
104
|
}
|
|
100
105
|
}
|
|
106
|
+
const totalMissing = infos.length - totalRunning;
|
|
101
107
|
metrics['total_watched'] = this.processes.length;
|
|
102
108
|
metrics['total_running'] = totalRunning;
|
|
103
109
|
metrics['total_missing'] = totalMissing;
|
|
104
110
|
// Transition speech: compare the running set to last poll's. On the first
|
|
105
111
|
// poll we only seed the set (no bogus "is back" for already-running procs).
|
|
106
112
|
const currentRunning = new Set(infos.filter((i) => i.running).map((i) => i.name));
|
|
107
|
-
|
|
108
|
-
if (this.previousRunning !== null) {
|
|
109
|
-
transitions = (0, process_scanner_js_1.detectTransitions)(this.previousRunning, currentRunning);
|
|
110
|
-
}
|
|
113
|
+
const transitions = this.previousRunning === null ? [] : (0, process_scanner_js_1.detectTransitions)(this.previousRunning, currentRunning);
|
|
111
114
|
this.previousRunning = currentRunning;
|
|
112
115
|
const status = (0, thresholds_js_1.worstStatus)(statuses);
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
116
|
+
const summary = problems.length === 0
|
|
117
|
+
? `All ${infos.length} running`
|
|
118
|
+
: `${totalRunning}/${infos.length} running · ${problems.join(' · ')}`;
|
|
119
|
+
// A death/return takes over the bubble this cycle so it reads as an alert.
|
|
120
|
+
const message = transitions.length > 0 ? transitions.join(' · ') : summary;
|
|
117
121
|
return {
|
|
118
122
|
status,
|
|
119
|
-
mood,
|
|
123
|
+
mood: (0, thresholds_js_1.moodForStatus)(status),
|
|
120
124
|
message,
|
|
121
125
|
subStatus: totalMissing > 0 ? 'process-down' : undefined,
|
|
122
|
-
screenData,
|
|
126
|
+
screenData: { layout: 'multi-metric', values: panels },
|
|
123
127
|
metrics,
|
|
124
128
|
};
|
|
125
129
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
130
|
+
scanFailed(err) {
|
|
131
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
132
|
+
this.logger.warn('Cannot list processes', { error: reason });
|
|
133
|
+
return {
|
|
134
|
+
status: 'unknown',
|
|
135
|
+
mood: (0, thresholds_js_1.moodForStatus)('unknown'),
|
|
136
|
+
message: 'Cannot list processes (ps failed)',
|
|
137
|
+
subStatus: 'scan-failed',
|
|
138
|
+
screenData: {
|
|
139
|
+
layout: 'multi-metric',
|
|
140
|
+
values: this.processes
|
|
141
|
+
.slice(0, MAX_PANELS)
|
|
142
|
+
.map((name) => ({ label: name, value: '?', color: 'gray' })),
|
|
143
|
+
},
|
|
144
|
+
metrics: { total_watched: this.processes.length },
|
|
145
|
+
};
|
|
138
146
|
}
|
|
139
147
|
}
|
|
148
|
+
exports.ProcessWatcherNPC = ProcessWatcherNPC;
|
|
140
149
|
//# sourceMappingURL=index.js.map
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.detectTransitions = detectTransitions;
|
|
4
|
+
exports.parsePsOutput = parsePsOutput;
|
|
5
|
+
exports.matchesName = matchesName;
|
|
6
|
+
exports.listProcesses = listProcesses;
|
|
7
|
+
exports.parseWindowsProcesses = parseWindowsProcesses;
|
|
8
|
+
exports.matchProcesses = matchProcesses;
|
|
4
9
|
exports.scanProcesses = scanProcesses;
|
|
5
10
|
const node_child_process_1 = require("node:child_process");
|
|
6
11
|
const node_util_1 = require("node:util");
|
|
12
|
+
const node_os_1 = require("node:os");
|
|
13
|
+
const node_path_1 = require("node:path");
|
|
7
14
|
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
15
|
+
/** Bounds on the `ps` call: a wedged or enormous process table can't stall or flood us. */
|
|
16
|
+
const PS_TIMEOUT_MS = 5000;
|
|
17
|
+
const PS_MAX_BUFFER = 16 * 1024 * 1024;
|
|
18
|
+
/** Linux truncates `comm` to 15 characters (TASK_COMM_LEN - 1). */
|
|
19
|
+
const LINUX_COMM_LEN = 15;
|
|
8
20
|
/**
|
|
9
21
|
* Compare the previous and current running-process sets and return speech
|
|
10
22
|
* strings for any transitions: "<name> died!" when a process disappears and
|
|
@@ -23,92 +35,112 @@ function detectTransitions(prev, curr) {
|
|
|
23
35
|
return messages;
|
|
24
36
|
}
|
|
25
37
|
/**
|
|
26
|
-
* Parse
|
|
27
|
-
*
|
|
38
|
+
* Parse header-less `ps -eo pid=,pcpu=,rss=,comm=` output. COMMAND is the rest
|
|
39
|
+
* of the line, so macOS paths with spaces ("Google Chrome") survive intact.
|
|
28
40
|
*/
|
|
29
41
|
function parsePsOutput(stdout) {
|
|
30
|
-
const lines = stdout.trim().split('\n');
|
|
31
|
-
// Skip the header line
|
|
32
42
|
const entries = [];
|
|
33
|
-
for (
|
|
34
|
-
const
|
|
35
|
-
if (!
|
|
43
|
+
for (const line of stdout.split('\n')) {
|
|
44
|
+
const m = line.match(/^\s*(\d+)\s+([\d.]+)\s+(\d+)\s+(.+?)\s*$/);
|
|
45
|
+
if (!m)
|
|
36
46
|
continue;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const memKb = parseInt(parts[2], 10);
|
|
44
|
-
// COMMAND is everything from the 4th column onward
|
|
45
|
-
const comm = parts.slice(3).join(' ');
|
|
46
|
-
if (Number.isNaN(pid) || Number.isNaN(cpu) || Number.isNaN(memKb))
|
|
47
|
-
continue;
|
|
48
|
-
entries.push({ pid, cpu, memKb, comm });
|
|
47
|
+
entries.push({
|
|
48
|
+
pid: Number(m[1]),
|
|
49
|
+
cpu: Number(m[2]),
|
|
50
|
+
memKb: Number(m[3]),
|
|
51
|
+
comm: m[4],
|
|
52
|
+
});
|
|
49
53
|
}
|
|
50
54
|
return entries;
|
|
51
55
|
}
|
|
52
56
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
57
|
+
* Does a ps COMMAND entry match a watched name? Case-insensitive on the
|
|
58
|
+
* executable's basename:
|
|
59
|
+
* - exact: `nginx` matches `/usr/sbin/nginx`;
|
|
60
|
+
* - version suffix: `python` matches `python3` and `python3.11`;
|
|
61
|
+
* - Linux truncation: a name longer than 15 chars matches its 15-char prefix;
|
|
62
|
+
* - a name containing `/` must equal the full path (macOS reports paths).
|
|
63
|
+
* Unlike a substring match, `sh` does not match `bash` or `ssh-agent`.
|
|
56
64
|
*/
|
|
57
65
|
function matchesName(comm, searchName) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
66
|
+
if (/[\\/]/.test(searchName))
|
|
67
|
+
return comm === searchName;
|
|
68
|
+
const base = (comm.split(/[\\/]/).pop() ?? comm).toLowerCase().replace(/\.exe$/, '');
|
|
69
|
+
const name = searchName.toLowerCase().replace(/\.exe$/, '');
|
|
70
|
+
if (base === name)
|
|
61
71
|
return true;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return
|
|
72
|
+
if (name.length > LINUX_COMM_LEN && base === name.slice(0, LINUX_COMM_LEN))
|
|
73
|
+
return true;
|
|
74
|
+
return base.startsWith(name) && /^\d[\d.]*$/.test(base.slice(name.length));
|
|
65
75
|
}
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
let entries;
|
|
81
|
-
try {
|
|
82
|
-
const { stdout } = await execFileAsync('ps', psArgs);
|
|
83
|
-
entries = parsePsOutput(stdout);
|
|
84
|
-
}
|
|
85
|
-
catch {
|
|
86
|
-
// If ps fails, report all processes as unknown / not running
|
|
87
|
-
return names.map((name) => ({ name, running: false }));
|
|
76
|
+
/** List every process. Throws if `ps` fails — callers must not read that as "all dead". */
|
|
77
|
+
async function listProcesses() {
|
|
78
|
+
if (process.platform === 'win32') {
|
|
79
|
+
// Fixed command, no user strings interpolated. Performance counters report a
|
|
80
|
+
// sampling interval (Get-Process.CPU is lifetime CPU seconds, not a percent).
|
|
81
|
+
const executable = (0, node_path_1.join)(process.env['SystemRoot'] || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
82
|
+
const { stdout } = await execFileAsync(executable, [
|
|
83
|
+
'-NoProfile',
|
|
84
|
+
'-NonInteractive',
|
|
85
|
+
'-Command',
|
|
86
|
+
'$ErrorActionPreference="Stop"; Get-CimInstance Win32_PerfFormattedData_PerfProc_Process | Select-Object IDProcess,Name,PercentProcessorTime,WorkingSetPrivate | ConvertTo-Json -Compress',
|
|
87
|
+
], { timeout: PS_TIMEOUT_MS, maxBuffer: PS_MAX_BUFFER, windowsHide: true });
|
|
88
|
+
return parseWindowsProcesses(stdout, (0, node_os_1.cpus)().length);
|
|
88
89
|
}
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,pcpu=,rss=,comm='], {
|
|
91
|
+
timeout: PS_TIMEOUT_MS,
|
|
92
|
+
maxBuffer: PS_MAX_BUFFER,
|
|
93
|
+
// Some locales print %CPU with a decimal comma ("1,5"); force "1.5".
|
|
94
|
+
env: { ...process.env, LC_ALL: 'C' },
|
|
95
|
+
});
|
|
96
|
+
return parsePsOutput(stdout);
|
|
97
|
+
}
|
|
98
|
+
/** Windows instance suffixes (#1, #2) distinguish copies of the same executable. */
|
|
99
|
+
function parseWindowsProcesses(stdout, cores = 1) {
|
|
100
|
+
const value = JSON.parse(stdout.replace(/^\uFEFF/, ''));
|
|
101
|
+
const rows = Array.isArray(value) ? value : value ? [value] : [];
|
|
102
|
+
return rows.flatMap((row) => {
|
|
103
|
+
const pid = Number(row.IDProcess);
|
|
104
|
+
const cpu = Number(row.PercentProcessorTime);
|
|
105
|
+
const bytes = Number(row.WorkingSetPrivate);
|
|
106
|
+
if (!(pid > 0) ||
|
|
107
|
+
!Number.isFinite(cpu) ||
|
|
108
|
+
!Number.isFinite(bytes) ||
|
|
109
|
+
typeof row.Name !== 'string')
|
|
110
|
+
return [];
|
|
111
|
+
return [
|
|
112
|
+
{
|
|
113
|
+
pid,
|
|
114
|
+
cpu: cpu / Math.max(1, cores),
|
|
115
|
+
memKb: bytes / 1024,
|
|
116
|
+
comm: row.Name.replace(/#\d+$/, ''),
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/** Summarise each watched name against a process list (CPU and memory summed per name). */
|
|
122
|
+
function matchProcesses(names, entries) {
|
|
123
|
+
return names.map((name) => {
|
|
92
124
|
const matching = entries.filter((e) => matchesName(e.comm, name));
|
|
93
|
-
if (matching.length === 0)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
let totalCpu = 0;
|
|
99
|
-
let totalMemKb = 0;
|
|
100
|
-
for (const entry of matching) {
|
|
101
|
-
totalCpu += entry.cpu;
|
|
102
|
-
totalMemKb += entry.memKb;
|
|
103
|
-
}
|
|
104
|
-
results.push({
|
|
125
|
+
if (matching.length === 0)
|
|
126
|
+
return { name, running: false };
|
|
127
|
+
const totalCpu = matching.reduce((sum, e) => sum + e.cpu, 0);
|
|
128
|
+
const totalMemKb = matching.reduce((sum, e) => sum + e.memKb, 0);
|
|
129
|
+
return {
|
|
105
130
|
name,
|
|
106
131
|
running: true,
|
|
107
132
|
pid: matching[0].pid,
|
|
133
|
+
count: matching.length,
|
|
108
134
|
cpuPercent: Math.round(totalCpu * 100) / 100,
|
|
109
135
|
memMb: Math.round((totalMemKb / 1024) * 100) / 100,
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Scan for processes by name and return their status and resource usage.
|
|
141
|
+
* Uses `ps` on macOS/Linux and CIM on Windows. Throws when the process list is unavailable.
|
|
142
|
+
*/
|
|
143
|
+
async function scanProcesses(names) {
|
|
144
|
+
return matchProcesses(names, await listProcesses());
|
|
113
145
|
}
|
|
114
146
|
//# sourceMappingURL=process-scanner.js.map
|
|
@@ -4,6 +4,10 @@ exports.DEFAULTS = void 0;
|
|
|
4
4
|
exports.DEFAULTS = {
|
|
5
5
|
pollInterval: 60000, // 60s clock tick
|
|
6
6
|
weatherRefreshMs: 15 * 60 * 1000, // refresh weather every 15 minutes
|
|
7
|
+
/** Drop a reading this old (repeated refresh failures) rather than show stale weather. */
|
|
8
|
+
weatherMaxAgeMs: 2 * 60 * 60 * 1000,
|
|
9
|
+
/** Wait this long before retrying a failed geocode / IP geo / weather call. */
|
|
10
|
+
retryBackoffMs: 5 * 60 * 1000,
|
|
7
11
|
fetchTimeoutMs: 5000,
|
|
8
12
|
};
|
|
9
13
|
//# sourceMappingURL=defaults.js.map
|
|
@@ -2,24 +2,9 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ReceptionistNPC = void 0;
|
|
4
4
|
const polling_npc_js_1 = require("../../core/polling-npc.js");
|
|
5
|
-
const defaults_js_1 = require("./defaults.js");
|
|
6
|
-
const weather_js_1 = require("./weather.js");
|
|
7
5
|
class ReceptionistNPC extends polling_npc_js_1.PollingNPC {
|
|
8
|
-
explicitCoords;
|
|
9
|
-
location;
|
|
10
|
-
fahrenheit;
|
|
11
|
-
coords = null;
|
|
12
|
-
geoAttempted = false;
|
|
13
|
-
weather = null;
|
|
14
|
-
lastWeatherFetch = 0;
|
|
15
6
|
constructor(config) {
|
|
16
|
-
super({ ...config, pollInterval: config.pollInterval ??
|
|
17
|
-
this.explicitCoords =
|
|
18
|
-
config.lat !== undefined && config.lon !== undefined
|
|
19
|
-
? { lat: config.lat, lon: config.lon }
|
|
20
|
-
: null;
|
|
21
|
-
this.location = config.location;
|
|
22
|
-
this.fahrenheit = config.fahrenheit ?? false;
|
|
7
|
+
super({ ...config, pollInterval: config.pollInterval ?? 60_000 });
|
|
23
8
|
}
|
|
24
9
|
get name() {
|
|
25
10
|
return 'receptionist';
|
|
@@ -32,88 +17,16 @@ class ReceptionistNPC extends polling_npc_js_1.PollingNPC {
|
|
|
32
17
|
}
|
|
33
18
|
get behavior() {
|
|
34
19
|
return {
|
|
20
|
+
role: 'receptionist',
|
|
21
|
+
activity: 'idle',
|
|
35
22
|
greetPlayers: true,
|
|
36
|
-
patrol: { everyMinutes: [15, 30], stops: 5 },
|
|
37
23
|
idle: 'stay',
|
|
38
|
-
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
get companion() {
|
|
42
|
-
return {
|
|
43
|
-
screenType: 'welcome-sign',
|
|
44
|
-
size: { w: 2, h: 1 },
|
|
45
|
-
placement: 'adjacent-wall',
|
|
46
|
-
label: 'Welcome',
|
|
24
|
+
idlePose: 'sit',
|
|
47
25
|
};
|
|
48
26
|
}
|
|
49
27
|
async collect() {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const { day, date, time } = (0, weather_js_1.formatClock)(now);
|
|
53
|
-
const values = [
|
|
54
|
-
{ label: 'time', value: time },
|
|
55
|
-
{ label: 'day', value: day },
|
|
56
|
-
{ label: 'date', value: date },
|
|
57
|
-
];
|
|
58
|
-
let message = `${day} ${time}`;
|
|
59
|
-
const metrics = { weather_ok: this.weather ? 1 : 0 };
|
|
60
|
-
if (this.weather) {
|
|
61
|
-
const temp = this.fahrenheit
|
|
62
|
-
? `${(0, weather_js_1.toFahrenheit)(this.weather.tempC)}°F`
|
|
63
|
-
: `${this.weather.tempC}°C`;
|
|
64
|
-
message += ` • ${temp} ${this.weather.text}`;
|
|
65
|
-
values.push({ label: 'temp', value: temp }, { label: 'condition', value: this.weather.text }, { label: 'icon', value: this.weather.icon });
|
|
66
|
-
metrics['temp_c'] = this.weather.tempC;
|
|
67
|
-
metrics['weather_code'] = this.weather.code;
|
|
68
|
-
metrics['wind_kph'] = this.weather.windKph;
|
|
69
|
-
}
|
|
70
|
-
// The receptionist is a greeter/clock — it stays healthy even when weather
|
|
71
|
-
// is unavailable (degraded time-only mode).
|
|
72
|
-
return {
|
|
73
|
-
status: 'healthy',
|
|
74
|
-
mood: 'happy',
|
|
75
|
-
message,
|
|
76
|
-
screenData: { layout: 'welcome-sign', values },
|
|
77
|
-
metrics,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
async refreshWeather() {
|
|
81
|
-
if (!this.coords) {
|
|
82
|
-
await this.resolveCoords();
|
|
83
|
-
}
|
|
84
|
-
if (!this.coords)
|
|
85
|
-
return;
|
|
86
|
-
const now = Date.now();
|
|
87
|
-
if (this.weather && now - this.lastWeatherFetch < defaults_js_1.DEFAULTS.weatherRefreshMs)
|
|
88
|
-
return;
|
|
89
|
-
const w = await (0, weather_js_1.fetchWeather)(this.coords, defaults_js_1.DEFAULTS.fetchTimeoutMs);
|
|
90
|
-
if (w) {
|
|
91
|
-
this.weather = w;
|
|
92
|
-
this.lastWeatherFetch = now;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
async resolveCoords() {
|
|
96
|
-
if (this.explicitCoords) {
|
|
97
|
-
this.coords = this.explicitCoords;
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
// Only attempt geocoding/geo-IP once per (failed) resolution to avoid
|
|
101
|
-
// hammering the keyless endpoints every poll.
|
|
102
|
-
if (this.geoAttempted)
|
|
103
|
-
return;
|
|
104
|
-
this.geoAttempted = true;
|
|
105
|
-
if (this.location) {
|
|
106
|
-
this.coords = await (0, weather_js_1.geocodeLocation)(this.location, defaults_js_1.DEFAULTS.fetchTimeoutMs);
|
|
107
|
-
if (this.coords)
|
|
108
|
-
return;
|
|
109
|
-
this.logger.warn(`Could not geocode "${this.location}", falling back to IP geo`);
|
|
110
|
-
}
|
|
111
|
-
this.coords = await (0, weather_js_1.autoGeo)(defaults_js_1.DEFAULTS.fetchTimeoutMs);
|
|
112
|
-
if (!this.coords) {
|
|
113
|
-
this.logger.warn('Weather geo unavailable — running in time-only mode');
|
|
114
|
-
// Allow a later retry.
|
|
115
|
-
this.geoAttempted = false;
|
|
116
|
-
}
|
|
28
|
+
// The server handles conversation and movement. Heartbeats stay quiet.
|
|
29
|
+
return { status: 'healthy', mood: 'happy', message: '', metrics: {} };
|
|
117
30
|
}
|
|
118
31
|
}
|
|
119
32
|
exports.ReceptionistNPC = ReceptionistNPC;
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Keyless weather + geolocation via open-meteo, with ipapi.co IP fallback.
|
|
4
|
-
* All network helpers return null on failure so the receptionist can degrade
|
|
5
|
-
* to a time-only mode without erroring.
|
|
6
|
-
*/
|
|
7
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
3
|
exports.describeWmo = describeWmo;
|
|
4
|
+
exports.validCoords = validCoords;
|
|
9
5
|
exports.geocodeLocation = geocodeLocation;
|
|
10
6
|
exports.autoGeo = autoGeo;
|
|
11
7
|
exports.fetchWeather = fetchWeather;
|
|
12
8
|
exports.toFahrenheit = toFahrenheit;
|
|
13
9
|
exports.formatClock = formatClock;
|
|
10
|
+
/**
|
|
11
|
+
* Keyless weather + geolocation via open-meteo, with an optional ipapi.co IP
|
|
12
|
+
* fallback. Every call is time- and size-bounded, validates the (untrusted)
|
|
13
|
+
* response, and returns null on failure so the receptionist can degrade to a
|
|
14
|
+
* time-only mode without erroring.
|
|
15
|
+
*/
|
|
16
|
+
const http_js_1 = require("../../core/http.js");
|
|
17
|
+
const text_js_1 = require("../../core/text.js");
|
|
14
18
|
// WMO weather-interpretation codes → human text + a glyph keyword the
|
|
15
19
|
// welcome-sign renderer understands (matched by /sun|clear|partly|rain|snow|storm|fog/).
|
|
16
20
|
const WMO = {
|
|
@@ -47,65 +51,56 @@ const WMO = {
|
|
|
47
51
|
function describeWmo(code) {
|
|
48
52
|
return WMO[code] ?? { text: 'Unknown', icon: 'cloud' };
|
|
49
53
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
signal: controller.signal,
|
|
56
|
-
headers: { 'User-Agent': 'nocville-npc' },
|
|
57
|
-
});
|
|
58
|
-
if (!res.ok)
|
|
59
|
-
return null;
|
|
60
|
-
return (await res.json());
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
finally {
|
|
66
|
-
clearTimeout(timer);
|
|
67
|
-
}
|
|
54
|
+
/** A finite, in-range latitude/longitude pair. */
|
|
55
|
+
function validCoords(lat, lon) {
|
|
56
|
+
const la = (0, http_js_1.finiteNumber)(lat);
|
|
57
|
+
const lo = (0, http_js_1.finiteNumber)(lon);
|
|
58
|
+
return la !== null && lo !== null && Math.abs(la) <= 90 && Math.abs(lo) <= 180;
|
|
68
59
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(name)}&count=1`;
|
|
72
|
-
const res = await fetchJson(url, timeoutMs);
|
|
73
|
-
const first = res?.['results']?.[0];
|
|
74
|
-
if (!first || typeof first['latitude'] !== 'number')
|
|
60
|
+
function coordsFrom(rec, labelKey) {
|
|
61
|
+
if (!validCoords(rec['latitude'], rec['longitude']))
|
|
75
62
|
return null;
|
|
63
|
+
const label = rec[labelKey];
|
|
76
64
|
return {
|
|
77
|
-
lat:
|
|
78
|
-
lon:
|
|
79
|
-
label:
|
|
65
|
+
lat: rec['latitude'],
|
|
66
|
+
lon: rec['longitude'],
|
|
67
|
+
label: typeof label === 'string' ? (0, text_js_1.sanitizeText)(label, 60) : undefined,
|
|
80
68
|
};
|
|
81
69
|
}
|
|
82
|
-
/**
|
|
70
|
+
/** Resolve a place name to coordinates via the open-meteo geocoding API. */
|
|
71
|
+
async function geocodeLocation(name, timeoutMs = 5000) {
|
|
72
|
+
const url = 'https://geocoding-api.open-meteo.com/v1/search' +
|
|
73
|
+
`?name=${encodeURIComponent(name.slice(0, 100))}&count=1`;
|
|
74
|
+
const results = (0, http_js_1.asRecord)(await (0, http_js_1.fetchJson)(url, { timeoutMs }))?.['results'];
|
|
75
|
+
const first = Array.isArray(results) ? (0, http_js_1.asRecord)(results[0]) : null;
|
|
76
|
+
return first ? coordsFrom(first, 'name') : null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Best-effort IP geolocation via ipapi.co (keyless). This sends the machine's
|
|
80
|
+
* public IP to a third party, so the receptionist only calls it when no place
|
|
81
|
+
* was configured and the user hasn't opted out. Only the coordinates are used;
|
|
82
|
+
* nothing from the response is pushed to Nocville.
|
|
83
|
+
*/
|
|
83
84
|
async function autoGeo(timeoutMs = 5000) {
|
|
84
|
-
const res = await fetchJson('https://ipapi.co/json/', timeoutMs);
|
|
85
|
-
|
|
86
|
-
return null;
|
|
87
|
-
return {
|
|
88
|
-
lat: res['latitude'],
|
|
89
|
-
lon: res['longitude'],
|
|
90
|
-
label: res['city'],
|
|
91
|
-
};
|
|
85
|
+
const res = (0, http_js_1.asRecord)(await (0, http_js_1.fetchJson)('https://ipapi.co/json/', { timeoutMs }));
|
|
86
|
+
return res ? coordsFrom(res, 'city') : null;
|
|
92
87
|
}
|
|
93
88
|
/** Fetch current temperature, weather code, and wind for coordinates. */
|
|
94
89
|
async function fetchWeather(coords, timeoutMs = 5000) {
|
|
95
90
|
const url = `https://api.open-meteo.com/v1/forecast?latitude=${coords.lat}` +
|
|
96
91
|
`&longitude=${coords.lon}¤t=temperature_2m,weather_code,wind_speed_10m`;
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
if (!cur ||
|
|
92
|
+
const cur = (0, http_js_1.asRecord)((0, http_js_1.asRecord)(await (0, http_js_1.fetchJson)(url, { timeoutMs }))?.['current']);
|
|
93
|
+
const temp = (0, http_js_1.finiteNumber)(cur?.['temperature_2m']);
|
|
94
|
+
if (!cur || temp === null || Math.abs(temp) > 100)
|
|
100
95
|
return null;
|
|
101
|
-
const code =
|
|
96
|
+
const code = (0, http_js_1.finiteNumber)(cur['weather_code']) ?? -1;
|
|
102
97
|
const { text, icon } = describeWmo(code);
|
|
103
98
|
return {
|
|
104
|
-
tempC: Math.round(
|
|
99
|
+
tempC: Math.round(temp),
|
|
105
100
|
code,
|
|
106
101
|
text,
|
|
107
102
|
icon,
|
|
108
|
-
windKph: Math.round(
|
|
103
|
+
windKph: Math.round(Math.max(0, (0, http_js_1.finiteNumber)(cur['wind_speed_10m']) ?? 0)),
|
|
109
104
|
};
|
|
110
105
|
}
|
|
111
106
|
/** Convert Celsius to Fahrenheit, rounded. */
|