@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.
Files changed (105) hide show
  1. package/README.md +55 -17
  2. package/dist/cjs/cli.js +94 -39
  3. package/dist/cjs/core/base-npc.js +15 -0
  4. package/dist/cjs/core/config.js +19 -2
  5. package/dist/cjs/core/errors.js +23 -0
  6. package/dist/cjs/core/http.js +121 -0
  7. package/dist/cjs/core/pixel-art.js +110 -0
  8. package/dist/cjs/core/polling-npc.js +99 -14
  9. package/dist/cjs/core/text.js +13 -0
  10. package/dist/cjs/core/thresholds.js +29 -0
  11. package/dist/cjs/index.js +21 -2
  12. package/dist/cjs/npcs/agent-common/session.js +1 -0
  13. package/dist/cjs/npcs/agent-common/shim.js +8 -2
  14. package/dist/cjs/npcs/github-watcher/defaults.js +7 -1
  15. package/dist/cjs/npcs/github-watcher/github-api.js +82 -45
  16. package/dist/cjs/npcs/github-watcher/index.js +82 -36
  17. package/dist/cjs/npcs/internet-connectivity/checks.js +70 -67
  18. package/dist/cjs/npcs/internet-connectivity/defaults.js +5 -2
  19. package/dist/cjs/npcs/internet-connectivity/index.js +134 -131
  20. package/dist/cjs/npcs/log-watcher/buffer.js +61 -0
  21. package/dist/cjs/npcs/log-watcher/index.js +109 -103
  22. package/dist/cjs/npcs/log-watcher/tail.js +100 -53
  23. package/dist/cjs/npcs/office-characters/index.js +92 -0
  24. package/dist/cjs/npcs/process-watcher/index.js +60 -51
  25. package/dist/cjs/npcs/process-watcher/process-scanner.js +100 -68
  26. package/dist/cjs/npcs/receptionist/defaults.js +4 -0
  27. package/dist/cjs/npcs/receptionist/index.js +6 -93
  28. package/dist/cjs/npcs/receptionist/weather.js +42 -47
  29. package/dist/cjs/npcs/security/index.js +26 -0
  30. package/dist/cjs/npcs/system-monitor/collectors.js +66 -17
  31. package/dist/cjs/npcs/system-monitor/index.js +52 -28
  32. package/dist/cjs/npcs/web-page-watcher/basic-mode.js +30 -14
  33. package/dist/cjs/npcs/web-page-watcher/capture.js +23 -1
  34. package/dist/cjs/npcs/web-page-watcher/defaults.js +44 -2
  35. package/dist/cjs/npcs/web-page-watcher/index.js +119 -68
  36. package/dist/cjs/npcs/web-page-watcher/pixelate.js +2 -1
  37. package/dist/cli.js +95 -40
  38. package/dist/core/base-npc.d.ts +5 -0
  39. package/dist/core/base-npc.js +15 -0
  40. package/dist/core/config.d.ts +5 -0
  41. package/dist/core/config.js +18 -2
  42. package/dist/core/errors.d.ts +11 -0
  43. package/dist/core/errors.js +18 -0
  44. package/dist/core/http.d.ts +52 -0
  45. package/dist/core/http.js +111 -0
  46. package/dist/core/pixel-art.d.ts +38 -0
  47. package/dist/core/pixel-art.js +105 -0
  48. package/dist/core/polling-npc.d.ts +22 -1
  49. package/dist/core/polling-npc.js +98 -13
  50. package/dist/core/text.d.ts +6 -0
  51. package/dist/core/text.js +12 -0
  52. package/dist/core/thresholds.d.ts +9 -1
  53. package/dist/core/thresholds.js +27 -0
  54. package/dist/core/types.d.ts +5 -0
  55. package/dist/index.d.ts +7 -2
  56. package/dist/index.js +6 -2
  57. package/dist/npcs/agent-common/session.js +1 -0
  58. package/dist/npcs/agent-common/shim.js +8 -2
  59. package/dist/npcs/github-watcher/defaults.d.ts +6 -0
  60. package/dist/npcs/github-watcher/defaults.js +7 -1
  61. package/dist/npcs/github-watcher/github-api.d.ts +21 -7
  62. package/dist/npcs/github-watcher/github-api.js +80 -45
  63. package/dist/npcs/github-watcher/index.d.ts +6 -0
  64. package/dist/npcs/github-watcher/index.js +83 -37
  65. package/dist/npcs/internet-connectivity/checks.d.ts +15 -18
  66. package/dist/npcs/internet-connectivity/checks.js +70 -67
  67. package/dist/npcs/internet-connectivity/defaults.d.ts +3 -0
  68. package/dist/npcs/internet-connectivity/defaults.js +5 -2
  69. package/dist/npcs/internet-connectivity/index.d.ts +19 -6
  70. package/dist/npcs/internet-connectivity/index.js +135 -133
  71. package/dist/npcs/log-watcher/buffer.d.ts +22 -0
  72. package/dist/npcs/log-watcher/buffer.js +58 -0
  73. package/dist/npcs/log-watcher/index.d.ts +22 -21
  74. package/dist/npcs/log-watcher/index.js +112 -106
  75. package/dist/npcs/log-watcher/tail.d.ts +23 -3
  76. package/dist/npcs/log-watcher/tail.js +101 -54
  77. package/dist/npcs/office-characters/index.d.ts +50 -0
  78. package/dist/npcs/office-characters/index.js +82 -0
  79. package/dist/npcs/process-watcher/index.d.ts +1 -1
  80. package/dist/npcs/process-watcher/index.js +61 -52
  81. package/dist/npcs/process-watcher/process-scanner.d.ts +31 -1
  82. package/dist/npcs/process-watcher/process-scanner.js +97 -70
  83. package/dist/npcs/receptionist/defaults.d.ts +4 -0
  84. package/dist/npcs/receptionist/defaults.js +4 -0
  85. package/dist/npcs/receptionist/index.d.ts +3 -12
  86. package/dist/npcs/receptionist/index.js +6 -93
  87. package/dist/npcs/receptionist/weather.d.ts +8 -6
  88. package/dist/npcs/receptionist/weather.js +39 -45
  89. package/dist/npcs/security/index.d.ts +12 -0
  90. package/dist/npcs/security/index.js +22 -0
  91. package/dist/npcs/system-monitor/collectors.d.ts +15 -3
  92. package/dist/npcs/system-monitor/collectors.js +64 -17
  93. package/dist/npcs/system-monitor/defaults.d.ts +1 -1
  94. package/dist/npcs/system-monitor/index.d.ts +2 -1
  95. package/dist/npcs/system-monitor/index.js +54 -30
  96. package/dist/npcs/web-page-watcher/basic-mode.d.ts +2 -2
  97. package/dist/npcs/web-page-watcher/basic-mode.js +30 -14
  98. package/dist/npcs/web-page-watcher/capture.d.ts +18 -0
  99. package/dist/npcs/web-page-watcher/capture.js +21 -1
  100. package/dist/npcs/web-page-watcher/defaults.d.ts +23 -0
  101. package/dist/npcs/web-page-watcher/defaults.js +41 -1
  102. package/dist/npcs/web-page-watcher/index.d.ts +18 -1
  103. package/dist/npcs/web-page-watcher/index.js +121 -70
  104. package/dist/npcs/web-page-watcher/pixelate.js +2 -1
  105. package/package.json +2 -2
@@ -0,0 +1,50 @@
1
+ import { PollingNPC } from '../../core/polling-npc.js';
2
+ import type { PollingNpcConfig } from '../../core/polling-npc.js';
3
+ import type { Archetype, BehaviorHints, CollectedMetrics } from '../../core/types.js';
4
+ /** Quiet heartbeats; the authoritative server drives routines and conversation. */
5
+ declare abstract class OfficeCharacterNPC extends PollingNPC {
6
+ constructor(config: PollingNpcConfig);
7
+ abstract get name(): NonNullable<BehaviorHints['role']>;
8
+ get archetype(): Archetype;
9
+ get behavior(): BehaviorHints;
10
+ protected collect(): Promise<CollectedMetrics>;
11
+ }
12
+ export declare class JanitorNPC extends OfficeCharacterNPC {
13
+ get name(): 'janitor';
14
+ protected get defaultDisplayName(): string;
15
+ }
16
+ export declare class BaristaNPC extends OfficeCharacterNPC {
17
+ get name(): 'barista';
18
+ protected get defaultDisplayName(): string;
19
+ }
20
+ export declare class OfficeCatNPC extends OfficeCharacterNPC {
21
+ get name(): 'office-cat';
22
+ protected get defaultDisplayName(): string;
23
+ }
24
+ export declare class OfficeDogNPC extends OfficeCharacterNPC {
25
+ get name(): 'office-dog';
26
+ protected get defaultDisplayName(): string;
27
+ }
28
+ export declare class PlantCaretakerNPC extends OfficeCharacterNPC {
29
+ get name(): 'plant-caretaker';
30
+ protected get defaultDisplayName(): string;
31
+ }
32
+ export declare class ITTechnicianNPC extends OfficeCharacterNPC {
33
+ get name(): 'it-technician';
34
+ protected get defaultDisplayName(): string;
35
+ }
36
+ export declare class OutOfTouchExecutiveNPC extends OfficeCharacterNPC {
37
+ get name(): 'out-of-touch-executive';
38
+ protected get defaultDisplayName(): string;
39
+ }
40
+ export declare const OFFICE_CHARACTER_NPCS: {
41
+ readonly janitor: typeof JanitorNPC;
42
+ readonly barista: typeof BaristaNPC;
43
+ readonly 'office-cat': typeof OfficeCatNPC;
44
+ readonly 'office-dog': typeof OfficeDogNPC;
45
+ readonly 'plant-caretaker': typeof PlantCaretakerNPC;
46
+ readonly 'it-technician': typeof ITTechnicianNPC;
47
+ readonly 'out-of-touch-executive': typeof OutOfTouchExecutiveNPC;
48
+ };
49
+ export {};
50
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,82 @@
1
+ import { PollingNPC } from '../../core/polling-npc.js';
2
+ /** Quiet heartbeats; the authoritative server drives routines and conversation. */
3
+ class OfficeCharacterNPC extends PollingNPC {
4
+ constructor(config) {
5
+ super({ ...config, pollInterval: config.pollInterval ?? 60_000 });
6
+ }
7
+ get archetype() {
8
+ return 'custom';
9
+ }
10
+ get behavior() {
11
+ return { role: this.name, activity: 'idle', idlePose: 'stand' };
12
+ }
13
+ async collect() {
14
+ return { status: 'healthy', mood: 'happy', message: '', metrics: {} };
15
+ }
16
+ }
17
+ export class JanitorNPC extends OfficeCharacterNPC {
18
+ get name() {
19
+ return 'janitor';
20
+ }
21
+ get defaultDisplayName() {
22
+ return 'Janitor';
23
+ }
24
+ }
25
+ export class BaristaNPC extends OfficeCharacterNPC {
26
+ get name() {
27
+ return 'barista';
28
+ }
29
+ get defaultDisplayName() {
30
+ return 'Barista';
31
+ }
32
+ }
33
+ export class OfficeCatNPC extends OfficeCharacterNPC {
34
+ get name() {
35
+ return 'office-cat';
36
+ }
37
+ get defaultDisplayName() {
38
+ return 'Office Cat';
39
+ }
40
+ }
41
+ export class OfficeDogNPC extends OfficeCharacterNPC {
42
+ get name() {
43
+ return 'office-dog';
44
+ }
45
+ get defaultDisplayName() {
46
+ return 'Office Dog';
47
+ }
48
+ }
49
+ export class PlantCaretakerNPC extends OfficeCharacterNPC {
50
+ get name() {
51
+ return 'plant-caretaker';
52
+ }
53
+ get defaultDisplayName() {
54
+ return 'Plant Caretaker';
55
+ }
56
+ }
57
+ export class ITTechnicianNPC extends OfficeCharacterNPC {
58
+ get name() {
59
+ return 'it-technician';
60
+ }
61
+ get defaultDisplayName() {
62
+ return 'IT Technician';
63
+ }
64
+ }
65
+ export class OutOfTouchExecutiveNPC extends OfficeCharacterNPC {
66
+ get name() {
67
+ return 'out-of-touch-executive';
68
+ }
69
+ get defaultDisplayName() {
70
+ return 'Out-of-Touch Executive';
71
+ }
72
+ }
73
+ export const OFFICE_CHARACTER_NPCS = {
74
+ janitor: JanitorNPC,
75
+ barista: BaristaNPC,
76
+ 'office-cat': OfficeCatNPC,
77
+ 'office-dog': OfficeDogNPC,
78
+ 'plant-caretaker': PlantCaretakerNPC,
79
+ 'it-technician': ITTechnicianNPC,
80
+ 'out-of-touch-executive': OutOfTouchExecutiveNPC,
81
+ };
82
+ //# sourceMappingURL=index.js.map
@@ -19,8 +19,8 @@ export declare class ProcessWatcherNPC extends PollingNPC {
19
19
  get name(): string;
20
20
  protected get defaultDisplayName(): string;
21
21
  get archetype(): Archetype;
22
- get heartbeatSeconds(): number;
23
22
  get companion(): CompanionDecl;
24
23
  protected collect(): Promise<CollectedMetrics>;
24
+ private scanFailed;
25
25
  }
26
26
  //# sourceMappingURL=index.d.ts.map
@@ -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 } 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,16 @@ 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
- }
19
16
  super(config);
20
- this.processes = config.processes;
17
+ this.processes = [...new Set(config.processes.map((p) => p.trim()).filter(Boolean))];
18
+ if (this.processes.length === 0) {
19
+ throw new NpcConfigError('process-watcher needs at least one process name (--processes)');
20
+ }
21
21
  this.cpuWarn = config.cpuWarn ?? DEFAULTS.cpuWarn;
22
22
  this.memWarn = config.memWarn ?? DEFAULTS.memWarn;
23
+ if (!(this.cpuWarn > 0) || !(this.memWarn > 0)) {
24
+ throw new NpcConfigError('--cpu-warn and --mem-warn must be positive numbers');
25
+ }
23
26
  }
24
27
  get name() {
25
28
  return 'process-watcher';
@@ -30,9 +33,6 @@ export class ProcessWatcherNPC extends PollingNPC {
30
33
  get archetype() {
31
34
  return 'service';
32
35
  }
33
- get heartbeatSeconds() {
34
- return 30;
35
- }
36
36
  get companion() {
37
37
  return {
38
38
  screenType: 'dashboard-wall',
@@ -42,22 +42,28 @@ export class ProcessWatcherNPC extends PollingNPC {
42
42
  };
43
43
  }
44
44
  async collect() {
45
- const infos = await scanProcesses(this.processes);
45
+ let infos;
46
+ try {
47
+ infos = await scanProcesses(this.processes);
48
+ }
49
+ catch (err) {
50
+ // Not knowing is different from "everything died": report unknown and
51
+ // leave the transition baseline alone so recovery doesn't say "is back".
52
+ return this.scanFailed(err);
53
+ }
46
54
  const statuses = [];
47
- const messageParts = [];
55
+ const problems = [];
48
56
  const metrics = {};
49
57
  const panels = [];
50
58
  let totalRunning = 0;
51
- let totalMissing = 0;
52
59
  for (const info of infos) {
53
60
  const safeKey = info.name.replace(/[^a-zA-Z0-9_]/g, '_');
54
61
  if (!info.running) {
55
62
  statuses.push('critical');
56
- messageParts.push(`${info.name}: NOT FOUND`);
63
+ problems.push(`${info.name} DOWN`);
57
64
  metrics[`${safeKey}_running`] = 0;
58
65
  metrics[`${safeKey}_cpu`] = 0;
59
66
  metrics[`${safeKey}_mem_mb`] = 0;
60
- totalMissing++;
61
67
  if (panels.length < MAX_PANELS) {
62
68
  panels.push({ label: info.name, value: 'DOWN', color: 'red' });
63
69
  }
@@ -65,72 +71,75 @@ export class ProcessWatcherNPC extends PollingNPC {
65
71
  }
66
72
  totalRunning++;
67
73
  const cpu = info.cpuPercent ?? 0;
74
+ const memMb = info.memMb ?? 0;
68
75
  metrics[`${safeKey}_running`] = 1;
69
76
  metrics[`${safeKey}_cpu`] = cpu;
70
- metrics[`${safeKey}_mem_mb`] = info.memMb ?? 0;
77
+ metrics[`${safeKey}_mem_mb`] = memMb;
71
78
  const history = this.cpuHistories.get(info.name) ?? [];
72
79
  pushHistory(history, cpu);
73
80
  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`);
81
+ // Single warning thresholds: no critical level for resource use.
82
+ const cpuStatus = evaluateThreshold(cpu, { warning: this.cpuWarn, critical: Infinity });
83
+ const memStatus = evaluateThreshold(memMb, { warning: this.memWarn, critical: Infinity });
84
+ const status = worstStatus([cpuStatus, memStatus]);
85
+ statuses.push(status);
86
+ if (status !== 'healthy') {
87
+ const high = [];
88
+ if (cpuStatus !== 'healthy')
89
+ high.push(`cpu ${cpu}%`);
90
+ if (memStatus !== 'healthy')
91
+ high.push(`mem ${Math.round(memMb)}MB`);
92
+ problems.push(`${info.name} high ${high.join(', ')}`);
88
93
  }
89
94
  if (panels.length < MAX_PANELS) {
90
95
  panels.push({
91
96
  label: info.name,
92
97
  value: `${cpu}%`,
93
- color: cpuHigh || memHigh ? 'amber' : 'green',
98
+ color: statusColor(status),
94
99
  sparkline: [...history],
95
100
  });
96
101
  }
97
102
  }
103
+ const totalMissing = infos.length - totalRunning;
98
104
  metrics['total_watched'] = this.processes.length;
99
105
  metrics['total_running'] = totalRunning;
100
106
  metrics['total_missing'] = totalMissing;
101
107
  // Transition speech: compare the running set to last poll's. On the first
102
108
  // poll we only seed the set (no bogus "is back" for already-running procs).
103
109
  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
- }
110
+ const transitions = this.previousRunning === null ? [] : detectTransitions(this.previousRunning, currentRunning);
108
111
  this.previousRunning = currentRunning;
109
112
  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 };
113
+ const summary = problems.length === 0
114
+ ? `All ${infos.length} running`
115
+ : `${totalRunning}/${infos.length} running · ${problems.join(' · ')}`;
116
+ // A death/return takes over the bubble this cycle so it reads as an alert.
117
+ const message = transitions.length > 0 ? transitions.join(' · ') : summary;
114
118
  return {
115
119
  status,
116
- mood,
120
+ mood: moodForStatus(status),
117
121
  message,
118
122
  subStatus: totalMissing > 0 ? 'process-down' : undefined,
119
- screenData,
123
+ screenData: { layout: 'multi-metric', values: panels },
120
124
  metrics,
121
125
  };
122
126
  }
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';
127
+ scanFailed(err) {
128
+ const reason = err instanceof Error ? err.message : String(err);
129
+ this.logger.warn('Cannot list processes', { error: reason });
130
+ return {
131
+ status: 'unknown',
132
+ mood: moodForStatus('unknown'),
133
+ message: 'Cannot list processes (ps failed)',
134
+ subStatus: 'scan-failed',
135
+ screenData: {
136
+ layout: 'multi-metric',
137
+ values: this.processes
138
+ .slice(0, MAX_PANELS)
139
+ .map((name) => ({ label: name, value: '?', color: 'gray' })),
140
+ },
141
+ metrics: { total_watched: this.processes.length },
142
+ };
134
143
  }
135
144
  }
136
145
  //# 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,37 @@ 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
+ /** Windows instance suffixes (#1, #2) distinguish copies of the same executable. */
41
+ export declare function parseWindowsProcesses(stdout: string, cores?: number): PsEntry[];
42
+ /** Summarise each watched name against a process list (CPU and memory summed per name). */
43
+ export declare function matchProcesses(names: string[], entries: PsEntry[]): ProcessInfo[];
14
44
  /**
15
45
  * 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.
46
+ * Uses `ps` on macOS/Linux and CIM on Windows. Throws when the process list is unavailable.
17
47
  */
18
48
  export declare function scanProcesses(names: string[]): Promise<ProcessInfo[]>;
19
49
  //# sourceMappingURL=process-scanner.d.ts.map
@@ -1,6 +1,13 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
+ import { cpus } from 'node:os';
4
+ import { join } from 'node:path';
3
5
  const execFileAsync = promisify(execFile);
6
+ /** Bounds on the `ps` call: a wedged or enormous process table can't stall or flood us. */
7
+ const PS_TIMEOUT_MS = 5000;
8
+ const PS_MAX_BUFFER = 16 * 1024 * 1024;
9
+ /** Linux truncates `comm` to 15 characters (TASK_COMM_LEN - 1). */
10
+ const LINUX_COMM_LEN = 15;
4
11
  /**
5
12
  * Compare the previous and current running-process sets and return speech
6
13
  * strings for any transitions: "<name> died!" when a process disappears and
@@ -19,92 +26,112 @@ export function detectTransitions(prev, curr) {
19
26
  return messages;
20
27
  }
21
28
  /**
22
- * Parse the output of `ps` into structured entries.
23
- * Expects columns: PID, %CPU, RSS (KB), COMMAND
29
+ * Parse header-less `ps -eo pid=,pcpu=,rss=,comm=` output. COMMAND is the rest
30
+ * of the line, so macOS paths with spaces ("Google Chrome") survive intact.
24
31
  */
25
- function parsePsOutput(stdout) {
26
- const lines = stdout.trim().split('\n');
27
- // Skip the header line
32
+ export function parsePsOutput(stdout) {
28
33
  const entries = [];
29
- for (let i = 1; i < lines.length; i++) {
30
- const line = lines[i].trim();
31
- if (!line)
34
+ for (const line of stdout.split('\n')) {
35
+ const m = line.match(/^\s*(\d+)\s+([\d.]+)\s+(\d+)\s+(.+?)\s*$/);
36
+ if (!m)
32
37
  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 });
38
+ entries.push({
39
+ pid: Number(m[1]),
40
+ cpu: Number(m[2]),
41
+ memKb: Number(m[3]),
42
+ comm: m[4],
43
+ });
45
44
  }
46
45
  return entries;
47
46
  }
48
47
  /**
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.
48
+ * Does a ps COMMAND entry match a watched name? Case-insensitive on the
49
+ * executable's basename:
50
+ * - exact: `nginx` matches `/usr/sbin/nginx`;
51
+ * - version suffix: `python` matches `python3` and `python3.11`;
52
+ * - Linux truncation: a name longer than 15 chars matches its 15-char prefix;
53
+ * - a name containing `/` must equal the full path (macOS reports paths).
54
+ * Unlike a substring match, `sh` does not match `bash` or `ssh-agent`.
52
55
  */
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)
56
+ export function matchesName(comm, searchName) {
57
+ if (/[\\/]/.test(searchName))
58
+ return comm === searchName;
59
+ const base = (comm.split(/[\\/]/).pop() ?? comm).toLowerCase().replace(/\.exe$/, '');
60
+ const name = searchName.toLowerCase().replace(/\.exe$/, '');
61
+ if (base === name)
57
62
  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());
63
+ if (name.length > LINUX_COMM_LEN && base === name.slice(0, LINUX_COMM_LEN))
64
+ return true;
65
+ return base.startsWith(name) && /^\d[\d.]*$/.test(base.slice(name.length));
61
66
  }
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 }));
67
+ /** List every process. Throws if `ps` fails — callers must not read that as "all dead". */
68
+ export async function listProcesses() {
69
+ if (process.platform === 'win32') {
70
+ // Fixed command, no user strings interpolated. Performance counters report a
71
+ // sampling interval (Get-Process.CPU is lifetime CPU seconds, not a percent).
72
+ const executable = join(process.env['SystemRoot'] || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
73
+ const { stdout } = await execFileAsync(executable, [
74
+ '-NoProfile',
75
+ '-NonInteractive',
76
+ '-Command',
77
+ '$ErrorActionPreference="Stop"; Get-CimInstance Win32_PerfFormattedData_PerfProc_Process | Select-Object IDProcess,Name,PercentProcessorTime,WorkingSetPrivate | ConvertTo-Json -Compress',
78
+ ], { timeout: PS_TIMEOUT_MS, maxBuffer: PS_MAX_BUFFER, windowsHide: true });
79
+ return parseWindowsProcesses(stdout, cpus().length);
84
80
  }
85
- const results = [];
86
- for (const name of names) {
87
- // Find all matching entries for this process name
81
+ const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,pcpu=,rss=,comm='], {
82
+ timeout: PS_TIMEOUT_MS,
83
+ maxBuffer: PS_MAX_BUFFER,
84
+ // Some locales print %CPU with a decimal comma ("1,5"); force "1.5".
85
+ env: { ...process.env, LC_ALL: 'C' },
86
+ });
87
+ return parsePsOutput(stdout);
88
+ }
89
+ /** Windows instance suffixes (#1, #2) distinguish copies of the same executable. */
90
+ export function parseWindowsProcesses(stdout, cores = 1) {
91
+ const value = JSON.parse(stdout.replace(/^\uFEFF/, ''));
92
+ const rows = Array.isArray(value) ? value : value ? [value] : [];
93
+ return rows.flatMap((row) => {
94
+ const pid = Number(row.IDProcess);
95
+ const cpu = Number(row.PercentProcessorTime);
96
+ const bytes = Number(row.WorkingSetPrivate);
97
+ if (!(pid > 0) ||
98
+ !Number.isFinite(cpu) ||
99
+ !Number.isFinite(bytes) ||
100
+ typeof row.Name !== 'string')
101
+ return [];
102
+ return [
103
+ {
104
+ pid,
105
+ cpu: cpu / Math.max(1, cores),
106
+ memKb: bytes / 1024,
107
+ comm: row.Name.replace(/#\d+$/, ''),
108
+ },
109
+ ];
110
+ });
111
+ }
112
+ /** Summarise each watched name against a process list (CPU and memory summed per name). */
113
+ export function matchProcesses(names, entries) {
114
+ return names.map((name) => {
88
115
  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({
116
+ if (matching.length === 0)
117
+ return { name, running: false };
118
+ const totalCpu = matching.reduce((sum, e) => sum + e.cpu, 0);
119
+ const totalMemKb = matching.reduce((sum, e) => sum + e.memKb, 0);
120
+ return {
101
121
  name,
102
122
  running: true,
103
123
  pid: matching[0].pid,
124
+ count: matching.length,
104
125
  cpuPercent: Math.round(totalCpu * 100) / 100,
105
126
  memMb: Math.round((totalMemKb / 1024) * 100) / 100,
106
- });
107
- }
108
- return results;
127
+ };
128
+ });
129
+ }
130
+ /**
131
+ * Scan for processes by name and return their status and resource usage.
132
+ * Uses `ps` on macOS/Linux and CIM on Windows. Throws when the process list is unavailable.
133
+ */
134
+ export async function scanProcesses(names) {
135
+ return matchProcesses(names, await listProcesses());
109
136
  }
110
137
  //# 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
@@ -1,29 +1,20 @@
1
1
  import { PollingNPC } from '../../core/polling-npc.js';
2
2
  import type { PollingNpcConfig } from '../../core/polling-npc.js';
3
- import type { CollectedMetrics, Archetype, BehaviorHints, CompanionDecl } from '../../core/types.js';
3
+ import type { CollectedMetrics, Archetype, BehaviorHints } from '../../core/types.js';
4
4
  export interface ReceptionistConfig extends PollingNpcConfig {
5
+ /** @deprecated Reception no longer uses weather or geolocation. Accepted for compatibility. */
5
6
  lat?: number;
6
7
  lon?: number;
7
8
  location?: string;
8
- /** Display temperature in Fahrenheit instead of Celsius. */
9
9
  fahrenheit?: boolean;
10
+ ipGeo?: boolean;
10
11
  }
11
12
  export declare class ReceptionistNPC extends PollingNPC {
12
- private explicitCoords;
13
- private location?;
14
- private fahrenheit;
15
- private coords;
16
- private geoAttempted;
17
- private weather;
18
- private lastWeatherFetch;
19
13
  constructor(config: ReceptionistConfig);
20
14
  get name(): string;
21
15
  protected get defaultDisplayName(): string;
22
16
  get archetype(): Archetype;
23
17
  get behavior(): BehaviorHints;
24
- get companion(): CompanionDecl;
25
18
  protected collect(): Promise<CollectedMetrics>;
26
- private refreshWeather;
27
- private resolveCoords;
28
19
  }
29
20
  //# sourceMappingURL=index.d.ts.map