@nocville/npc 0.2.1 → 0.2.3

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 (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -120
  3. package/dist/cjs/cli.js +55 -34
  4. package/dist/cjs/core/base-npc.js +8 -0
  5. package/dist/cjs/core/config.js +19 -2
  6. package/dist/cjs/core/errors.js +23 -0
  7. package/dist/cjs/core/http.js +121 -0
  8. package/dist/cjs/core/pixel-art.js +110 -0
  9. package/dist/cjs/core/polling-npc.js +99 -14
  10. package/dist/cjs/core/session-filter.js +3 -1
  11. package/dist/cjs/core/text.js +13 -0
  12. package/dist/cjs/core/thresholds.js +29 -0
  13. package/dist/cjs/index.js +10 -2
  14. package/dist/cjs/npcs/agent-common/session.js +1 -0
  15. package/dist/cjs/npcs/github-watcher/defaults.js +7 -1
  16. package/dist/cjs/npcs/github-watcher/github-api.js +82 -45
  17. package/dist/cjs/npcs/github-watcher/index.js +82 -36
  18. package/dist/cjs/npcs/internet-connectivity/checks.js +70 -67
  19. package/dist/cjs/npcs/internet-connectivity/defaults.js +5 -2
  20. package/dist/cjs/npcs/internet-connectivity/index.js +134 -131
  21. package/dist/cjs/npcs/log-watcher/buffer.js +61 -0
  22. package/dist/cjs/npcs/log-watcher/index.js +109 -103
  23. package/dist/cjs/npcs/log-watcher/tail.js +100 -53
  24. package/dist/cjs/npcs/process-watcher/index.js +61 -51
  25. package/dist/cjs/npcs/process-watcher/process-scanner.js +63 -69
  26. package/dist/cjs/npcs/receptionist/defaults.js +4 -0
  27. package/dist/cjs/npcs/receptionist/index.js +45 -19
  28. package/dist/cjs/npcs/receptionist/weather.js +42 -47
  29. package/dist/cjs/npcs/system-monitor/collectors.js +66 -17
  30. package/dist/cjs/npcs/system-monitor/index.js +52 -28
  31. package/dist/cjs/npcs/web-page-watcher/basic-mode.js +30 -14
  32. package/dist/cjs/npcs/web-page-watcher/capture.js +23 -1
  33. package/dist/cjs/npcs/web-page-watcher/defaults.js +44 -2
  34. package/dist/cjs/npcs/web-page-watcher/index.js +119 -68
  35. package/dist/cjs/npcs/web-page-watcher/pixelate.js +2 -1
  36. package/dist/cli.js +56 -35
  37. package/dist/core/base-npc.d.ts +5 -0
  38. package/dist/core/base-npc.js +8 -0
  39. package/dist/core/config.d.ts +5 -0
  40. package/dist/core/config.js +18 -2
  41. package/dist/core/errors.d.ts +11 -0
  42. package/dist/core/errors.js +18 -0
  43. package/dist/core/http.d.ts +52 -0
  44. package/dist/core/http.js +111 -0
  45. package/dist/core/pixel-art.d.ts +38 -0
  46. package/dist/core/pixel-art.js +105 -0
  47. package/dist/core/polling-npc.d.ts +22 -1
  48. package/dist/core/polling-npc.js +98 -13
  49. package/dist/core/session-filter.d.ts +2 -2
  50. package/dist/core/session-filter.js +3 -1
  51. package/dist/core/text.d.ts +6 -0
  52. package/dist/core/text.js +12 -0
  53. package/dist/core/thresholds.d.ts +9 -1
  54. package/dist/core/thresholds.js +27 -0
  55. package/dist/index.d.ts +5 -2
  56. package/dist/index.js +4 -2
  57. package/dist/npcs/agent-common/session.js +1 -0
  58. package/dist/npcs/github-watcher/defaults.d.ts +6 -0
  59. package/dist/npcs/github-watcher/defaults.js +7 -1
  60. package/dist/npcs/github-watcher/github-api.d.ts +21 -7
  61. package/dist/npcs/github-watcher/github-api.js +80 -45
  62. package/dist/npcs/github-watcher/index.d.ts +6 -0
  63. package/dist/npcs/github-watcher/index.js +83 -37
  64. package/dist/npcs/internet-connectivity/checks.d.ts +15 -18
  65. package/dist/npcs/internet-connectivity/checks.js +70 -67
  66. package/dist/npcs/internet-connectivity/defaults.d.ts +3 -0
  67. package/dist/npcs/internet-connectivity/defaults.js +5 -2
  68. package/dist/npcs/internet-connectivity/index.d.ts +19 -6
  69. package/dist/npcs/internet-connectivity/index.js +135 -133
  70. package/dist/npcs/log-watcher/buffer.d.ts +22 -0
  71. package/dist/npcs/log-watcher/buffer.js +58 -0
  72. package/dist/npcs/log-watcher/index.d.ts +22 -21
  73. package/dist/npcs/log-watcher/index.js +112 -106
  74. package/dist/npcs/log-watcher/tail.d.ts +23 -3
  75. package/dist/npcs/log-watcher/tail.js +101 -54
  76. package/dist/npcs/process-watcher/index.d.ts +1 -1
  77. package/dist/npcs/process-watcher/index.js +62 -52
  78. package/dist/npcs/process-watcher/process-scanner.d.ts +29 -1
  79. package/dist/npcs/process-watcher/process-scanner.js +61 -71
  80. package/dist/npcs/receptionist/defaults.d.ts +4 -0
  81. package/dist/npcs/receptionist/defaults.js +4 -0
  82. package/dist/npcs/receptionist/index.d.ts +12 -2
  83. package/dist/npcs/receptionist/index.js +46 -20
  84. package/dist/npcs/receptionist/weather.d.ts +8 -6
  85. package/dist/npcs/receptionist/weather.js +39 -45
  86. package/dist/npcs/system-monitor/collectors.d.ts +15 -3
  87. package/dist/npcs/system-monitor/collectors.js +64 -17
  88. package/dist/npcs/system-monitor/defaults.d.ts +1 -1
  89. package/dist/npcs/system-monitor/index.d.ts +2 -1
  90. package/dist/npcs/system-monitor/index.js +54 -30
  91. package/dist/npcs/web-page-watcher/basic-mode.d.ts +2 -2
  92. package/dist/npcs/web-page-watcher/basic-mode.js +30 -14
  93. package/dist/npcs/web-page-watcher/capture.d.ts +18 -0
  94. package/dist/npcs/web-page-watcher/capture.js +21 -1
  95. package/dist/npcs/web-page-watcher/defaults.d.ts +23 -0
  96. package/dist/npcs/web-page-watcher/defaults.js +41 -1
  97. package/dist/npcs/web-page-watcher/index.d.ts +18 -1
  98. package/dist/npcs/web-page-watcher/index.js +121 -70
  99. package/dist/npcs/web-page-watcher/pixelate.js +2 -1
  100. package/package.json +2 -7
@@ -2,6 +2,10 @@ export const DEFAULTS = {
2
2
  pollInterval: 60000, // 60s
3
3
  minPollInterval: 15000, // floor
4
4
  navTimeoutMs: 15000,
5
+ /** Extra time for screenshot + encode before a capture counts as hung. */
6
+ captureGraceMs: 15000,
7
+ /** Wait before relaunching Chrome after it failed to start. */
8
+ relaunchBackoffMs: 10 * 60 * 1000,
5
9
  maxColors: 32,
6
10
  /** Polls to keep the 'changed' sub-status active after a page change. */
7
11
  changedPolls: 2,
@@ -14,6 +18,21 @@ export const DEFAULTS = {
14
18
  scaleDivisor: 8,
15
19
  waitUntil: 'networkidle2',
16
20
  };
21
+ /**
22
+ * Size limits. The pixel payload is base64(W×H) and the API caps a screen value
23
+ * at 220 KB, so W×H must stay under ~165k; 320×240 leaves headroom. The
24
+ * viewport bound keeps Chrome's raster (and the PNG decode) to sane memory.
25
+ */
26
+ export const LIMITS = {
27
+ screenMin: 16,
28
+ screenMaxSide: 320,
29
+ screenMaxPixels: 320 * 240,
30
+ viewportMin: 64,
31
+ viewportMax: 4096,
32
+ };
33
+ export const WATCH_MODES = ['auto', 'screenshot', 'basic'];
34
+ /** puppeteer `waitUntil` values. */
35
+ export const WAIT_UNTIL = ['load', 'domcontentloaded', 'networkidle0', 'networkidle2'];
17
36
  /** Parse a "WxH" string into a {width, height} pair, or null if malformed. */
18
37
  export function parseDimensions(s) {
19
38
  const m = s
@@ -22,6 +41,27 @@ export function parseDimensions(s) {
22
41
  .match(/^(\d+)x(\d+)$/);
23
42
  if (!m)
24
43
  return null;
25
- return { width: parseInt(m[1], 10), height: parseInt(m[2], 10) };
44
+ const width = parseInt(m[1], 10);
45
+ const height = parseInt(m[2], 10);
46
+ return width > 0 && height > 0 ? { width, height } : null;
47
+ }
48
+ /** Why a TV resolution is unusable, or null when it is fine. */
49
+ export function screenSizeProblem(w, h) {
50
+ const { screenMin, screenMaxSide, screenMaxPixels } = LIMITS;
51
+ if (!Number.isInteger(w) || !Number.isInteger(h) || w < screenMin || h < screenMin) {
52
+ return `--screen must be at least ${screenMin}x${screenMin}`;
53
+ }
54
+ if (w > screenMaxSide || h > screenMaxSide || w * h > screenMaxPixels) {
55
+ return `--screen must fit within ${screenMaxSide}x${screenMaxSide} and ${screenMaxPixels} pixels (e.g. 320x240)`;
56
+ }
57
+ return null;
58
+ }
59
+ /** Why a browser viewport is unusable, or null when it is fine. */
60
+ export function viewportProblem(w, h) {
61
+ const { viewportMin, viewportMax } = LIMITS;
62
+ const ok = (n) => Number.isInteger(n) && n >= viewportMin && n <= viewportMax;
63
+ return ok(w) && ok(h)
64
+ ? null
65
+ : `--viewport sides must be between ${viewportMin} and ${viewportMax} pixels`;
26
66
  }
27
67
  //# sourceMappingURL=defaults.js.map
@@ -11,11 +11,18 @@ export interface WebPageWatcherConfig extends PollingNpcConfig {
11
11
  screenWidth?: number;
12
12
  screenHeight?: number;
13
13
  waitUntil?: string;
14
+ /**
15
+ * Run Chrome with its sandbox (default true). The sandbox is always off
16
+ * when running as root, where Chrome can't use it.
17
+ */
18
+ chromeSandbox?: boolean;
14
19
  }
15
20
  export declare class WebPageWatcherNPC extends PollingNPC {
16
21
  private url;
22
+ private host;
17
23
  private mode;
18
24
  private chromePath?;
25
+ private chromeSandbox;
19
26
  private viewportW;
20
27
  private viewportH;
21
28
  private screenW;
@@ -23,7 +30,10 @@ export declare class WebPageWatcherNPC extends PollingNPC {
23
30
  private waitUntil;
24
31
  private puppeteer;
25
32
  private browser;
26
- private browserUnavailable;
33
+ /** puppeteer-core or Chrome is missing: never retried. */
34
+ private browserMissing;
35
+ /** Chrome failed to launch: retry after this time (epoch ms). */
36
+ private nextLaunchAttempt;
27
37
  private chromeHintShown;
28
38
  private lastHash;
29
39
  private changedPollsLeft;
@@ -34,9 +44,16 @@ export declare class WebPageWatcherNPC extends PollingNPC {
34
44
  get companion(): CompanionDecl;
35
45
  protected collect(): Promise<CollectedMetrics>;
36
46
  protected onStop(): Promise<void>;
47
+ private withDeadline;
37
48
  private collectScreenshot;
38
49
  private ensureBrowser;
50
+ private closeBrowser;
39
51
  private maybeShowChromeHint;
52
+ /**
53
+ * Plain fetch: HTTP status + page title on a ticker. When this is a fallback
54
+ * from screenshot mode (no Chrome), a healthy page is reported as a warning
55
+ * to nudge the Chrome setup; an explicit `--mode=basic` reports it healthy.
56
+ */
40
57
  private collectBasic;
41
58
  }
42
59
  //# sourceMappingURL=index.d.ts.map
@@ -1,12 +1,21 @@
1
1
  import { PollingNPC } from '../../core/polling-npc.js';
2
- import { DEFAULTS } from './defaults.js';
2
+ import { moodForStatus } from '../../core/thresholds.js';
3
+ import { statusColor } from '../../core/screen.js';
4
+ import { NpcConfigError } from '../../core/errors.js';
5
+ import { isHttpUrl } from '../../core/http.js';
6
+ import { DEFAULTS, WATCH_MODES, WAIT_UNTIL, screenSizeProblem, viewportProblem, } from './defaults.js';
3
7
  import { pngToPixelImage } from './pixelate.js';
4
- import { findChrome, loadPuppeteer, browserConnected } from './capture.js';
8
+ import { findChrome, loadPuppeteer, browserConnected, chromeArgs, runningAsRoot, } from './capture.js';
5
9
  import { basicCheck, statusFromHttp, hostnameOf } from './basic-mode.js';
10
+ /** Thrown when a capture exceeds its deadline (the browser is then recycled). */
11
+ class CaptureTimeout extends Error {
12
+ }
6
13
  export class WebPageWatcherNPC extends PollingNPC {
7
14
  url;
15
+ host;
8
16
  mode;
9
17
  chromePath;
18
+ chromeSandbox;
10
19
  viewportW;
11
20
  viewportH;
12
21
  screenW;
@@ -14,7 +23,10 @@ export class WebPageWatcherNPC extends PollingNPC {
14
23
  waitUntil;
15
24
  puppeteer = null;
16
25
  browser = null;
17
- browserUnavailable = false;
26
+ /** puppeteer-core or Chrome is missing: never retried. */
27
+ browserMissing = false;
28
+ /** Chrome failed to launch: retry after this time (epoch ms). */
29
+ nextLaunchAttempt = 0;
18
30
  chromeHintShown = false;
19
31
  lastHash = 0;
20
32
  changedPollsLeft = 0;
@@ -24,22 +36,41 @@ export class WebPageWatcherNPC extends PollingNPC {
24
36
  // Floor the poll interval — screenshots are expensive.
25
37
  pollInterval: Math.max(config.pollInterval ?? DEFAULTS.pollInterval, DEFAULTS.minPollInterval),
26
38
  });
39
+ // The page is rendered and shown to the whole office: http(s) only, never
40
+ // file:, data:, chrome: or javascript: URLs.
41
+ if (!isHttpUrl(config.url)) {
42
+ throw new NpcConfigError('--url must be an http(s) URL');
43
+ }
27
44
  this.url = config.url;
28
- this.mode = config.mode ?? 'auto';
45
+ this.host = hostnameOf(config.url);
46
+ const mode = config.mode ?? 'auto';
47
+ if (!WATCH_MODES.includes(mode)) {
48
+ throw new NpcConfigError(`--mode must be one of ${WATCH_MODES.join(', ')}`);
49
+ }
50
+ this.mode = mode;
51
+ const waitUntil = config.waitUntil ?? DEFAULTS.waitUntil;
52
+ if (!WAIT_UNTIL.includes(waitUntil)) {
53
+ throw new NpcConfigError(`--wait-until must be one of ${WAIT_UNTIL.join(', ')}`);
54
+ }
55
+ this.waitUntil = waitUntil;
29
56
  this.chromePath = config.chromePath;
57
+ this.chromeSandbox = config.chromeSandbox ?? true;
30
58
  this.screenW = config.screenWidth ?? DEFAULTS.screenW;
31
59
  this.screenH = config.screenHeight ?? DEFAULTS.screenH;
32
60
  // Derive the viewport from the screen size for native downscaling unless the
33
61
  // user overrides it (in which case pngToPixelImage box-filters to screen).
34
62
  this.viewportW = config.viewportWidth ?? this.screenW * DEFAULTS.scaleDivisor;
35
63
  this.viewportH = config.viewportHeight ?? this.screenH * DEFAULTS.scaleDivisor;
36
- this.waitUntil = config.waitUntil ?? DEFAULTS.waitUntil;
64
+ const sizeProblem = screenSizeProblem(this.screenW, this.screenH) ??
65
+ viewportProblem(this.viewportW, this.viewportH);
66
+ if (sizeProblem)
67
+ throw new NpcConfigError(sizeProblem);
37
68
  }
38
69
  get name() {
39
70
  return 'web-page-watcher';
40
71
  }
41
72
  get defaultDisplayName() {
42
- return hostnameOf(this.url);
73
+ return this.host;
43
74
  }
44
75
  get archetype() {
45
76
  return 'custom';
@@ -49,43 +80,58 @@ export class WebPageWatcherNPC extends PollingNPC {
49
80
  screenType: 'web-view',
50
81
  size: { w: 4, h: 3 },
51
82
  placement: 'adjacent-wall',
52
- label: hostnameOf(this.url),
83
+ label: this.host,
53
84
  };
54
85
  }
55
86
  async collect() {
56
87
  if (this.mode === 'basic')
57
- return this.collectBasic();
88
+ return this.collectBasic(false);
58
89
  const browser = await this.ensureBrowser();
59
90
  if (!browser) {
60
- // 'auto' silently degrades; explicit 'screenshot' warns once.
61
91
  this.maybeShowChromeHint();
62
- return this.collectBasic();
92
+ return this.collectBasic(true);
93
+ }
94
+ try {
95
+ return await this.withDeadline(this.collectScreenshot(browser));
96
+ }
97
+ catch (err) {
98
+ if (this.state !== 'running')
99
+ throw err; // shutting down: nothing to report
100
+ // A crashed or wedged browser: recycle it and report this cycle from a plain fetch.
101
+ this.logger.warn('Screenshot failed; using a basic check this cycle', {
102
+ error: err instanceof Error ? err.message : String(err),
103
+ });
104
+ await this.closeBrowser();
105
+ return this.collectBasic(true);
63
106
  }
64
- return this.collectScreenshot(browser);
65
107
  }
66
108
  async onStop() {
109
+ // Close Chrome first: it aborts an in-flight capture, so the base class
110
+ // doesn't wait out a slow page load before pushing offline.
111
+ await this.closeBrowser();
67
112
  await super.onStop();
68
- if (this.browser) {
69
- try {
70
- await this.browser.close();
71
- }
72
- catch {
73
- // best effort
74
- }
75
- this.browser = null;
76
- }
77
113
  }
78
114
  // --- Screenshot mode ---
115
+ withDeadline(work) {
116
+ const ms = DEFAULTS.navTimeoutMs + DEFAULTS.captureGraceMs;
117
+ let timer;
118
+ const deadline = new Promise((_, reject) => {
119
+ timer = setTimeout(() => reject(new CaptureTimeout(`capture exceeded ${ms}ms`)), ms);
120
+ });
121
+ return Promise.race([work, deadline]).finally(() => clearTimeout(timer));
122
+ }
79
123
  async collectScreenshot(browser) {
80
124
  const page = await browser.newPage();
81
125
  try {
82
- // deviceScaleFactor 0.125 makes Chrome rasterize the viewport down to the
83
- // screen size natively (e.g. 1280×800 → 160×100); pngToPixelImage
126
+ // A page that alert()s would block navigation and the screenshot.
127
+ page.on?.('dialog', (dialog) => void dialog.dismiss().catch(() => undefined));
128
+ // deviceScaleFactor 1/8 makes Chrome rasterize the viewport down to the
129
+ // screen size natively (e.g. 1280×960 → 160×120); pngToPixelImage
84
130
  // box-filters as a fallback if it doesn't.
85
131
  await page.setViewport({
86
132
  width: this.viewportW,
87
133
  height: this.viewportH,
88
- deviceScaleFactor: 0.125,
134
+ deviceScaleFactor: 1 / DEFAULTS.scaleDivisor,
89
135
  });
90
136
  let httpStatus = 0;
91
137
  let timedOut = false;
@@ -113,15 +159,14 @@ export class WebPageWatcherNPC extends PollingNPC {
113
159
  else if (this.changedPollsLeft > 0)
114
160
  this.changedPollsLeft--;
115
161
  const status = statusFromHttp(httpStatus, timedOut);
116
- const host = hostnameOf(this.url);
117
162
  const message = changed
118
163
  ? 'Page updated!'
119
164
  : httpStatus
120
- ? `${host} ${httpStatus < 400 ? 'OK' : 'ERR'} (${httpStatus}, ${loadMs}ms)`
121
- : `${host} unreachable`;
165
+ ? `${this.host} ${httpStatus < 400 ? 'OK' : 'ERR'} (${httpStatus}, ${loadMs}ms)`
166
+ : `${this.host} ${timedOut ? 'timed out' : 'unreachable'}`;
122
167
  return {
123
168
  status,
124
- mood: moodFor(status),
169
+ mood: moodForStatus(status),
125
170
  message,
126
171
  subStatus: this.changedPollsLeft > 0 ? 'changed' : undefined,
127
172
  screenData,
@@ -138,74 +183,92 @@ export class WebPageWatcherNPC extends PollingNPC {
138
183
  }
139
184
  }
140
185
  async ensureBrowser() {
141
- if (this.browserUnavailable)
186
+ if (this.browserMissing || Date.now() < this.nextLaunchAttempt)
142
187
  return null;
143
188
  if (this.browser && browserConnected(this.browser))
144
189
  return this.browser;
145
- if (this.browser)
146
- this.browser = null; // disconnected — relaunch
147
- if (!this.puppeteer) {
148
- this.puppeteer = await loadPuppeteer();
149
- }
190
+ this.browser = null; // absent or disconnected — (re)launch
191
+ this.puppeteer ??= await loadPuppeteer();
150
192
  const chromePath = findChrome(this.chromePath);
151
193
  if (!this.puppeteer || !chromePath) {
152
- this.browserUnavailable = true;
194
+ this.browserMissing = true;
153
195
  return null;
154
196
  }
197
+ const { args, sandboxed } = chromeArgs({
198
+ sandbox: this.chromeSandbox,
199
+ isRoot: runningAsRoot(),
200
+ });
201
+ if (!sandboxed) {
202
+ this.logger.warn('Chrome sandbox disabled' +
203
+ (this.chromeSandbox ? ' (running as root)' : '') +
204
+ ': only watch pages you trust');
205
+ }
155
206
  try {
156
- this.browser = await this.puppeteer.launch({
207
+ const browser = await this.puppeteer.launch({
157
208
  executablePath: chromePath,
158
209
  headless: true,
159
- args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu', '--hide-scrollbars'],
210
+ args,
160
211
  });
161
- this.browser.on('disconnected', () => {
162
- this.browser = null;
212
+ browser.on('disconnected', () => {
213
+ if (this.browser === browser)
214
+ this.browser = null;
163
215
  });
164
- return this.browser;
216
+ // Stopped while Chrome was starting: don't leave it running.
217
+ if (this.state !== 'running') {
218
+ await browser.close().catch(() => undefined);
219
+ return null;
220
+ }
221
+ this.browser = browser;
222
+ return browser;
165
223
  }
166
224
  catch (err) {
167
- this.logger.warn('Failed to launch Chrome', {
225
+ this.logger.warn('Failed to launch Chrome; retrying in 10 minutes', {
168
226
  error: err instanceof Error ? err.message : String(err),
169
227
  });
170
- this.browserUnavailable = true;
228
+ this.nextLaunchAttempt = Date.now() + DEFAULTS.relaunchBackoffMs;
171
229
  return null;
172
230
  }
173
231
  }
232
+ async closeBrowser() {
233
+ const browser = this.browser;
234
+ this.browser = null;
235
+ if (browser)
236
+ await browser.close().catch(() => undefined);
237
+ }
174
238
  maybeShowChromeHint() {
175
- if (this.chromeHintShown)
239
+ if (this.chromeHintShown || !this.browserMissing)
176
240
  return;
177
241
  this.chromeHintShown = true;
178
242
  this.logger.warn('Chrome/puppeteer-core unavailable — falling back to basic mode. ' +
179
243
  'Install `puppeteer-core` and Google Chrome (or set --chrome-path/CHROME_PATH) for the pixel-art TV.');
180
244
  }
181
245
  // --- Basic mode ---
182
- async collectBasic() {
246
+ /**
247
+ * Plain fetch: HTTP status + page title on a ticker. When this is a fallback
248
+ * from screenshot mode (no Chrome), a healthy page is reported as a warning
249
+ * to nudge the Chrome setup; an explicit `--mode=basic` reports it healthy.
250
+ */
251
+ async collectBasic(fallback) {
183
252
  const loadStart = Date.now();
184
- const check = await basicCheck(this.url);
253
+ const check = await basicCheck(this.url, DEFAULTS.navTimeoutMs);
185
254
  const loadMs = Date.now() - loadStart;
186
- // Basic mode can't render the pixel TV, so it never reports fully healthy —
187
- // a healthy HTTP result is downgraded to a warning to nudge Chrome setup.
188
- let status = statusFromHttp(check.status, check.timedOut);
189
- if (status === 'healthy')
190
- status = 'warning';
191
- const host = hostnameOf(this.url);
192
- const message = check.title ? `${host}: ${check.title}` : `${host} (${check.status || 'ERR'})`;
255
+ const httpStatus = statusFromHttp(check.status, check.timedOut);
256
+ const status = fallback && httpStatus === 'healthy' ? 'warning' : httpStatus;
257
+ const message = check.title
258
+ ? `${this.host}: ${check.title}`
259
+ : `${this.host} (${check.status || (check.timedOut ? 'timed out' : 'ERR')})`;
193
260
  return {
194
261
  status,
195
- mood: moodFor(status),
262
+ mood: moodForStatus(status),
196
263
  message,
197
264
  screenData: {
198
265
  layout: 'ticker',
199
266
  values: [
200
- { label: 'URL', value: host },
267
+ { label: 'URL', value: this.host },
201
268
  {
202
269
  label: 'HTTP',
203
270
  value: check.status ? String(check.status) : 'ERR',
204
- color: check.status >= 200 && check.status < 400
205
- ? 'green'
206
- : check.status >= 500 || check.status === 0
207
- ? 'red'
208
- : 'amber',
271
+ color: statusColor(httpStatus),
209
272
  },
210
273
  { label: 'Title', value: check.title || '(no title)' },
211
274
  { label: 'Mode', value: 'basic' },
@@ -215,16 +278,4 @@ export class WebPageWatcherNPC extends PollingNPC {
215
278
  };
216
279
  }
217
280
  }
218
- function moodFor(status) {
219
- switch (status) {
220
- case 'healthy':
221
- return 'happy';
222
- case 'warning':
223
- return 'concerned';
224
- case 'critical':
225
- return 'stressed';
226
- default:
227
- return 'idle';
228
- }
229
- }
230
281
  //# sourceMappingURL=index.js.map
@@ -167,6 +167,7 @@ export function pngToPixelImage(pngBuffer, maxColors = 32, targetW = TARGET_W, t
167
167
  const { palette, indices } = quantize(rgba, w * h, maxColors);
168
168
  const screenData = buildPixelImageScreen(w, h, palette, indices);
169
169
  const byteSize = screenData.values.reduce((sum, v) => sum + v.value.length, 0);
170
- return { screenData, byteSize, hash: fnv1a(indices) };
170
+ // Hash the palette too: a recolour with the same layout is still a change.
171
+ return { screenData, byteSize, hash: fnv1a(Buffer.concat([palette, indices])) };
171
172
  }
172
173
  //# sourceMappingURL=pixelate.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocville/npc",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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.1"
27
+ "@nocville/node": "^0.2.3"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "puppeteer-core": "*"
@@ -59,11 +59,6 @@
59
59
  ],
60
60
  "license": "MIT",
61
61
  "homepage": "https://nocville.com",
62
- "repository": {
63
- "type": "git",
64
- "url": "git+https://github.com/marshmansf/nocville.git",
65
- "directory": "libs/nocville-npc-library"
66
- },
67
62
  "scripts": {
68
63
  "build": "tsc && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist/cjs/package.json', JSON.stringify({ type: 'commonjs' }))\" && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"",
69
64
  "clean": "rm -rf dist",