@nocville/npc 0.2.3 → 0.3.1

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 (40) hide show
  1. package/README.md +36 -15
  2. package/dist/cjs/cli.js +44 -8
  3. package/dist/cjs/core/base-npc.js +7 -0
  4. package/dist/cjs/core/polling-npc.js +8 -0
  5. package/dist/cjs/index.js +13 -1
  6. package/dist/cjs/npcs/agent-common/shim.js +8 -2
  7. package/dist/cjs/npcs/github-watcher/github.js +109 -0
  8. package/dist/cjs/npcs/office-characters/index.js +102 -0
  9. package/dist/cjs/npcs/process-watcher/index.js +0 -1
  10. package/dist/cjs/npcs/process-watcher/process-scanner.js +42 -4
  11. package/dist/cjs/npcs/receptionist/index.js +6 -119
  12. package/dist/cjs/npcs/security/index.js +26 -0
  13. package/dist/cjs/npcs/web-page-watcher/basic.js +58 -0
  14. package/dist/cjs/npcs/web-page-watcher/capture.js +26 -0
  15. package/dist/cjs/npcs/web-page-watcher/chrome.js +62 -0
  16. package/dist/cli.js +44 -8
  17. package/dist/core/base-npc.js +7 -0
  18. package/dist/core/polling-npc.d.ts +3 -0
  19. package/dist/core/polling-npc.js +8 -0
  20. package/dist/core/types.d.ts +5 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.js +2 -0
  23. package/dist/npcs/agent-common/shim.js +8 -2
  24. package/dist/npcs/github-watcher/github.d.ts +39 -0
  25. package/dist/npcs/github-watcher/github.js +103 -0
  26. package/dist/npcs/office-characters/index.d.ts +55 -0
  27. package/dist/npcs/office-characters/index.js +91 -0
  28. package/dist/npcs/process-watcher/index.js +1 -2
  29. package/dist/npcs/process-watcher/process-scanner.d.ts +3 -1
  30. package/dist/npcs/process-watcher/process-scanner.js +41 -4
  31. package/dist/npcs/receptionist/index.d.ts +2 -21
  32. package/dist/npcs/receptionist/index.js +6 -119
  33. package/dist/npcs/security/index.d.ts +12 -0
  34. package/dist/npcs/security/index.js +22 -0
  35. package/dist/npcs/web-page-watcher/basic.d.ts +16 -0
  36. package/dist/npcs/web-page-watcher/basic.js +52 -0
  37. package/dist/npcs/web-page-watcher/capture.js +26 -0
  38. package/dist/npcs/web-page-watcher/chrome.d.ts +46 -0
  39. package/dist/npcs/web-page-watcher/chrome.js +57 -0
  40. package/package.json +2 -2
package/README.md CHANGED
@@ -140,15 +140,24 @@ 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
+ | `slightly-inappropriate-hr` | People skills. Questionable discretion. | `nocville-npc slightly-inappropriate-hr` |
159
+ | `github-watcher` | New commits on a commit board | `nocville-npc github-watcher --repo=owner/name` |
160
+ | `web-page-watcher` | A web page rendered as a pixel-art TV | `nocville-npc web-page-watcher --url=https://…` |
152
161
 
153
162
  Run `nocville-npc --help` to see every flag. Poll intervals below 5 seconds are raised to 5
154
163
  seconds. A few notes:
@@ -160,16 +169,28 @@ seconds. A few notes:
160
169
  `--latency-hosts` take comma-separated lists; latency targets accept `host:port` or
161
170
  `[ipv6]:port`. `--timeout=<ms>` bounds each check (default 5000).
162
171
  - **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.
172
+ but `sh` does not match `bash`. Works on macOS and Linux via `ps`, and Windows via
173
+ PowerShell/CIM performance counters. Windows accepts names with or without `.exe` and
174
+ reports CPU as a percentage of total machine capacity. Full-path matching is available
175
+ where the OS reports paths (macOS); use executable names on Windows and Linux.
165
176
  - **log-watcher**: `--error-pattern="ERROR|FATAL" --window=300 --error-warn=5 --error-crit=20`.
166
177
  Files are followed across log rotation and may be created after the watcher starts. Lines
167
178
  shown in the office have anything that looks like a secret (tokens, passwords, keys, URL
168
179
  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.
180
+ - **receptionist**: claims a free desk in a room named Lobby, Reception, Foyer or Entrance
181
+ (or its assigned work room). Without a desk, uses an open spot. Greets each arrival,
182
+ checks on a teammate every 2–4 minutes and makes a desk-side joke every 1–2 minutes.
183
+ No screen, weather requests or geolocation; old weather options are accepted but ignored.
184
+ - **security**: continuously visits rooms and reachable outdoor areas, pausing beside
185
+ exterior doors for 15–30 seconds. Does not claim a desk. An explicit work-room rule
186
+ still confines its route, so leave it unassigned for office-wide rounds.
187
+ - **office characters**: `janitor`, `barista`, `office-cat`, `office-dog`,
188
+ `plant-caretaker`, `it-technician`, `out-of-touch-executive`, and `slightly-inappropriate-hr` run without screens or
189
+ external service credentials. They share the usual API key, name, service ID and map
190
+ options. Run each as its own process. Their routes and conversation are synchronized by
191
+ the server, and work-room rules apply. Missing furniture or coworkers cause quiet walks
192
+ or floor rests instead. The technician's reboots and executive's requests are pretend.
193
+ Animals have custom walk, sleep and tail animations; staff carry their tools or coffee.
173
194
  - **github-watcher**: uses ETag polling. Set `GITHUB_TOKEN` for private repos and a higher rate
174
195
  limit (`--token` also works, but flags are visible to other users in the process list).
175
196
  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,16 @@ 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
94
+ slightly-inappropriate-hr Desk-based HR with questionable people skills
86
95
  github-watcher Call out new commits on a repo
87
96
  web-page-watcher Render a watched web page as a pixel-art TV
88
97
 
@@ -137,13 +146,8 @@ Log Watcher Options:
137
146
  --error-crit=<n> Error count for critical status (default: 20)
138
147
  --report-interval=<ms> Report interval in ms (default: 15000)
139
148
 
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)
149
+ Office Character Options:
150
+ --poll-interval=<ms> Heartbeat interval in ms (default: 60000)
147
151
 
148
152
  GitHub Watcher Options:
149
153
  --repo=<owner/name> Repository to watch (required)
@@ -396,6 +400,38 @@ async function main() {
396
400
  await npc.start();
397
401
  break;
398
402
  }
403
+ case 'security': {
404
+ if (!config.serviceId)
405
+ config.serviceId = 'security';
406
+ const { SecurityNPC } = await Promise.resolve().then(() => __importStar(require('./npcs/security/index.js')));
407
+ const npc = new SecurityNPC({
408
+ ...config,
409
+ pollInterval: (0, config_js_1.numberFlag)(parsed.flags, 'poll-interval'),
410
+ });
411
+ logger.info('Starting security NPC');
412
+ await npc.start();
413
+ break;
414
+ }
415
+ case 'janitor':
416
+ case 'barista':
417
+ case 'office-cat':
418
+ case 'office-dog':
419
+ case 'plant-caretaker':
420
+ case 'it-technician':
421
+ case 'out-of-touch-executive':
422
+ case 'slightly-inappropriate-hr': {
423
+ if (!config.serviceId)
424
+ config.serviceId = parsed.command;
425
+ const { OFFICE_CHARACTER_NPCS } = await Promise.resolve().then(() => __importStar(require('./npcs/office-characters/index.js')));
426
+ const NPCClass = OFFICE_CHARACTER_NPCS[parsed.command];
427
+ const npc = new NPCClass({
428
+ ...config,
429
+ pollInterval: (0, config_js_1.numberFlag)(parsed.flags, 'poll-interval'),
430
+ });
431
+ logger.info(`Starting ${parsed.command} NPC`);
432
+ await npc.start();
433
+ break;
434
+ }
399
435
  case 'github-watcher': {
400
436
  const repo = (0, config_js_1.stringFlag)(parsed.flags, 'repo');
401
437
  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
  }
@@ -23,8 +23,10 @@ class PollingNPC extends base_npc_js_1.BaseNPC {
23
23
  lastPushed = null;
24
24
  lastPushAt = 0;
25
25
  pollInterval;
26
+ onCollection;
26
27
  constructor(config) {
27
28
  super(config);
29
+ this.onCollection = config.onCollection;
28
30
  const requested = config.pollInterval ?? 15000;
29
31
  this.pollInterval = Number.isFinite(requested)
30
32
  ? Math.max(exports.MIN_POLL_INTERVAL_MS, requested)
@@ -117,6 +119,12 @@ class PollingNPC extends base_npc_js_1.BaseNPC {
117
119
  // Stopped mid-collection: never push after (or race) the offline push.
118
120
  if (this.state !== 'running')
119
121
  return;
122
+ try {
123
+ this.onCollection?.(metrics);
124
+ }
125
+ catch {
126
+ /* Diagnostics cannot stop publishing. */
127
+ }
120
128
  await this.pushMetrics(metrics);
121
129
  this.lastPushed = metrics;
122
130
  this.lastPushAt = Date.now();
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.SlightlyInappropriateHRNPC = 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,16 @@ 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, "SlightlyInappropriateHRNPC", { enumerable: true, get: function () { return index_js_13.SlightlyInappropriateHRNPC; } });
114
+ Object.defineProperty(exports, "OFFICE_CHARACTER_NPCS", { enumerable: true, get: function () { return index_js_13.OFFICE_CHARACTER_NPCS; } });
103
115
  //# 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,109 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.fetchCommits = fetchCommits;
4
+ exports.commitsWithin = commitsWithin;
5
+ exports.relativeTime = relativeTime;
6
+ exports.newCommitsSince = newCommitsSince;
7
+ function toNum(v) {
8
+ if (v === null)
9
+ return null;
10
+ const n = Number(v);
11
+ return Number.isNaN(n) ? null : n;
12
+ }
13
+ /**
14
+ * Fetch recent commits from the GitHub REST API using conditional requests.
15
+ * Passing the previous ETag yields a 304 (which does not count against the
16
+ * unauthenticated 60/hour limit) when nothing has changed. Never throws —
17
+ * network errors surface as `status: 0`.
18
+ */
19
+ async function fetchCommits(repo, opts = {}) {
20
+ const perPage = opts.perPage ?? 10;
21
+ const url = `https://api.github.com/repos/${repo}/commits?per_page=${perPage}`;
22
+ const headers = {
23
+ Accept: 'application/vnd.github+json',
24
+ 'User-Agent': 'nocville-npc',
25
+ 'X-GitHub-Api-Version': '2022-11-28',
26
+ };
27
+ if (opts.token)
28
+ headers['Authorization'] = `Bearer ${opts.token}`;
29
+ if (opts.etag)
30
+ headers['If-None-Match'] = opts.etag;
31
+ const controller = new AbortController();
32
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 5000);
33
+ try {
34
+ const res = await fetch(url, { headers, signal: controller.signal });
35
+ const rateRemaining = toNum(res.headers.get('x-ratelimit-remaining'));
36
+ const etag = res.headers.get('etag');
37
+ if (res.status === 304) {
38
+ return { notModified: true, commits: [], etag: opts.etag ?? null, rateRemaining, status: 304 };
39
+ }
40
+ if (!res.ok) {
41
+ // Drain the body so the socket can be reused.
42
+ await res.text().catch(() => undefined);
43
+ return { notModified: false, commits: [], etag, rateRemaining, status: res.status };
44
+ }
45
+ const body = (await res.json());
46
+ const commits = body.map((c) => parseCommit(c));
47
+ return { notModified: false, commits, etag, rateRemaining, status: res.status };
48
+ }
49
+ catch {
50
+ return { notModified: false, commits: [], etag: null, rateRemaining: null, status: 0 };
51
+ }
52
+ finally {
53
+ clearTimeout(timer);
54
+ }
55
+ }
56
+ function parseCommit(c) {
57
+ const commit = c['commit'] ?? {};
58
+ const commitAuthor = commit['author'] ?? {};
59
+ const ghAuthor = c['author'] ?? {};
60
+ const message = String(commit['message'] ?? '').split('\n')[0] ?? '';
61
+ return {
62
+ sha: String(c['sha'] ?? ''),
63
+ author: String(commitAuthor['name'] ?? ghAuthor['login'] ?? 'unknown'),
64
+ message,
65
+ date: String(commitAuthor['date'] ?? ''),
66
+ };
67
+ }
68
+ /** Count how many commits landed within the last `hours`. */
69
+ function commitsWithin(commits, hours, now = Date.now()) {
70
+ const cutoff = now - hours * 3600_000;
71
+ return commits.filter((c) => {
72
+ const t = Date.parse(c.date);
73
+ return Number.isFinite(t) && t >= cutoff;
74
+ }).length;
75
+ }
76
+ /** Short relative-time string like "3m ago", "2h ago", "1d ago". */
77
+ function relativeTime(iso, now = Date.now()) {
78
+ const t = Date.parse(iso);
79
+ if (!Number.isFinite(t))
80
+ return 'unknown';
81
+ const secs = Math.max(0, Math.round((now - t) / 1000));
82
+ if (secs < 60)
83
+ return `${secs}s ago`;
84
+ const mins = Math.round(secs / 60);
85
+ if (mins < 60)
86
+ return `${mins}m ago`;
87
+ const hrs = Math.round(mins / 60);
88
+ if (hrs < 24)
89
+ return `${hrs}h ago`;
90
+ const days = Math.round(hrs / 24);
91
+ return `${days}d ago`;
92
+ }
93
+ /**
94
+ * Return the commits newer than `lastSeenSha`, newest-first. When the sha isn't
95
+ * present in the window (more than a page of new commits, or rewritten history)
96
+ * every fetched commit is treated as new. An undefined sha means "not seeded
97
+ * yet" — returns empty so the caller can seed without a spurious announcement.
98
+ */
99
+ function newCommitsSince(commits, lastSeenSha) {
100
+ if (!lastSeenSha)
101
+ return [];
102
+ const idx = commits.findIndex((c) => c.sha === lastSeenSha);
103
+ if (idx === 0)
104
+ return []; // newest is already seen
105
+ if (idx === -1)
106
+ return commits; // fell out of the window — treat all as new
107
+ return commits.slice(0, idx);
108
+ }
109
+ //# sourceMappingURL=github.js.map
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OFFICE_CHARACTER_NPCS = exports.SlightlyInappropriateHRNPC = 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
+ class SlightlyInappropriateHRNPC extends OfficeCharacterNPC {
84
+ get name() {
85
+ return 'slightly-inappropriate-hr';
86
+ }
87
+ get defaultDisplayName() {
88
+ return 'Slightly Inappropriate HR';
89
+ }
90
+ }
91
+ exports.SlightlyInappropriateHRNPC = SlightlyInappropriateHRNPC;
92
+ exports.OFFICE_CHARACTER_NPCS = {
93
+ janitor: JanitorNPC,
94
+ barista: BaristaNPC,
95
+ 'office-cat': OfficeCatNPC,
96
+ 'office-dog': OfficeDogNPC,
97
+ 'plant-caretaker': PlantCaretakerNPC,
98
+ 'it-technician': ITTechnicianNPC,
99
+ 'out-of-touch-executive': OutOfTouchExecutiveNPC,
100
+ 'slightly-inappropriate-hr': SlightlyInappropriateHRNPC,
101
+ };
102
+ //# 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());