@nocville/npc 0.2.3 → 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 CHANGED
@@ -140,15 +140,23 @@ on your map automatically and despawns when you press Ctrl-C. Once you've run
140
140
  # --debug Verbose logging
141
141
  ```
142
142
 
143
- | NPC | What it does | Example |
144
- | ----------------------- | -------------------------------------------------- | ------------------------------------------------------ |
145
- | `system-monitor` | CPU / memory / disk / load with sparklines | `nocville-npc system-monitor --cpu-warn=75` |
146
- | `internet-connectivity` | DNS, HTTP and TCP latency checks | `nocville-npc internet-connectivity` |
147
- | `process-watcher` | Watches named processes, calls out deaths | `nocville-npc process-watcher --processes=nginx,redis` |
148
- | `log-watcher` | Error bursts in log files, last lines on a monitor | `nocville-npc log-watcher --files=/var/log/app.log` |
149
- | `receptionist` | Time and weather at the entrance, greets players | `nocville-npc receptionist --location="Berlin"` |
150
- | `github-watcher` | New commits on a commit board | `nocville-npc github-watcher --repo=owner/name` |
151
- | `web-page-watcher` | A web page rendered as a pixel-art TV | `nocville-npc web-page-watcher --url=https://…` |
143
+ | NPC | What it does | Example |
144
+ | ------------------------ | ---------------------------------------------------------- | ------------------------------------------------------ |
145
+ | `system-monitor` | CPU / memory / disk / load with sparklines | `nocville-npc system-monitor --cpu-warn=75` |
146
+ | `internet-connectivity` | DNS, HTTP and TCP latency checks | `nocville-npc internet-connectivity` |
147
+ | `process-watcher` | Watches named processes, calls out deaths | `nocville-npc process-watcher --processes=nginx,redis` |
148
+ | `log-watcher` | Error bursts in log files, last lines on a monitor | `nocville-npc log-watcher --files=/var/log/app.log` |
149
+ | `receptionist` | Lobby greetings, check-ins and front desk jokes | `nocville-npc receptionist` |
150
+ | `security` | Indoor/outdoor patrols and exterior door watch | `nocville-npc security` |
151
+ | `janitor` | Mops suspicious spots and complains about bugs everywhere. | `nocville-npc janitor` |
152
+ | `barista` | Your coffee order is getting a little out of hand. | `nocville-npc barista` |
153
+ | `office-cat` | Your desk belongs to the cat now. | `nocville-npc office-cat` |
154
+ | `office-dog` | Chief morale officer. Easily distracted. | `nocville-npc office-dog` |
155
+ | `plant-caretaker` | Every fern deserves a performance review. | `nocville-npc plant-caretaker` |
156
+ | `it-technician` | Have you tried turning it off and on again? | `nocville-npc it-technician` |
157
+ | `out-of-touch-executive` | Big ideas. Long coffee breaks. Your overtime. | `nocville-npc out-of-touch-executive` |
158
+ | `github-watcher` | New commits on a commit board | `nocville-npc github-watcher --repo=owner/name` |
159
+ | `web-page-watcher` | A web page rendered as a pixel-art TV | `nocville-npc web-page-watcher --url=https://…` |
152
160
 
153
161
  Run `nocville-npc --help` to see every flag. Poll intervals below 5 seconds are raised to 5
154
162
  seconds. A few notes:
@@ -160,16 +168,28 @@ seconds. A few notes:
160
168
  `--latency-hosts` take comma-separated lists; latency targets accept `host:port` or
161
169
  `[ipv6]:port`. `--timeout=<ms>` bounds each check (default 5000).
162
170
  - **process-watcher**: matches executable names, so `--processes=python` also finds `python3.11`
163
- but `sh` does not match `bash`. Use a full path to match one binary exactly. macOS and Linux
164
- only.
171
+ but `sh` does not match `bash`. Works on macOS and Linux via `ps`, and Windows via
172
+ PowerShell/CIM performance counters. Windows accepts names with or without `.exe` and
173
+ reports CPU as a percentage of total machine capacity. Full-path matching is available
174
+ where the OS reports paths (macOS); use executable names on Windows and Linux.
165
175
  - **log-watcher**: `--error-pattern="ERROR|FATAL" --window=300 --error-warn=5 --error-crit=20`.
166
176
  Files are followed across log rotation and may be created after the watcher starts. Lines
167
177
  shown in the office have anything that looks like a secret (tokens, passwords, keys, URL
168
178
  credentials) redacted, but treat the screen as visible to your whole team.
169
- - **receptionist**: weather comes from the keyless open-meteo API. Set the place with
170
- `--lat=52.5 --lon=13.4` or `--location=…`. Without either, it estimates your location from
171
- your public IP via ipapi.co; pass `--no-ip-geo` to turn that off and show only the time. Add
172
- `--fahrenheit` for °F.
179
+ - **receptionist**: claims a free desk in a room named Lobby, Reception, Foyer or Entrance
180
+ (or its assigned work room). Without a desk, uses an open spot. Greets each arrival,
181
+ checks on a teammate every 2–4 minutes and makes a desk-side joke every 1–2 minutes.
182
+ No screen, weather requests or geolocation; old weather options are accepted but ignored.
183
+ - **security**: continuously visits rooms and reachable outdoor areas, pausing beside
184
+ exterior doors for 15–30 seconds. Does not claim a desk. An explicit work-room rule
185
+ still confines its route, so leave it unassigned for office-wide rounds.
186
+ - **office characters**: `janitor`, `barista`, `office-cat`, `office-dog`,
187
+ `plant-caretaker`, `it-technician` and `out-of-touch-executive` run without screens or
188
+ external service credentials. They share the usual API key, name, service ID and map
189
+ options. Run each as its own process. Their routes and conversation are synchronized by
190
+ the server, and work-room rules apply. Missing furniture or coworkers cause quiet walks
191
+ or floor rests instead. The technician's reboots and executive's requests are pretend.
192
+ Animals have custom walk, sleep and tail animations; staff carry their tools or coffee.
173
193
  - **github-watcher**: uses ETag polling. Set `GITHUB_TOKEN` for private repos and a higher rate
174
194
  limit (`--token` also works, but flags are visible to other users in the process list).
175
195
  Without a token it polls every 90 seconds to stay within GitHub's 60 requests per hour.
package/dist/cjs/cli.js CHANGED
@@ -82,7 +82,15 @@ Monitoring NPC types:
82
82
  internet-connectivity Monitor network connectivity
83
83
  process-watcher Watch for running processes
84
84
  log-watcher Watch log files for errors
85
- receptionist Greeter showing time + local weather
85
+ receptionist Lobby greeter with office check-ins and jokes
86
+ security Indoor/outdoor patrols and exterior door watch
87
+ janitor Mops suspicious spots and hunts office bugs
88
+ barista Takes increasingly absurd coffee orders
89
+ office-cat Naps on desks and supervises keyboards
90
+ office-dog Naps, barks at coworkers and runs around
91
+ plant-caretaker Waters plants and reviews their performance
92
+ it-technician Inspects tech gear and recommends reboots
93
+ out-of-touch-executive Interrupts coworkers between long lounge breaks
86
94
  github-watcher Call out new commits on a repo
87
95
  web-page-watcher Render a watched web page as a pixel-art TV
88
96
 
@@ -137,13 +145,8 @@ Log Watcher Options:
137
145
  --error-crit=<n> Error count for critical status (default: 20)
138
146
  --report-interval=<ms> Report interval in ms (default: 15000)
139
147
 
140
- Receptionist Options:
141
- --poll-interval=<ms> Clock tick in ms (default: 60000)
142
- --lat=<n> --lon=<n> Explicit coordinates for weather
143
- --location=<name> Place name to geocode (open-meteo)
144
- --no-ip-geo Without --lat/--lon or --location, don't look up
145
- this machine's IP location (ipapi.co); show time only
146
- --fahrenheit Show temperature in °F (default: °C)
148
+ Office Character Options:
149
+ --poll-interval=<ms> Heartbeat interval in ms (default: 60000)
147
150
 
148
151
  GitHub Watcher Options:
149
152
  --repo=<owner/name> Repository to watch (required)
@@ -396,6 +399,37 @@ async function main() {
396
399
  await npc.start();
397
400
  break;
398
401
  }
402
+ case 'security': {
403
+ if (!config.serviceId)
404
+ config.serviceId = 'security';
405
+ const { SecurityNPC } = await Promise.resolve().then(() => __importStar(require('./npcs/security/index.js')));
406
+ const npc = new SecurityNPC({
407
+ ...config,
408
+ pollInterval: (0, config_js_1.numberFlag)(parsed.flags, 'poll-interval'),
409
+ });
410
+ logger.info('Starting security NPC');
411
+ await npc.start();
412
+ break;
413
+ }
414
+ case 'janitor':
415
+ case 'barista':
416
+ case 'office-cat':
417
+ case 'office-dog':
418
+ case 'plant-caretaker':
419
+ case 'it-technician':
420
+ case 'out-of-touch-executive': {
421
+ if (!config.serviceId)
422
+ config.serviceId = parsed.command;
423
+ const { OFFICE_CHARACTER_NPCS } = await Promise.resolve().then(() => __importStar(require('./npcs/office-characters/index.js')));
424
+ const NPCClass = OFFICE_CHARACTER_NPCS[parsed.command];
425
+ const npc = new NPCClass({
426
+ ...config,
427
+ pollInterval: (0, config_js_1.numberFlag)(parsed.flags, 'poll-interval'),
428
+ });
429
+ logger.info(`Starting ${parsed.command} NPC`);
430
+ await npc.start();
431
+ break;
432
+ }
399
433
  case 'github-watcher': {
400
434
  const repo = (0, config_js_1.stringFlag)(parsed.flags, 'repo');
401
435
  if (!repo) {
@@ -131,6 +131,13 @@ class BaseNPC {
131
131
  alertConfig: { heartbeatInterval: this.heartbeatSeconds },
132
132
  map: this.config.map,
133
133
  });
134
+ // An observer cannot interrupt monitoring or retries.
135
+ try {
136
+ this.config.onPushResult?.({ success: result.success, error: result.error });
137
+ }
138
+ catch {
139
+ /* observer only */
140
+ }
134
141
  if (!result.success) {
135
142
  this.reportPushFailure(result.error);
136
143
  }
package/dist/cjs/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ReceptionistNPC = exports.LogWatcherNPC = exports.ProcessWatcherNPC = exports.InternetConnectivityNPC = exports.ensureSessionFilterTemplate = exports.sessionPublishDecision = exports.sessionFilterPath = exports.loadSessionFilter = exports.pixelDevice = exports.pixelArtFromPng = exports.encodePixelArt = exports.userConfigPath = exports.saveUserConfig = exports.loadUserConfig = exports.redactSecrets = exports.shortPath = exports.shortCommand = exports.describeToolUse = exports.CodexWatcher = exports.parseRolloutLine = exports.uninstallCodexHooks = exports.setupCodexHooks = exports.codexTransition = exports.handleCodexHookEvent = exports.handleCodexCommand = exports.claudeTransition = exports.processHookEvent = exports.uninstallHooks = exports.setupHooks = exports.handleHookEvent = exports.handleClaudeCodeCommand = exports.ClaudeCodeNPC = exports.SystemMonitorNPC = exports.pushHistory = exports.statusColor = exports.NpcConfigError = exports.sanitizeText = exports.truncate = exports.moodForStatus = exports.worstStatus = exports.evaluateThreshold = exports.numberFlag = exports.boolFlag = exports.stringFlag = exports.resolveConfig = exports.parseArgs = exports.Logger = exports.EventNPC = exports.PollingNPC = exports.BaseNPC = void 0;
4
- exports.normalizeCursorEvent = exports.isCursorInvocation = exports.CURSOR_HOOK_EVENTS = exports.installCursorHooksInto = exports.uninstallCursorHooks = exports.setupCursorHooks = exports.handleCursorHookEvent = exports.cursorServiceId = exports.CURSOR_ADAPTER = exports.CURSOR_DEFAULTS = exports.GEMINI_HOOK_EVENTS = exports.installGeminiHooksInto = exports.uninstallGeminiHooks = exports.setupGeminiHooks = exports.handleGeminiHookEvent = exports.geminiServiceId = exports.geminiTransition = exports.GEMINI_ADAPTER = exports.GEMINI_DEFAULTS = exports.WebPageWatcherNPC = exports.GitHubWatcherNPC = void 0;
4
+ exports.OFFICE_CHARACTER_NPCS = exports.OutOfTouchExecutiveNPC = exports.ITTechnicianNPC = exports.PlantCaretakerNPC = exports.OfficeDogNPC = exports.OfficeCatNPC = exports.BaristaNPC = exports.JanitorNPC = exports.SecurityNPC = exports.normalizeCursorEvent = exports.isCursorInvocation = exports.CURSOR_HOOK_EVENTS = exports.installCursorHooksInto = exports.uninstallCursorHooks = exports.setupCursorHooks = exports.handleCursorHookEvent = exports.cursorServiceId = exports.CURSOR_ADAPTER = exports.CURSOR_DEFAULTS = exports.GEMINI_HOOK_EVENTS = exports.installGeminiHooksInto = exports.uninstallGeminiHooks = exports.setupGeminiHooks = exports.handleGeminiHookEvent = exports.geminiServiceId = exports.geminiTransition = exports.GEMINI_ADAPTER = exports.GEMINI_DEFAULTS = exports.WebPageWatcherNPC = exports.GitHubWatcherNPC = void 0;
5
5
  // Core
6
6
  var base_npc_js_1 = require("./core/base-npc.js");
7
7
  Object.defineProperty(exports, "BaseNPC", { enumerable: true, get: function () { return base_npc_js_1.BaseNPC; } });
@@ -100,4 +100,15 @@ Object.defineProperty(exports, "CURSOR_HOOK_EVENTS", { enumerable: true, get: fu
100
100
  var normalize_js_1 = require("./npcs/cursor/normalize.js");
101
101
  Object.defineProperty(exports, "isCursorInvocation", { enumerable: true, get: function () { return normalize_js_1.isCursorInvocation; } });
102
102
  Object.defineProperty(exports, "normalizeCursorEvent", { enumerable: true, get: function () { return normalize_js_1.normalizeCursorEvent; } });
103
+ var index_js_12 = require("./npcs/security/index.js");
104
+ Object.defineProperty(exports, "SecurityNPC", { enumerable: true, get: function () { return index_js_12.SecurityNPC; } });
105
+ var index_js_13 = require("./npcs/office-characters/index.js");
106
+ Object.defineProperty(exports, "JanitorNPC", { enumerable: true, get: function () { return index_js_13.JanitorNPC; } });
107
+ Object.defineProperty(exports, "BaristaNPC", { enumerable: true, get: function () { return index_js_13.BaristaNPC; } });
108
+ Object.defineProperty(exports, "OfficeCatNPC", { enumerable: true, get: function () { return index_js_13.OfficeCatNPC; } });
109
+ Object.defineProperty(exports, "OfficeDogNPC", { enumerable: true, get: function () { return index_js_13.OfficeDogNPC; } });
110
+ Object.defineProperty(exports, "PlantCaretakerNPC", { enumerable: true, get: function () { return index_js_13.PlantCaretakerNPC; } });
111
+ Object.defineProperty(exports, "ITTechnicianNPC", { enumerable: true, get: function () { return index_js_13.ITTechnicianNPC; } });
112
+ Object.defineProperty(exports, "OutOfTouchExecutiveNPC", { enumerable: true, get: function () { return index_js_13.OutOfTouchExecutiveNPC; } });
113
+ Object.defineProperty(exports, "OFFICE_CHARACTER_NPCS", { enumerable: true, get: function () { return index_js_13.OFFICE_CHARACTER_NPCS; } });
103
114
  //# sourceMappingURL=index.js.map
@@ -83,13 +83,19 @@ try {
83
83
  if (neutral) process.stdout.write('{}\\n');
84
84
  `;
85
85
  function installHookShim(nodePath, cliPath) {
86
+ // Desktop ships this same CLI with Electron's Node runtime. Keep the flag in
87
+ // the launcher (never globally), inherited by flush/watchdog child processes.
88
+ const electron = !!process.versions['electron'];
86
89
  writeChanged((0, exports.RUNTIME_POINTER)(), JSON.stringify({ node: nodePath, cli: cliPath }, null, 2) + '\n', 0o600);
87
90
  writeChanged((0, node_path_1.join)((0, node_path_1.dirname)((0, exports.RUNTIME_POINTER)()), 'node-path'), nodePath + '\n', 0o600);
88
91
  writeChanged((0, node_path_1.join)((0, node_path_1.dirname)((0, exports.SHIM_PATH)()), 'launch.cjs'), LAUNCHER, 0o600);
89
- writeChanged((0, exports.SHIM_PATH)(), SHIM, 0o755);
92
+ writeChanged((0, exports.SHIM_PATH)(), electron ? SHIM.replace('exec "$NODE"', 'export ELECTRON_RUN_AS_NODE=1\nexec "$NODE"') : SHIM, 0o755);
90
93
  (0, node_fs_1.chmodSync)((0, exports.SHIM_PATH)(), 0o755);
91
94
  // Both launchers use the same bootstrap, including imported-Cursor detection.
92
- writeChanged((0, exports.SHIM_PATH)() + '.cmd', WINDOWS_SHIM.replace(/\n/g, '\r\n'), 0o755);
95
+ const windowsShim = electron
96
+ ? WINDOWS_SHIM.replace(':launch\n', ':launch\nset "ELECTRON_RUN_AS_NODE=1"\n')
97
+ : WINDOWS_SHIM;
98
+ writeChanged((0, exports.SHIM_PATH)() + '.cmd', windowsShim.replace(/\n/g, '\r\n'), 0o755);
93
99
  (0, runtime_js_1.pruneOldRuntimes)((0, node_path_1.dirname)((0, exports.RUNTIME_POINTER)()));
94
100
  return (0, exports.SHIM_PATH)();
95
101
  }
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OFFICE_CHARACTER_NPCS = exports.OutOfTouchExecutiveNPC = exports.ITTechnicianNPC = exports.PlantCaretakerNPC = exports.OfficeDogNPC = exports.OfficeCatNPC = exports.BaristaNPC = exports.JanitorNPC = void 0;
4
+ const polling_npc_js_1 = require("../../core/polling-npc.js");
5
+ /** Quiet heartbeats; the authoritative server drives routines and conversation. */
6
+ class OfficeCharacterNPC extends polling_npc_js_1.PollingNPC {
7
+ constructor(config) {
8
+ super({ ...config, pollInterval: config.pollInterval ?? 60_000 });
9
+ }
10
+ get archetype() {
11
+ return 'custom';
12
+ }
13
+ get behavior() {
14
+ return { role: this.name, activity: 'idle', idlePose: 'stand' };
15
+ }
16
+ async collect() {
17
+ return { status: 'healthy', mood: 'happy', message: '', metrics: {} };
18
+ }
19
+ }
20
+ class JanitorNPC extends OfficeCharacterNPC {
21
+ get name() {
22
+ return 'janitor';
23
+ }
24
+ get defaultDisplayName() {
25
+ return 'Janitor';
26
+ }
27
+ }
28
+ exports.JanitorNPC = JanitorNPC;
29
+ class BaristaNPC extends OfficeCharacterNPC {
30
+ get name() {
31
+ return 'barista';
32
+ }
33
+ get defaultDisplayName() {
34
+ return 'Barista';
35
+ }
36
+ }
37
+ exports.BaristaNPC = BaristaNPC;
38
+ class OfficeCatNPC extends OfficeCharacterNPC {
39
+ get name() {
40
+ return 'office-cat';
41
+ }
42
+ get defaultDisplayName() {
43
+ return 'Office Cat';
44
+ }
45
+ }
46
+ exports.OfficeCatNPC = OfficeCatNPC;
47
+ class OfficeDogNPC extends OfficeCharacterNPC {
48
+ get name() {
49
+ return 'office-dog';
50
+ }
51
+ get defaultDisplayName() {
52
+ return 'Office Dog';
53
+ }
54
+ }
55
+ exports.OfficeDogNPC = OfficeDogNPC;
56
+ class PlantCaretakerNPC extends OfficeCharacterNPC {
57
+ get name() {
58
+ return 'plant-caretaker';
59
+ }
60
+ get defaultDisplayName() {
61
+ return 'Plant Caretaker';
62
+ }
63
+ }
64
+ exports.PlantCaretakerNPC = PlantCaretakerNPC;
65
+ class ITTechnicianNPC extends OfficeCharacterNPC {
66
+ get name() {
67
+ return 'it-technician';
68
+ }
69
+ get defaultDisplayName() {
70
+ return 'IT Technician';
71
+ }
72
+ }
73
+ exports.ITTechnicianNPC = ITTechnicianNPC;
74
+ class OutOfTouchExecutiveNPC extends OfficeCharacterNPC {
75
+ get name() {
76
+ return 'out-of-touch-executive';
77
+ }
78
+ get defaultDisplayName() {
79
+ return 'Out-of-Touch Executive';
80
+ }
81
+ }
82
+ exports.OutOfTouchExecutiveNPC = OutOfTouchExecutiveNPC;
83
+ exports.OFFICE_CHARACTER_NPCS = {
84
+ janitor: JanitorNPC,
85
+ barista: BaristaNPC,
86
+ 'office-cat': OfficeCatNPC,
87
+ 'office-dog': OfficeDogNPC,
88
+ 'plant-caretaker': PlantCaretakerNPC,
89
+ 'it-technician': ITTechnicianNPC,
90
+ 'out-of-touch-executive': OutOfTouchExecutiveNPC,
91
+ };
92
+ //# sourceMappingURL=index.js.map
@@ -16,7 +16,6 @@ class ProcessWatcherNPC extends polling_npc_js_1.PollingNPC {
16
16
  cpuHistories = new Map();
17
17
  previousRunning = null;
18
18
  constructor(config) {
19
- (0, errors_js_1.assertPosix)('process-watcher', 'It relies on the POSIX `ps` command; run it under WSL/Linux, or use system-monitor instead.');
20
19
  super(config);
21
20
  this.processes = [...new Set(config.processes.map((p) => p.trim()).filter(Boolean))];
22
21
  if (this.processes.length === 0) {
@@ -4,10 +4,13 @@ exports.detectTransitions = detectTransitions;
4
4
  exports.parsePsOutput = parsePsOutput;
5
5
  exports.matchesName = matchesName;
6
6
  exports.listProcesses = listProcesses;
7
+ exports.parseWindowsProcesses = parseWindowsProcesses;
7
8
  exports.matchProcesses = matchProcesses;
8
9
  exports.scanProcesses = scanProcesses;
9
10
  const node_child_process_1 = require("node:child_process");
10
11
  const node_util_1 = require("node:util");
12
+ const node_os_1 = require("node:os");
13
+ const node_path_1 = require("node:path");
11
14
  const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
12
15
  /** Bounds on the `ps` call: a wedged or enormous process table can't stall or flood us. */
13
16
  const PS_TIMEOUT_MS = 5000;
@@ -60,10 +63,10 @@ function parsePsOutput(stdout) {
60
63
  * Unlike a substring match, `sh` does not match `bash` or `ssh-agent`.
61
64
  */
62
65
  function matchesName(comm, searchName) {
63
- if (searchName.includes('/'))
66
+ if (/[\\/]/.test(searchName))
64
67
  return comm === searchName;
65
- const base = (comm.split('/').pop() ?? comm).toLowerCase();
66
- const name = searchName.toLowerCase();
68
+ const base = (comm.split(/[\\/]/).pop() ?? comm).toLowerCase().replace(/\.exe$/, '');
69
+ const name = searchName.toLowerCase().replace(/\.exe$/, '');
67
70
  if (base === name)
68
71
  return true;
69
72
  if (name.length > LINUX_COMM_LEN && base === name.slice(0, LINUX_COMM_LEN))
@@ -72,6 +75,18 @@ function matchesName(comm, searchName) {
72
75
  }
73
76
  /** List every process. Throws if `ps` fails — callers must not read that as "all dead". */
74
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);
89
+ }
75
90
  const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,pcpu=,rss=,comm='], {
76
91
  timeout: PS_TIMEOUT_MS,
77
92
  maxBuffer: PS_MAX_BUFFER,
@@ -80,6 +95,29 @@ async function listProcesses() {
80
95
  });
81
96
  return parsePsOutput(stdout);
82
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
+ }
83
121
  /** Summarise each watched name against a process list (CPU and memory summed per name). */
84
122
  function matchProcesses(names, entries) {
85
123
  return names.map((name) => {
@@ -100,7 +138,7 @@ function matchProcesses(names, entries) {
100
138
  }
101
139
  /**
102
140
  * Scan for processes by name and return their status and resource usage.
103
- * Works on macOS and Linux via `ps`. Throws when the process list is unavailable.
141
+ * Uses `ps` on macOS/Linux and CIM on Windows. Throws when the process list is unavailable.
104
142
  */
105
143
  async function scanProcesses(names) {
106
144
  return matchProcesses(names, await listProcesses());
@@ -2,36 +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 errors_js_1 = require("../../core/errors.js");
6
- const defaults_js_1 = require("./defaults.js");
7
- const weather_js_1 = require("./weather.js");
8
5
  class ReceptionistNPC extends polling_npc_js_1.PollingNPC {
9
- explicitCoords;
10
- location;
11
- fahrenheit;
12
- ipGeo;
13
- coords = null;
14
- /** Earliest time to retry geocoding / IP geo after a failure. */
15
- nextGeoAttempt = 0;
16
- weather = null;
17
- weatherFetchedAt = 0;
18
- /** Earliest time to retry the weather after a failure. */
19
- nextWeatherAttempt = 0;
20
6
  constructor(config) {
21
- super({ ...config, pollInterval: config.pollInterval ?? defaults_js_1.DEFAULTS.pollInterval });
22
- if ((config.lat === undefined) !== (config.lon === undefined)) {
23
- throw new errors_js_1.NpcConfigError('--lat and --lon must be given together');
24
- }
25
- if (config.lat !== undefined && !(0, weather_js_1.validCoords)(config.lat, config.lon)) {
26
- throw new errors_js_1.NpcConfigError('--lat must be within ±90 and --lon within ±180');
27
- }
28
- this.explicitCoords =
29
- config.lat !== undefined && config.lon !== undefined
30
- ? { lat: config.lat, lon: config.lon }
31
- : null;
32
- this.location = config.location?.trim() || undefined;
33
- this.fahrenheit = config.fahrenheit ?? false;
34
- this.ipGeo = config.ipGeo ?? true;
7
+ super({ ...config, pollInterval: config.pollInterval ?? 60_000 });
35
8
  }
36
9
  get name() {
37
10
  return 'receptionist';
@@ -44,102 +17,16 @@ class ReceptionistNPC extends polling_npc_js_1.PollingNPC {
44
17
  }
45
18
  get behavior() {
46
19
  return {
20
+ role: 'receptionist',
21
+ activity: 'idle',
47
22
  greetPlayers: true,
48
- patrol: { everyMinutes: [15, 30], stops: 5 },
49
23
  idle: 'stay',
50
- home: { near: 'entrance' },
51
- };
52
- }
53
- get companion() {
54
- return {
55
- screenType: 'welcome-sign',
56
- size: { w: 2, h: 1 },
57
- placement: 'adjacent-wall',
58
- label: 'Welcome',
24
+ idlePose: 'sit',
59
25
  };
60
26
  }
61
27
  async collect() {
62
- await this.refreshWeather();
63
- const now = new Date();
64
- const { day, date, time } = (0, weather_js_1.formatClock)(now);
65
- const values = [
66
- { label: 'time', value: time },
67
- { label: 'day', value: day },
68
- { label: 'date', value: date },
69
- ];
70
- let message = `${day} ${time}`;
71
- const metrics = { weather_ok: this.weather ? 1 : 0 };
72
- if (this.weather) {
73
- const temp = this.fahrenheit
74
- ? `${(0, weather_js_1.toFahrenheit)(this.weather.tempC)}°F`
75
- : `${this.weather.tempC}°C`;
76
- message += ` • ${temp} ${this.weather.text}`;
77
- values.push({ label: 'temp', value: temp }, { label: 'condition', value: this.weather.text }, { label: 'icon', value: this.weather.icon });
78
- metrics['temp_c'] = this.weather.tempC;
79
- metrics['weather_code'] = this.weather.code;
80
- metrics['wind_kph'] = this.weather.windKph;
81
- }
82
- // The receptionist is a greeter/clock — it stays healthy even when weather
83
- // is unavailable (degraded time-only mode).
84
- return {
85
- status: 'healthy',
86
- mood: 'happy',
87
- message,
88
- screenData: { layout: 'welcome-sign', values },
89
- metrics,
90
- };
91
- }
92
- async refreshWeather() {
93
- const now = Date.now();
94
- if (!this.coords)
95
- await this.resolveCoords(now);
96
- // Don't show a reading that has gone stale through repeated failures.
97
- if (this.weather && now - this.weatherFetchedAt > defaults_js_1.DEFAULTS.weatherMaxAgeMs) {
98
- this.weather = null;
99
- }
100
- if (!this.coords || now < this.nextWeatherAttempt)
101
- return;
102
- if (this.weather && now - this.weatherFetchedAt < defaults_js_1.DEFAULTS.weatherRefreshMs)
103
- return;
104
- const w = await (0, weather_js_1.fetchWeather)(this.coords, defaults_js_1.DEFAULTS.fetchTimeoutMs);
105
- if (w) {
106
- this.weather = w;
107
- this.weatherFetchedAt = now;
108
- }
109
- else {
110
- this.nextWeatherAttempt = now + defaults_js_1.DEFAULTS.retryBackoffMs;
111
- }
112
- }
113
- async resolveCoords(now) {
114
- if (this.explicitCoords) {
115
- this.coords = this.explicitCoords;
116
- return;
117
- }
118
- // Back off after a failure rather than hitting the keyless endpoints every tick.
119
- if (now < this.nextGeoAttempt)
120
- return;
121
- this.nextGeoAttempt = now + defaults_js_1.DEFAULTS.retryBackoffMs;
122
- if (this.location) {
123
- this.coords = await (0, weather_js_1.geocodeLocation)(this.location, defaults_js_1.DEFAULTS.fetchTimeoutMs);
124
- if (this.coords)
125
- return;
126
- this.logger.warn(`Could not geocode "${this.location}"` + (this.ipGeo ? ', falling back to IP geo' : ''));
127
- }
128
- if (!this.ipGeo) {
129
- if (!this.location) {
130
- this.nextGeoAttempt = Infinity; // nothing to resolve, ever
131
- this.logger.info('No location set and IP geolocation disabled: time-only mode');
132
- }
133
- return;
134
- }
135
- this.coords = await (0, weather_js_1.autoGeo)(defaults_js_1.DEFAULTS.fetchTimeoutMs);
136
- if (this.coords) {
137
- this.logger.info("Weather location estimated from this machine's IP via ipapi.co " +
138
- '(set --location or --lat/--lon, or pass --no-ip-geo to disable)');
139
- }
140
- else {
141
- this.logger.warn('Weather geo unavailable — running in time-only mode for now');
142
- }
28
+ // The server handles conversation and movement. Heartbeats stay quiet.
29
+ return { status: 'healthy', mood: 'happy', message: '', metrics: {} };
143
30
  }
144
31
  }
145
32
  exports.ReceptionistNPC = ReceptionistNPC;
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SecurityNPC = void 0;
4
+ const polling_npc_js_1 = require("../../core/polling-npc.js");
5
+ class SecurityNPC extends polling_npc_js_1.PollingNPC {
6
+ constructor(config) {
7
+ super({ ...config, pollInterval: config.pollInterval ?? 60_000 });
8
+ }
9
+ get name() {
10
+ return 'security';
11
+ }
12
+ get defaultDisplayName() {
13
+ return 'Security';
14
+ }
15
+ get archetype() {
16
+ return 'custom';
17
+ }
18
+ get behavior() {
19
+ return { role: 'security', activity: 'idle', idlePose: 'stand' };
20
+ }
21
+ async collect() {
22
+ return { status: 'healthy', mood: 'happy', message: '', metrics: {} };
23
+ }
24
+ }
25
+ exports.SecurityNPC = SecurityNPC;
26
+ //# sourceMappingURL=index.js.map
package/dist/cli.js CHANGED
@@ -47,7 +47,15 @@ Monitoring NPC types:
47
47
  internet-connectivity Monitor network connectivity
48
48
  process-watcher Watch for running processes
49
49
  log-watcher Watch log files for errors
50
- receptionist Greeter showing time + local weather
50
+ receptionist Lobby greeter with office check-ins and jokes
51
+ security Indoor/outdoor patrols and exterior door watch
52
+ janitor Mops suspicious spots and hunts office bugs
53
+ barista Takes increasingly absurd coffee orders
54
+ office-cat Naps on desks and supervises keyboards
55
+ office-dog Naps, barks at coworkers and runs around
56
+ plant-caretaker Waters plants and reviews their performance
57
+ it-technician Inspects tech gear and recommends reboots
58
+ out-of-touch-executive Interrupts coworkers between long lounge breaks
51
59
  github-watcher Call out new commits on a repo
52
60
  web-page-watcher Render a watched web page as a pixel-art TV
53
61
 
@@ -102,13 +110,8 @@ Log Watcher Options:
102
110
  --error-crit=<n> Error count for critical status (default: 20)
103
111
  --report-interval=<ms> Report interval in ms (default: 15000)
104
112
 
105
- Receptionist Options:
106
- --poll-interval=<ms> Clock tick in ms (default: 60000)
107
- --lat=<n> --lon=<n> Explicit coordinates for weather
108
- --location=<name> Place name to geocode (open-meteo)
109
- --no-ip-geo Without --lat/--lon or --location, don't look up
110
- this machine's IP location (ipapi.co); show time only
111
- --fahrenheit Show temperature in °F (default: °C)
113
+ Office Character Options:
114
+ --poll-interval=<ms> Heartbeat interval in ms (default: 60000)
112
115
 
113
116
  GitHub Watcher Options:
114
117
  --repo=<owner/name> Repository to watch (required)
@@ -361,6 +364,37 @@ async function main() {
361
364
  await npc.start();
362
365
  break;
363
366
  }
367
+ case 'security': {
368
+ if (!config.serviceId)
369
+ config.serviceId = 'security';
370
+ const { SecurityNPC } = await import('./npcs/security/index.js');
371
+ const npc = new SecurityNPC({
372
+ ...config,
373
+ pollInterval: numberFlag(parsed.flags, 'poll-interval'),
374
+ });
375
+ logger.info('Starting security NPC');
376
+ await npc.start();
377
+ break;
378
+ }
379
+ case 'janitor':
380
+ case 'barista':
381
+ case 'office-cat':
382
+ case 'office-dog':
383
+ case 'plant-caretaker':
384
+ case 'it-technician':
385
+ case 'out-of-touch-executive': {
386
+ if (!config.serviceId)
387
+ config.serviceId = parsed.command;
388
+ const { OFFICE_CHARACTER_NPCS } = await import('./npcs/office-characters/index.js');
389
+ const NPCClass = OFFICE_CHARACTER_NPCS[parsed.command];
390
+ const npc = new NPCClass({
391
+ ...config,
392
+ pollInterval: numberFlag(parsed.flags, 'poll-interval'),
393
+ });
394
+ logger.info(`Starting ${parsed.command} NPC`);
395
+ await npc.start();
396
+ break;
397
+ }
364
398
  case 'github-watcher': {
365
399
  const repo = stringFlag(parsed.flags, 'repo');
366
400
  if (!repo) {
@@ -128,6 +128,13 @@ export class BaseNPC {
128
128
  alertConfig: { heartbeatInterval: this.heartbeatSeconds },
129
129
  map: this.config.map,
130
130
  });
131
+ // An observer cannot interrupt monitoring or retries.
132
+ try {
133
+ this.config.onPushResult?.({ success: result.success, error: result.error });
134
+ }
135
+ catch {
136
+ /* observer only */
137
+ }
131
138
  if (!result.success) {
132
139
  this.reportPushFailure(result.error);
133
140
  }
@@ -14,6 +14,11 @@ export interface NpcConfig {
14
14
  map?: string;
15
15
  /** Friendly display name for the NPC banner. Overrides the per-NPC default. */
16
16
  name?: string;
17
+ /** Optional local observer for publishing health; never included in status payloads. */
18
+ onPushResult?: (result: {
19
+ success: boolean;
20
+ error?: string;
21
+ }) => void;
17
22
  }
18
23
  export interface CollectedMetrics {
19
24
  status: Status;
package/dist/index.d.ts CHANGED
@@ -41,4 +41,6 @@ export type { GeminiSetupOptions } from './npcs/gemini/index.js';
41
41
  export { CURSOR_DEFAULTS, CURSOR_ADAPTER, cursorServiceId, handleCursorHookEvent, setupCursorHooks, uninstallCursorHooks, installCursorHooksInto, CURSOR_HOOK_EVENTS, } from './npcs/cursor/index.js';
42
42
  export type { CursorSetupOptions } from './npcs/cursor/index.js';
43
43
  export { isCursorInvocation, normalizeCursorEvent } from './npcs/cursor/normalize.js';
44
+ export { SecurityNPC } from './npcs/security/index.js';
45
+ export { JanitorNPC, BaristaNPC, OfficeCatNPC, OfficeDogNPC, PlantCaretakerNPC, ITTechnicianNPC, OutOfTouchExecutiveNPC, OFFICE_CHARACTER_NPCS, } from './npcs/office-characters/index.js';
44
46
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -25,4 +25,6 @@ export { WebPageWatcherNPC } from './npcs/web-page-watcher/index.js';
25
25
  export { GEMINI_DEFAULTS, GEMINI_ADAPTER, geminiTransition, geminiServiceId, handleGeminiHookEvent, setupGeminiHooks, uninstallGeminiHooks, installGeminiHooksInto, GEMINI_HOOK_EVENTS, } from './npcs/gemini/index.js';
26
26
  export { CURSOR_DEFAULTS, CURSOR_ADAPTER, cursorServiceId, handleCursorHookEvent, setupCursorHooks, uninstallCursorHooks, installCursorHooksInto, CURSOR_HOOK_EVENTS, } from './npcs/cursor/index.js';
27
27
  export { isCursorInvocation, normalizeCursorEvent } from './npcs/cursor/normalize.js';
28
+ export { SecurityNPC } from './npcs/security/index.js';
29
+ export { JanitorNPC, BaristaNPC, OfficeCatNPC, OfficeDogNPC, PlantCaretakerNPC, ITTechnicianNPC, OutOfTouchExecutiveNPC, OFFICE_CHARACTER_NPCS, } from './npcs/office-characters/index.js';
28
30
  //# sourceMappingURL=index.js.map
@@ -77,13 +77,19 @@ try {
77
77
  if (neutral) process.stdout.write('{}\\n');
78
78
  `;
79
79
  export function installHookShim(nodePath, cliPath) {
80
+ // Desktop ships this same CLI with Electron's Node runtime. Keep the flag in
81
+ // the launcher (never globally), inherited by flush/watchdog child processes.
82
+ const electron = !!process.versions['electron'];
80
83
  writeChanged(RUNTIME_POINTER(), JSON.stringify({ node: nodePath, cli: cliPath }, null, 2) + '\n', 0o600);
81
84
  writeChanged(join(dirname(RUNTIME_POINTER()), 'node-path'), nodePath + '\n', 0o600);
82
85
  writeChanged(join(dirname(SHIM_PATH()), 'launch.cjs'), LAUNCHER, 0o600);
83
- writeChanged(SHIM_PATH(), SHIM, 0o755);
86
+ writeChanged(SHIM_PATH(), electron ? SHIM.replace('exec "$NODE"', 'export ELECTRON_RUN_AS_NODE=1\nexec "$NODE"') : SHIM, 0o755);
84
87
  chmodSync(SHIM_PATH(), 0o755);
85
88
  // Both launchers use the same bootstrap, including imported-Cursor detection.
86
- writeChanged(SHIM_PATH() + '.cmd', WINDOWS_SHIM.replace(/\n/g, '\r\n'), 0o755);
89
+ const windowsShim = electron
90
+ ? WINDOWS_SHIM.replace(':launch\n', ':launch\nset "ELECTRON_RUN_AS_NODE=1"\n')
91
+ : WINDOWS_SHIM;
92
+ writeChanged(SHIM_PATH() + '.cmd', windowsShim.replace(/\n/g, '\r\n'), 0o755);
87
93
  pruneOldRuntimes(dirname(RUNTIME_POINTER()));
88
94
  return SHIM_PATH();
89
95
  }
@@ -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
@@ -1,7 +1,7 @@
1
1
  import { PollingNPC } from '../../core/polling-npc.js';
2
2
  import { evaluateThreshold, worstStatus, moodForStatus } from '../../core/thresholds.js';
3
3
  import { statusColor, pushHistory } from '../../core/screen.js';
4
- import { NpcConfigError, assertPosix } from '../../core/errors.js';
4
+ import { NpcConfigError } from '../../core/errors.js';
5
5
  import { scanProcesses, detectTransitions } from './process-scanner.js';
6
6
  import { DEFAULTS } from './defaults.js';
7
7
  /** Max per-process panels on the companion dashboard. */
@@ -13,7 +13,6 @@ export class ProcessWatcherNPC extends PollingNPC {
13
13
  cpuHistories = new Map();
14
14
  previousRunning = null;
15
15
  constructor(config) {
16
- assertPosix('process-watcher', 'It relies on the POSIX `ps` command; run it under WSL/Linux, or use system-monitor instead.');
17
16
  super(config);
18
17
  this.processes = [...new Set(config.processes.map((p) => p.trim()).filter(Boolean))];
19
18
  if (this.processes.length === 0) {
@@ -37,11 +37,13 @@ export declare function parsePsOutput(stdout: string): PsEntry[];
37
37
  export declare function matchesName(comm: string, searchName: string): boolean;
38
38
  /** List every process. Throws if `ps` fails — callers must not read that as "all dead". */
39
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[];
40
42
  /** Summarise each watched name against a process list (CPU and memory summed per name). */
41
43
  export declare function matchProcesses(names: string[], entries: PsEntry[]): ProcessInfo[];
42
44
  /**
43
45
  * Scan for processes by name and return their status and resource usage.
44
- * Works on macOS and Linux via `ps`. Throws when the process list is unavailable.
46
+ * Uses `ps` on macOS/Linux and CIM on Windows. Throws when the process list is unavailable.
45
47
  */
46
48
  export declare function scanProcesses(names: string[]): Promise<ProcessInfo[]>;
47
49
  //# sourceMappingURL=process-scanner.d.ts.map
@@ -1,5 +1,7 @@
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);
4
6
  /** Bounds on the `ps` call: a wedged or enormous process table can't stall or flood us. */
5
7
  const PS_TIMEOUT_MS = 5000;
@@ -52,10 +54,10 @@ export function parsePsOutput(stdout) {
52
54
  * Unlike a substring match, `sh` does not match `bash` or `ssh-agent`.
53
55
  */
54
56
  export function matchesName(comm, searchName) {
55
- if (searchName.includes('/'))
57
+ if (/[\\/]/.test(searchName))
56
58
  return comm === searchName;
57
- const base = (comm.split('/').pop() ?? comm).toLowerCase();
58
- const name = searchName.toLowerCase();
59
+ const base = (comm.split(/[\\/]/).pop() ?? comm).toLowerCase().replace(/\.exe$/, '');
60
+ const name = searchName.toLowerCase().replace(/\.exe$/, '');
59
61
  if (base === name)
60
62
  return true;
61
63
  if (name.length > LINUX_COMM_LEN && base === name.slice(0, LINUX_COMM_LEN))
@@ -64,6 +66,18 @@ export function matchesName(comm, searchName) {
64
66
  }
65
67
  /** List every process. Throws if `ps` fails — callers must not read that as "all dead". */
66
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);
80
+ }
67
81
  const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,pcpu=,rss=,comm='], {
68
82
  timeout: PS_TIMEOUT_MS,
69
83
  maxBuffer: PS_MAX_BUFFER,
@@ -72,6 +86,29 @@ export async function listProcesses() {
72
86
  });
73
87
  return parsePsOutput(stdout);
74
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
+ }
75
112
  /** Summarise each watched name against a process list (CPU and memory summed per name). */
76
113
  export function matchProcesses(names, entries) {
77
114
  return names.map((name) => {
@@ -92,7 +129,7 @@ export function matchProcesses(names, entries) {
92
129
  }
93
130
  /**
94
131
  * 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.
132
+ * Uses `ps` on macOS/Linux and CIM on Windows. Throws when the process list is unavailable.
96
133
  */
97
134
  export async function scanProcesses(names) {
98
135
  return matchProcesses(names, await listProcesses());
@@ -1,39 +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
- /**
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
10
  ipGeo?: boolean;
16
11
  }
17
12
  export declare class ReceptionistNPC extends PollingNPC {
18
- private explicitCoords;
19
- private location?;
20
- private fahrenheit;
21
- private ipGeo;
22
- private coords;
23
- /** Earliest time to retry geocoding / IP geo after a failure. */
24
- private nextGeoAttempt;
25
- private weather;
26
- private weatherFetchedAt;
27
- /** Earliest time to retry the weather after a failure. */
28
- private nextWeatherAttempt;
29
13
  constructor(config: ReceptionistConfig);
30
14
  get name(): string;
31
15
  protected get defaultDisplayName(): string;
32
16
  get archetype(): Archetype;
33
17
  get behavior(): BehaviorHints;
34
- get companion(): CompanionDecl;
35
18
  protected collect(): Promise<CollectedMetrics>;
36
- private refreshWeather;
37
- private resolveCoords;
38
19
  }
39
20
  //# sourceMappingURL=index.d.ts.map
@@ -1,34 +1,7 @@
1
1
  import { PollingNPC } from '../../core/polling-npc.js';
2
- import { NpcConfigError } from '../../core/errors.js';
3
- import { DEFAULTS } from './defaults.js';
4
- import { fetchWeather, geocodeLocation, autoGeo, formatClock, toFahrenheit, validCoords, } from './weather.js';
5
2
  export class ReceptionistNPC extends PollingNPC {
6
- explicitCoords;
7
- location;
8
- fahrenheit;
9
- ipGeo;
10
- coords = null;
11
- /** Earliest time to retry geocoding / IP geo after a failure. */
12
- nextGeoAttempt = 0;
13
- weather = null;
14
- weatherFetchedAt = 0;
15
- /** Earliest time to retry the weather after a failure. */
16
- nextWeatherAttempt = 0;
17
3
  constructor(config) {
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
- }
25
- this.explicitCoords =
26
- config.lat !== undefined && config.lon !== undefined
27
- ? { lat: config.lat, lon: config.lon }
28
- : null;
29
- this.location = config.location?.trim() || undefined;
30
- this.fahrenheit = config.fahrenheit ?? false;
31
- this.ipGeo = config.ipGeo ?? true;
4
+ super({ ...config, pollInterval: config.pollInterval ?? 60_000 });
32
5
  }
33
6
  get name() {
34
7
  return 'receptionist';
@@ -41,102 +14,16 @@ export class ReceptionistNPC extends PollingNPC {
41
14
  }
42
15
  get behavior() {
43
16
  return {
17
+ role: 'receptionist',
18
+ activity: 'idle',
44
19
  greetPlayers: true,
45
- patrol: { everyMinutes: [15, 30], stops: 5 },
46
20
  idle: 'stay',
47
- home: { near: 'entrance' },
48
- };
49
- }
50
- get companion() {
51
- return {
52
- screenType: 'welcome-sign',
53
- size: { w: 2, h: 1 },
54
- placement: 'adjacent-wall',
55
- label: 'Welcome',
21
+ idlePose: 'sit',
56
22
  };
57
23
  }
58
24
  async collect() {
59
- await this.refreshWeather();
60
- const now = new Date();
61
- const { day, date, time } = formatClock(now);
62
- const values = [
63
- { label: 'time', value: time },
64
- { label: 'day', value: day },
65
- { label: 'date', value: date },
66
- ];
67
- let message = `${day} ${time}`;
68
- const metrics = { weather_ok: this.weather ? 1 : 0 };
69
- if (this.weather) {
70
- const temp = this.fahrenheit
71
- ? `${toFahrenheit(this.weather.tempC)}°F`
72
- : `${this.weather.tempC}°C`;
73
- message += ` • ${temp} ${this.weather.text}`;
74
- values.push({ label: 'temp', value: temp }, { label: 'condition', value: this.weather.text }, { label: 'icon', value: this.weather.icon });
75
- metrics['temp_c'] = this.weather.tempC;
76
- metrics['weather_code'] = this.weather.code;
77
- metrics['wind_kph'] = this.weather.windKph;
78
- }
79
- // The receptionist is a greeter/clock — it stays healthy even when weather
80
- // is unavailable (degraded time-only mode).
81
- return {
82
- status: 'healthy',
83
- mood: 'happy',
84
- message,
85
- screenData: { layout: 'welcome-sign', values },
86
- metrics,
87
- };
88
- }
89
- async refreshWeather() {
90
- const now = Date.now();
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)
98
- return;
99
- if (this.weather && now - this.weatherFetchedAt < DEFAULTS.weatherRefreshMs)
100
- return;
101
- const w = await fetchWeather(this.coords, DEFAULTS.fetchTimeoutMs);
102
- if (w) {
103
- this.weather = w;
104
- this.weatherFetchedAt = now;
105
- }
106
- else {
107
- this.nextWeatherAttempt = now + DEFAULTS.retryBackoffMs;
108
- }
109
- }
110
- async resolveCoords(now) {
111
- if (this.explicitCoords) {
112
- this.coords = this.explicitCoords;
113
- return;
114
- }
115
- // Back off after a failure rather than hitting the keyless endpoints every tick.
116
- if (now < this.nextGeoAttempt)
117
- return;
118
- this.nextGeoAttempt = now + DEFAULTS.retryBackoffMs;
119
- if (this.location) {
120
- this.coords = await geocodeLocation(this.location, DEFAULTS.fetchTimeoutMs);
121
- if (this.coords)
122
- return;
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;
131
- }
132
- this.coords = await autoGeo(DEFAULTS.fetchTimeoutMs);
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');
139
- }
25
+ // The server handles conversation and movement. Heartbeats stay quiet.
26
+ return { status: 'healthy', mood: 'happy', message: '', metrics: {} };
140
27
  }
141
28
  }
142
29
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,12 @@
1
+ import { PollingNPC } from '../../core/polling-npc.js';
2
+ import type { PollingNpcConfig } from '../../core/polling-npc.js';
3
+ import type { CollectedMetrics, Archetype, BehaviorHints } from '../../core/types.js';
4
+ export declare class SecurityNPC extends PollingNPC {
5
+ constructor(config: PollingNpcConfig);
6
+ get name(): string;
7
+ protected get defaultDisplayName(): string;
8
+ get archetype(): Archetype;
9
+ get behavior(): BehaviorHints;
10
+ protected collect(): Promise<CollectedMetrics>;
11
+ }
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,22 @@
1
+ import { PollingNPC } from '../../core/polling-npc.js';
2
+ export class SecurityNPC extends PollingNPC {
3
+ constructor(config) {
4
+ super({ ...config, pollInterval: config.pollInterval ?? 60_000 });
5
+ }
6
+ get name() {
7
+ return 'security';
8
+ }
9
+ get defaultDisplayName() {
10
+ return 'Security';
11
+ }
12
+ get archetype() {
13
+ return 'custom';
14
+ }
15
+ get behavior() {
16
+ return { role: 'security', activity: 'idle', idlePose: 'stand' };
17
+ }
18
+ async collect() {
19
+ return { status: 'healthy', mood: 'happy', message: '', metrics: {} };
20
+ }
21
+ }
22
+ //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocville/npc",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Put your Claude Code and Codex sessions (and your servers) in the Nocville office as live NPCs",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -24,7 +24,7 @@
24
24
  ],
25
25
  "dependencies": {
26
26
  "pngjs": "^7.0.0",
27
- "@nocville/node": "^0.2.3"
27
+ "@nocville/node": "^0.3.0"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "puppeteer-core": "*"