@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
@@ -2,14 +2,23 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.WebPageWatcherNPC = void 0;
4
4
  const polling_npc_js_1 = require("../../core/polling-npc.js");
5
+ const thresholds_js_1 = require("../../core/thresholds.js");
6
+ const screen_js_1 = require("../../core/screen.js");
7
+ const errors_js_1 = require("../../core/errors.js");
8
+ const http_js_1 = require("../../core/http.js");
5
9
  const defaults_js_1 = require("./defaults.js");
6
10
  const pixelate_js_1 = require("./pixelate.js");
7
11
  const capture_js_1 = require("./capture.js");
8
12
  const basic_mode_js_1 = require("./basic-mode.js");
13
+ /** Thrown when a capture exceeds its deadline (the browser is then recycled). */
14
+ class CaptureTimeout extends Error {
15
+ }
9
16
  class WebPageWatcherNPC extends polling_npc_js_1.PollingNPC {
10
17
  url;
18
+ host;
11
19
  mode;
12
20
  chromePath;
21
+ chromeSandbox;
13
22
  viewportW;
14
23
  viewportH;
15
24
  screenW;
@@ -17,7 +26,10 @@ class WebPageWatcherNPC extends polling_npc_js_1.PollingNPC {
17
26
  waitUntil;
18
27
  puppeteer = null;
19
28
  browser = null;
20
- browserUnavailable = false;
29
+ /** puppeteer-core or Chrome is missing: never retried. */
30
+ browserMissing = false;
31
+ /** Chrome failed to launch: retry after this time (epoch ms). */
32
+ nextLaunchAttempt = 0;
21
33
  chromeHintShown = false;
22
34
  lastHash = 0;
23
35
  changedPollsLeft = 0;
@@ -27,22 +39,41 @@ class WebPageWatcherNPC extends polling_npc_js_1.PollingNPC {
27
39
  // Floor the poll interval — screenshots are expensive.
28
40
  pollInterval: Math.max(config.pollInterval ?? defaults_js_1.DEFAULTS.pollInterval, defaults_js_1.DEFAULTS.minPollInterval),
29
41
  });
42
+ // The page is rendered and shown to the whole office: http(s) only, never
43
+ // file:, data:, chrome: or javascript: URLs.
44
+ if (!(0, http_js_1.isHttpUrl)(config.url)) {
45
+ throw new errors_js_1.NpcConfigError('--url must be an http(s) URL');
46
+ }
30
47
  this.url = config.url;
31
- this.mode = config.mode ?? 'auto';
48
+ this.host = (0, basic_mode_js_1.hostnameOf)(config.url);
49
+ const mode = config.mode ?? 'auto';
50
+ if (!defaults_js_1.WATCH_MODES.includes(mode)) {
51
+ throw new errors_js_1.NpcConfigError(`--mode must be one of ${defaults_js_1.WATCH_MODES.join(', ')}`);
52
+ }
53
+ this.mode = mode;
54
+ const waitUntil = config.waitUntil ?? defaults_js_1.DEFAULTS.waitUntil;
55
+ if (!defaults_js_1.WAIT_UNTIL.includes(waitUntil)) {
56
+ throw new errors_js_1.NpcConfigError(`--wait-until must be one of ${defaults_js_1.WAIT_UNTIL.join(', ')}`);
57
+ }
58
+ this.waitUntil = waitUntil;
32
59
  this.chromePath = config.chromePath;
60
+ this.chromeSandbox = config.chromeSandbox ?? true;
33
61
  this.screenW = config.screenWidth ?? defaults_js_1.DEFAULTS.screenW;
34
62
  this.screenH = config.screenHeight ?? defaults_js_1.DEFAULTS.screenH;
35
63
  // Derive the viewport from the screen size for native downscaling unless the
36
64
  // user overrides it (in which case pngToPixelImage box-filters to screen).
37
65
  this.viewportW = config.viewportWidth ?? this.screenW * defaults_js_1.DEFAULTS.scaleDivisor;
38
66
  this.viewportH = config.viewportHeight ?? this.screenH * defaults_js_1.DEFAULTS.scaleDivisor;
39
- this.waitUntil = config.waitUntil ?? defaults_js_1.DEFAULTS.waitUntil;
67
+ const sizeProblem = (0, defaults_js_1.screenSizeProblem)(this.screenW, this.screenH) ??
68
+ (0, defaults_js_1.viewportProblem)(this.viewportW, this.viewportH);
69
+ if (sizeProblem)
70
+ throw new errors_js_1.NpcConfigError(sizeProblem);
40
71
  }
41
72
  get name() {
42
73
  return 'web-page-watcher';
43
74
  }
44
75
  get defaultDisplayName() {
45
- return (0, basic_mode_js_1.hostnameOf)(this.url);
76
+ return this.host;
46
77
  }
47
78
  get archetype() {
48
79
  return 'custom';
@@ -52,43 +83,58 @@ class WebPageWatcherNPC extends polling_npc_js_1.PollingNPC {
52
83
  screenType: 'web-view',
53
84
  size: { w: 4, h: 3 },
54
85
  placement: 'adjacent-wall',
55
- label: (0, basic_mode_js_1.hostnameOf)(this.url),
86
+ label: this.host,
56
87
  };
57
88
  }
58
89
  async collect() {
59
90
  if (this.mode === 'basic')
60
- return this.collectBasic();
91
+ return this.collectBasic(false);
61
92
  const browser = await this.ensureBrowser();
62
93
  if (!browser) {
63
- // 'auto' silently degrades; explicit 'screenshot' warns once.
64
94
  this.maybeShowChromeHint();
65
- return this.collectBasic();
95
+ return this.collectBasic(true);
96
+ }
97
+ try {
98
+ return await this.withDeadline(this.collectScreenshot(browser));
99
+ }
100
+ catch (err) {
101
+ if (this.state !== 'running')
102
+ throw err; // shutting down: nothing to report
103
+ // A crashed or wedged browser: recycle it and report this cycle from a plain fetch.
104
+ this.logger.warn('Screenshot failed; using a basic check this cycle', {
105
+ error: err instanceof Error ? err.message : String(err),
106
+ });
107
+ await this.closeBrowser();
108
+ return this.collectBasic(true);
66
109
  }
67
- return this.collectScreenshot(browser);
68
110
  }
69
111
  async onStop() {
112
+ // Close Chrome first: it aborts an in-flight capture, so the base class
113
+ // doesn't wait out a slow page load before pushing offline.
114
+ await this.closeBrowser();
70
115
  await super.onStop();
71
- if (this.browser) {
72
- try {
73
- await this.browser.close();
74
- }
75
- catch {
76
- // best effort
77
- }
78
- this.browser = null;
79
- }
80
116
  }
81
117
  // --- Screenshot mode ---
118
+ withDeadline(work) {
119
+ const ms = defaults_js_1.DEFAULTS.navTimeoutMs + defaults_js_1.DEFAULTS.captureGraceMs;
120
+ let timer;
121
+ const deadline = new Promise((_, reject) => {
122
+ timer = setTimeout(() => reject(new CaptureTimeout(`capture exceeded ${ms}ms`)), ms);
123
+ });
124
+ return Promise.race([work, deadline]).finally(() => clearTimeout(timer));
125
+ }
82
126
  async collectScreenshot(browser) {
83
127
  const page = await browser.newPage();
84
128
  try {
85
- // deviceScaleFactor 0.125 makes Chrome rasterize the viewport down to the
86
- // screen size natively (e.g. 1280×800 → 160×100); pngToPixelImage
129
+ // A page that alert()s would block navigation and the screenshot.
130
+ page.on?.('dialog', (dialog) => void dialog.dismiss().catch(() => undefined));
131
+ // deviceScaleFactor 1/8 makes Chrome rasterize the viewport down to the
132
+ // screen size natively (e.g. 1280×960 → 160×120); pngToPixelImage
87
133
  // box-filters as a fallback if it doesn't.
88
134
  await page.setViewport({
89
135
  width: this.viewportW,
90
136
  height: this.viewportH,
91
- deviceScaleFactor: 0.125,
137
+ deviceScaleFactor: 1 / defaults_js_1.DEFAULTS.scaleDivisor,
92
138
  });
93
139
  let httpStatus = 0;
94
140
  let timedOut = false;
@@ -116,15 +162,14 @@ class WebPageWatcherNPC extends polling_npc_js_1.PollingNPC {
116
162
  else if (this.changedPollsLeft > 0)
117
163
  this.changedPollsLeft--;
118
164
  const status = (0, basic_mode_js_1.statusFromHttp)(httpStatus, timedOut);
119
- const host = (0, basic_mode_js_1.hostnameOf)(this.url);
120
165
  const message = changed
121
166
  ? 'Page updated!'
122
167
  : httpStatus
123
- ? `${host} ${httpStatus < 400 ? 'OK' : 'ERR'} (${httpStatus}, ${loadMs}ms)`
124
- : `${host} unreachable`;
168
+ ? `${this.host} ${httpStatus < 400 ? 'OK' : 'ERR'} (${httpStatus}, ${loadMs}ms)`
169
+ : `${this.host} ${timedOut ? 'timed out' : 'unreachable'}`;
125
170
  return {
126
171
  status,
127
- mood: moodFor(status),
172
+ mood: (0, thresholds_js_1.moodForStatus)(status),
128
173
  message,
129
174
  subStatus: this.changedPollsLeft > 0 ? 'changed' : undefined,
130
175
  screenData,
@@ -141,74 +186,92 @@ class WebPageWatcherNPC extends polling_npc_js_1.PollingNPC {
141
186
  }
142
187
  }
143
188
  async ensureBrowser() {
144
- if (this.browserUnavailable)
189
+ if (this.browserMissing || Date.now() < this.nextLaunchAttempt)
145
190
  return null;
146
191
  if (this.browser && (0, capture_js_1.browserConnected)(this.browser))
147
192
  return this.browser;
148
- if (this.browser)
149
- this.browser = null; // disconnected — relaunch
150
- if (!this.puppeteer) {
151
- this.puppeteer = await (0, capture_js_1.loadPuppeteer)();
152
- }
193
+ this.browser = null; // absent or disconnected — (re)launch
194
+ this.puppeteer ??= await (0, capture_js_1.loadPuppeteer)();
153
195
  const chromePath = (0, capture_js_1.findChrome)(this.chromePath);
154
196
  if (!this.puppeteer || !chromePath) {
155
- this.browserUnavailable = true;
197
+ this.browserMissing = true;
156
198
  return null;
157
199
  }
200
+ const { args, sandboxed } = (0, capture_js_1.chromeArgs)({
201
+ sandbox: this.chromeSandbox,
202
+ isRoot: (0, capture_js_1.runningAsRoot)(),
203
+ });
204
+ if (!sandboxed) {
205
+ this.logger.warn('Chrome sandbox disabled' +
206
+ (this.chromeSandbox ? ' (running as root)' : '') +
207
+ ': only watch pages you trust');
208
+ }
158
209
  try {
159
- this.browser = await this.puppeteer.launch({
210
+ const browser = await this.puppeteer.launch({
160
211
  executablePath: chromePath,
161
212
  headless: true,
162
- args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu', '--hide-scrollbars'],
213
+ args,
163
214
  });
164
- this.browser.on('disconnected', () => {
165
- this.browser = null;
215
+ browser.on('disconnected', () => {
216
+ if (this.browser === browser)
217
+ this.browser = null;
166
218
  });
167
- return this.browser;
219
+ // Stopped while Chrome was starting: don't leave it running.
220
+ if (this.state !== 'running') {
221
+ await browser.close().catch(() => undefined);
222
+ return null;
223
+ }
224
+ this.browser = browser;
225
+ return browser;
168
226
  }
169
227
  catch (err) {
170
- this.logger.warn('Failed to launch Chrome', {
228
+ this.logger.warn('Failed to launch Chrome; retrying in 10 minutes', {
171
229
  error: err instanceof Error ? err.message : String(err),
172
230
  });
173
- this.browserUnavailable = true;
231
+ this.nextLaunchAttempt = Date.now() + defaults_js_1.DEFAULTS.relaunchBackoffMs;
174
232
  return null;
175
233
  }
176
234
  }
235
+ async closeBrowser() {
236
+ const browser = this.browser;
237
+ this.browser = null;
238
+ if (browser)
239
+ await browser.close().catch(() => undefined);
240
+ }
177
241
  maybeShowChromeHint() {
178
- if (this.chromeHintShown)
242
+ if (this.chromeHintShown || !this.browserMissing)
179
243
  return;
180
244
  this.chromeHintShown = true;
181
245
  this.logger.warn('Chrome/puppeteer-core unavailable — falling back to basic mode. ' +
182
246
  'Install `puppeteer-core` and Google Chrome (or set --chrome-path/CHROME_PATH) for the pixel-art TV.');
183
247
  }
184
248
  // --- Basic mode ---
185
- async collectBasic() {
249
+ /**
250
+ * Plain fetch: HTTP status + page title on a ticker. When this is a fallback
251
+ * from screenshot mode (no Chrome), a healthy page is reported as a warning
252
+ * to nudge the Chrome setup; an explicit `--mode=basic` reports it healthy.
253
+ */
254
+ async collectBasic(fallback) {
186
255
  const loadStart = Date.now();
187
- const check = await (0, basic_mode_js_1.basicCheck)(this.url);
256
+ const check = await (0, basic_mode_js_1.basicCheck)(this.url, defaults_js_1.DEFAULTS.navTimeoutMs);
188
257
  const loadMs = Date.now() - loadStart;
189
- // Basic mode can't render the pixel TV, so it never reports fully healthy —
190
- // a healthy HTTP result is downgraded to a warning to nudge Chrome setup.
191
- let status = (0, basic_mode_js_1.statusFromHttp)(check.status, check.timedOut);
192
- if (status === 'healthy')
193
- status = 'warning';
194
- const host = (0, basic_mode_js_1.hostnameOf)(this.url);
195
- const message = check.title ? `${host}: ${check.title}` : `${host} (${check.status || 'ERR'})`;
258
+ const httpStatus = (0, basic_mode_js_1.statusFromHttp)(check.status, check.timedOut);
259
+ const status = fallback && httpStatus === 'healthy' ? 'warning' : httpStatus;
260
+ const message = check.title
261
+ ? `${this.host}: ${check.title}`
262
+ : `${this.host} (${check.status || (check.timedOut ? 'timed out' : 'ERR')})`;
196
263
  return {
197
264
  status,
198
- mood: moodFor(status),
265
+ mood: (0, thresholds_js_1.moodForStatus)(status),
199
266
  message,
200
267
  screenData: {
201
268
  layout: 'ticker',
202
269
  values: [
203
- { label: 'URL', value: host },
270
+ { label: 'URL', value: this.host },
204
271
  {
205
272
  label: 'HTTP',
206
273
  value: check.status ? String(check.status) : 'ERR',
207
- color: check.status >= 200 && check.status < 400
208
- ? 'green'
209
- : check.status >= 500 || check.status === 0
210
- ? 'red'
211
- : 'amber',
274
+ color: (0, screen_js_1.statusColor)(httpStatus),
212
275
  },
213
276
  { label: 'Title', value: check.title || '(no title)' },
214
277
  { label: 'Mode', value: 'basic' },
@@ -219,16 +282,4 @@ class WebPageWatcherNPC extends polling_npc_js_1.PollingNPC {
219
282
  }
220
283
  }
221
284
  exports.WebPageWatcherNPC = WebPageWatcherNPC;
222
- function moodFor(status) {
223
- switch (status) {
224
- case 'healthy':
225
- return 'happy';
226
- case 'warning':
227
- return 'concerned';
228
- case 'critical':
229
- return 'stressed';
230
- default:
231
- return 'idle';
232
- }
233
- }
234
285
  //# sourceMappingURL=index.js.map
@@ -176,6 +176,7 @@ function pngToPixelImage(pngBuffer, maxColors = 32, targetW = exports.TARGET_W,
176
176
  const { palette, indices } = quantize(rgba, w * h, maxColors);
177
177
  const screenData = buildPixelImageScreen(w, h, palette, indices);
178
178
  const byteSize = screenData.values.reduce((sum, v) => sum + v.value.length, 0);
179
- return { screenData, byteSize, hash: fnv1a(indices) };
179
+ // Hash the palette too: a recolour with the same layout is still a change.
180
+ return { screenData, byteSize, hash: fnv1a(Buffer.concat([palette, indices])) };
180
181
  }
181
182
  //# sourceMappingURL=pixelate.js.map
package/dist/cli.js CHANGED
@@ -2,7 +2,8 @@
2
2
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
- import { parseArgs, resolveConfig, stringFlag, numberFlag, boolFlag } from './core/config.js';
5
+ import { parseArgs, resolveConfig, stringFlag, numberFlag, boolFlag, listFlag, } from './core/config.js';
6
+ import { NpcConfigError } from './core/errors.js';
6
7
  import { Logger } from './core/logger.js';
7
8
  import { ensureSessionFilterTemplate, loadSessionFilter, sessionPublishDecision, } from './core/session-filter.js';
8
9
  // NPC modules are imported lazily per command: `claude-code hook` / `codex hook`
@@ -46,7 +47,15 @@ Monitoring NPC types:
46
47
  internet-connectivity Monitor network connectivity
47
48
  process-watcher Watch for running processes
48
49
  log-watcher Watch log files for errors
49
- 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
50
59
  github-watcher Call out new commits on a repo
51
60
  web-page-watcher Render a watched web page as a pixel-art TV
52
61
 
@@ -77,17 +86,23 @@ Internet Connectivity Options:
77
86
  --poll-interval=<ms> Poll interval in ms (default: 30000)
78
87
  --dns-hosts=<list> Comma-separated DNS hosts (default: google.com,cloudflare.com)
79
88
  --http-urls=<list> Comma-separated HTTP URLs to check
80
- --latency-hosts=<list> Comma-separated hosts for latency checks
89
+ --latency-hosts=<list> Comma-separated TCP latency targets: host, host:port or
90
+ [ipv6]:port (default: 8.8.8.8:443,1.1.1.1:443;
91
+ port 80 when omitted)
81
92
  --slow-threshold=<ms> Slow response threshold in ms (default: 1000)
93
+ --timeout=<ms> Per-check timeout in ms (default: 5000)
82
94
 
83
95
  Process Watcher Options:
84
96
  --poll-interval=<ms> Poll interval in ms (default: 15000)
85
- --processes=<list> Comma-separated process names to watch
97
+ --processes=<list> Comma-separated executable names to watch (python
98
+ also matches python3.11; a /path matches exactly)
86
99
  --cpu-warn=<n> CPU warning threshold % (default: 80)
87
100
  --mem-warn=<n> Memory warning threshold MB (default: 500)
88
101
 
89
102
  Log Watcher Options:
90
- --files=<list> Comma-separated log file paths
103
+ --files=<list> Comma-separated log file paths (followed across
104
+ rotation; files may appear later). Lines shown on
105
+ the screen have secrets redacted.
91
106
  --error-pattern=<regex> Error pattern (default: ERROR|FATAL)
92
107
  --warning-pattern=<regex> Warning pattern (default: WARN)
93
108
  --window=<seconds> Sliding window in seconds (default: 300)
@@ -95,26 +110,28 @@ Log Watcher Options:
95
110
  --error-crit=<n> Error count for critical status (default: 20)
96
111
  --report-interval=<ms> Report interval in ms (default: 15000)
97
112
 
98
- Receptionist Options:
99
- --poll-interval=<ms> Clock tick in ms (default: 60000)
100
- --lat=<n> --lon=<n> Explicit coordinates for weather
101
- --location=<name> Place name to geocode (else IP geo)
102
- --fahrenheit Show temperature in °F (default: °C)
113
+ Office Character Options:
114
+ --poll-interval=<ms> Heartbeat interval in ms (default: 60000)
103
115
 
104
116
  GitHub Watcher Options:
105
117
  --repo=<owner/name> Repository to watch (required)
106
118
  --branch=<name> Branch to watch (default: repo default branch)
107
- --token=<token> GitHub token (or GITHUB_TOKEN env)
108
- --poll-interval=<ms> Poll interval in ms (default: 60000)
119
+ --token=<token> GitHub token (prefer the GITHUB_TOKEN env: flags are
120
+ visible in the process list)
121
+ --poll-interval=<ms> Poll interval in ms (default: 60000 with a token,
122
+ 90000 without, to stay under GitHub's 60/hour)
109
123
 
110
124
  Web Page Watcher Options:
111
- --url=<url> Page to watch (required)
125
+ --url=<url> http(s) page to watch (required)
112
126
  --mode=<auto|screenshot|basic> Render mode (default: auto)
113
127
  --interval=<ms> Poll interval in ms (default: 60000, floor 15000)
114
- --viewport=<WxH> Browser viewport (default: screen x 8)
115
- --screen=<WxH> TV pixel resolution (default: 160x120; e.g. 160x100)
116
- --wait-until=<event> Puppeteer waitUntil (default: networkidle2)
128
+ --viewport=<WxH> Browser viewport (default: screen x 8, max 4096x4096)
129
+ --screen=<WxH> TV pixel resolution (default: 160x120, max 320x240)
130
+ --wait-until=<event> load, domcontentloaded, networkidle0 or networkidle2
131
+ (default: networkidle2)
117
132
  --chrome-path=<path> Chrome executable (or CHROME_PATH env)
133
+ --no-chrome-sandbox Run Chrome without its sandbox (only for trusted
134
+ pages; automatic when running as root)
118
135
  (needs puppeteer-core + Chrome for the pixel-art TV; falls back to basic)
119
136
  `.trim();
120
137
  async function main() {
@@ -275,18 +292,10 @@ async function main() {
275
292
  if (!config.serviceId) {
276
293
  config.serviceId = 'internet-connectivity';
277
294
  }
278
- const dnsHosts = stringFlag(parsed.flags, 'dns-hosts')?.split(',');
279
- const httpUrls = stringFlag(parsed.flags, 'http-urls')?.split(',');
280
- const latencyHostsRaw = stringFlag(parsed.flags, 'latency-hosts')?.split(',');
281
- const latencyHosts = latencyHostsRaw?.map((h) => ({ host: h.trim(), port: 80 }));
282
295
  const { InternetConnectivityNPC } = await import('./npcs/internet-connectivity/index.js');
283
296
  const npc = new InternetConnectivityNPC({
284
297
  ...config,
285
- pollInterval: numberFlag(parsed.flags, 'poll-interval'),
286
- dnsHosts,
287
- httpUrls,
288
- latencyHosts,
289
- slowThreshold: numberFlag(parsed.flags, 'slow-threshold'),
298
+ ...InternetConnectivityNPC.configFromFlags(parsed.flags),
290
299
  });
291
300
  logger.info('Starting internet-connectivity NPC');
292
301
  await npc.start();
@@ -296,8 +305,8 @@ async function main() {
296
305
  if (!config.serviceId) {
297
306
  config.serviceId = 'process-watcher';
298
307
  }
299
- const processesStr = stringFlag(parsed.flags, 'processes');
300
- if (!processesStr) {
308
+ const processes = listFlag(parsed.flags, 'processes');
309
+ if (!processes) {
301
310
  console.error('Error: --processes is required (comma-separated list)');
302
311
  process.exit(1);
303
312
  }
@@ -305,7 +314,7 @@ async function main() {
305
314
  const npc = new ProcessWatcherNPC({
306
315
  ...config,
307
316
  pollInterval: numberFlag(parsed.flags, 'poll-interval'),
308
- processes: processesStr.split(','),
317
+ processes,
309
318
  cpuWarn: numberFlag(parsed.flags, 'cpu-warn'),
310
319
  memWarn: numberFlag(parsed.flags, 'mem-warn'),
311
320
  });
@@ -314,8 +323,8 @@ async function main() {
314
323
  break;
315
324
  }
316
325
  case 'log-watcher': {
317
- const filesStr = stringFlag(parsed.flags, 'files');
318
- if (!filesStr) {
326
+ const files = listFlag(parsed.flags, 'files');
327
+ if (!files) {
319
328
  console.error('Error: --files is required (comma-separated list)');
320
329
  process.exit(1);
321
330
  }
@@ -325,7 +334,7 @@ async function main() {
325
334
  const { LogWatcherNPC } = await import('./npcs/log-watcher/index.js');
326
335
  const npc = new LogWatcherNPC({
327
336
  ...config,
328
- files: filesStr.split(','),
337
+ files,
329
338
  errorPattern: stringFlag(parsed.flags, 'error-pattern'),
330
339
  warningPattern: stringFlag(parsed.flags, 'warning-pattern'),
331
340
  windowSeconds: numberFlag(parsed.flags, 'window'),
@@ -349,11 +358,43 @@ async function main() {
349
358
  lon: numberFlag(parsed.flags, 'lon'),
350
359
  location: stringFlag(parsed.flags, 'location'),
351
360
  fahrenheit: boolFlag(parsed.flags, 'fahrenheit') ?? false,
361
+ ipGeo: boolFlag(parsed.flags, 'no-ip-geo') !== true,
352
362
  });
353
363
  logger.info('Starting receptionist NPC');
354
364
  await npc.start();
355
365
  break;
356
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
+ }
357
398
  case 'github-watcher': {
358
399
  const repo = stringFlag(parsed.flags, 'repo');
359
400
  if (!repo) {
@@ -365,11 +406,15 @@ async function main() {
365
406
  config.serviceId = `github-${shortName.replace(/[^a-zA-Z0-9_-]/g, '-')}`.slice(0, 100);
366
407
  }
367
408
  const { GitHubWatcherNPC } = await import('./npcs/github-watcher/index.js');
409
+ const tokenFlag = stringFlag(parsed.flags, 'token');
410
+ if (tokenFlag) {
411
+ logger.warn('--token is visible to other local users in the process list; prefer GITHUB_TOKEN');
412
+ }
368
413
  const npc = new GitHubWatcherNPC({
369
414
  ...config,
370
415
  repo,
371
416
  branch: stringFlag(parsed.flags, 'branch'),
372
- token: stringFlag(parsed.flags, 'token') ?? process.env['GITHUB_TOKEN'],
417
+ token: tokenFlag ?? process.env['GITHUB_TOKEN'],
373
418
  pollInterval: numberFlag(parsed.flags, 'poll-interval'),
374
419
  });
375
420
  logger.info('Starting github-watcher NPC');
@@ -385,22 +430,28 @@ async function main() {
385
430
  const { hostnameOf } = await import('./npcs/web-page-watcher/basic-mode.js');
386
431
  const { parseDimensions } = await import('./npcs/web-page-watcher/defaults.js');
387
432
  const { WebPageWatcherNPC } = await import('./npcs/web-page-watcher/index.js');
388
- const parseWxH = (v) => v ? (parseDimensions(v) ?? undefined) : undefined;
433
+ const parseWxH = (flag) => {
434
+ const v = stringFlag(parsed.flags, flag);
435
+ if (v === undefined)
436
+ return undefined;
437
+ const dims = parseDimensions(v);
438
+ if (!dims)
439
+ throw new NpcConfigError(`--${flag} must look like 160x120`);
440
+ return dims;
441
+ };
389
442
  if (!config.serviceId) {
390
443
  config.serviceId = `webpage-${hostnameOf(url)}`
391
444
  .replace(/[^a-zA-Z0-9_.-]/g, '-')
392
445
  .slice(0, 100);
393
446
  }
394
- const modeFlag = stringFlag(parsed.flags, 'mode');
395
- const mode = modeFlag === 'auto' || modeFlag === 'screenshot' || modeFlag === 'basic'
396
- ? modeFlag
397
- : undefined;
398
- const viewport = parseWxH(stringFlag(parsed.flags, 'viewport'));
399
- const screen = parseWxH(stringFlag(parsed.flags, 'screen'));
447
+ const viewport = parseWxH('viewport');
448
+ const screen = parseWxH('screen');
400
449
  const npc = new WebPageWatcherNPC({
401
450
  ...config,
402
451
  url,
403
- mode,
452
+ // Validated (with a clear error) by the constructor.
453
+ mode: stringFlag(parsed.flags, 'mode'),
454
+ chromeSandbox: boolFlag(parsed.flags, 'no-chrome-sandbox') !== true,
404
455
  chromePath: stringFlag(parsed.flags, 'chrome-path'),
405
456
  waitUntil: stringFlag(parsed.flags, 'wait-until'),
406
457
  viewportWidth: viewport?.width,
@@ -543,6 +594,10 @@ main().catch((err) => {
543
594
  process.stdout.write('{}\n');
544
595
  return;
545
596
  }
597
+ if (err instanceof NpcConfigError) {
598
+ console.error(`Error: ${err.message}`);
599
+ process.exit(1);
600
+ }
546
601
  console.error('Fatal error:', err);
547
602
  process.exit(1);
548
603
  });
@@ -20,6 +20,11 @@ export declare abstract class BaseNPC {
20
20
  protected get defaultDisplayName(): string | null;
21
21
  /** The banner name to send, clamped to the API's 32-char limit, or undefined. */
22
22
  private pushName;
23
+ /**
24
+ * NPC type sent as the push `kind` (groups NPCs for per-type rules such as "all process
25
+ * watchers work in the Server Room"). Defaults to `name` when it is a valid slug.
26
+ */
27
+ get kind(): string | undefined;
23
28
  /** NPC archetype — steers movement speed and default behavior. Override per NPC. */
24
29
  get archetype(): Archetype;
25
30
  /** Base behavior hints sent on every push (merged with per-cycle overrides). */
@@ -39,6 +39,13 @@ export class BaseNPC {
39
39
  const name = this.displayName;
40
40
  return name ? truncate(name, 32) : undefined;
41
41
  }
42
+ /**
43
+ * NPC type sent as the push `kind` (groups NPCs for per-type rules such as "all process
44
+ * watchers work in the Server Room"). Defaults to `name` when it is a valid slug.
45
+ */
46
+ get kind() {
47
+ return /^[a-z0-9][a-z0-9-]{0,39}$/.test(this.name) ? this.name : undefined;
48
+ }
42
49
  /** NPC archetype — steers movement speed and default behavior. Override per NPC. */
43
50
  get archetype() {
44
51
  return 'service';
@@ -113,6 +120,7 @@ export class BaseNPC {
113
120
  message: metrics.message ? truncate(metrics.message, 500) : undefined,
114
121
  metrics: metrics.metrics,
115
122
  archetype: this.archetype,
123
+ kind: this.kind,
116
124
  subStatus: metrics.subStatus,
117
125
  screenData: metrics.screenData,
118
126
  behavior: Object.keys(behavior).length > 0 ? behavior : undefined,
@@ -120,6 +128,13 @@ export class BaseNPC {
120
128
  alertConfig: { heartbeatInterval: this.heartbeatSeconds },
121
129
  map: this.config.map,
122
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
+ }
123
138
  if (!result.success) {
124
139
  this.reportPushFailure(result.error);
125
140
  }
@@ -18,5 +18,10 @@ export declare function parseArgs(argv: string[]): ParsedArgs;
18
18
  export declare function resolveConfig(flags: Record<string, string | boolean>, overrides?: Partial<NpcConfig>): NpcConfig;
19
19
  export declare function stringFlag(flags: Record<string, string | boolean>, key: string): string | undefined;
20
20
  export declare function boolFlag(flags: Record<string, string | boolean>, key: string): boolean | undefined;
21
+ /**
22
+ * A comma-separated list flag (`--processes=nginx,redis`): entries trimmed,
23
+ * empties dropped, duplicates removed. Undefined when the flag is absent or empty.
24
+ */
25
+ export declare function listFlag(flags: Record<string, string | boolean>, key: string): string[] | undefined;
21
26
  export declare function numberFlag(flags: Record<string, string | boolean>, key: string): number | undefined;
22
27
  //# sourceMappingURL=config.d.ts.map