@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.
- package/README.md +55 -17
- package/dist/cjs/cli.js +94 -39
- package/dist/cjs/core/base-npc.js +15 -0
- package/dist/cjs/core/config.js +19 -2
- package/dist/cjs/core/errors.js +23 -0
- package/dist/cjs/core/http.js +121 -0
- package/dist/cjs/core/pixel-art.js +110 -0
- package/dist/cjs/core/polling-npc.js +99 -14
- package/dist/cjs/core/text.js +13 -0
- package/dist/cjs/core/thresholds.js +29 -0
- package/dist/cjs/index.js +21 -2
- package/dist/cjs/npcs/agent-common/session.js +1 -0
- package/dist/cjs/npcs/agent-common/shim.js +8 -2
- package/dist/cjs/npcs/github-watcher/defaults.js +7 -1
- package/dist/cjs/npcs/github-watcher/github-api.js +82 -45
- package/dist/cjs/npcs/github-watcher/index.js +82 -36
- package/dist/cjs/npcs/internet-connectivity/checks.js +70 -67
- package/dist/cjs/npcs/internet-connectivity/defaults.js +5 -2
- package/dist/cjs/npcs/internet-connectivity/index.js +134 -131
- package/dist/cjs/npcs/log-watcher/buffer.js +61 -0
- package/dist/cjs/npcs/log-watcher/index.js +109 -103
- package/dist/cjs/npcs/log-watcher/tail.js +100 -53
- package/dist/cjs/npcs/office-characters/index.js +92 -0
- package/dist/cjs/npcs/process-watcher/index.js +60 -51
- package/dist/cjs/npcs/process-watcher/process-scanner.js +100 -68
- package/dist/cjs/npcs/receptionist/defaults.js +4 -0
- package/dist/cjs/npcs/receptionist/index.js +6 -93
- package/dist/cjs/npcs/receptionist/weather.js +42 -47
- package/dist/cjs/npcs/security/index.js +26 -0
- package/dist/cjs/npcs/system-monitor/collectors.js +66 -17
- package/dist/cjs/npcs/system-monitor/index.js +52 -28
- package/dist/cjs/npcs/web-page-watcher/basic-mode.js +30 -14
- package/dist/cjs/npcs/web-page-watcher/capture.js +23 -1
- package/dist/cjs/npcs/web-page-watcher/defaults.js +44 -2
- package/dist/cjs/npcs/web-page-watcher/index.js +119 -68
- package/dist/cjs/npcs/web-page-watcher/pixelate.js +2 -1
- package/dist/cli.js +95 -40
- package/dist/core/base-npc.d.ts +5 -0
- package/dist/core/base-npc.js +15 -0
- package/dist/core/config.d.ts +5 -0
- package/dist/core/config.js +18 -2
- package/dist/core/errors.d.ts +11 -0
- package/dist/core/errors.js +18 -0
- package/dist/core/http.d.ts +52 -0
- package/dist/core/http.js +111 -0
- package/dist/core/pixel-art.d.ts +38 -0
- package/dist/core/pixel-art.js +105 -0
- package/dist/core/polling-npc.d.ts +22 -1
- package/dist/core/polling-npc.js +98 -13
- package/dist/core/text.d.ts +6 -0
- package/dist/core/text.js +12 -0
- package/dist/core/thresholds.d.ts +9 -1
- package/dist/core/thresholds.js +27 -0
- package/dist/core/types.d.ts +5 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +6 -2
- package/dist/npcs/agent-common/session.js +1 -0
- package/dist/npcs/agent-common/shim.js +8 -2
- package/dist/npcs/github-watcher/defaults.d.ts +6 -0
- package/dist/npcs/github-watcher/defaults.js +7 -1
- package/dist/npcs/github-watcher/github-api.d.ts +21 -7
- package/dist/npcs/github-watcher/github-api.js +80 -45
- package/dist/npcs/github-watcher/index.d.ts +6 -0
- package/dist/npcs/github-watcher/index.js +83 -37
- package/dist/npcs/internet-connectivity/checks.d.ts +15 -18
- package/dist/npcs/internet-connectivity/checks.js +70 -67
- package/dist/npcs/internet-connectivity/defaults.d.ts +3 -0
- package/dist/npcs/internet-connectivity/defaults.js +5 -2
- package/dist/npcs/internet-connectivity/index.d.ts +19 -6
- package/dist/npcs/internet-connectivity/index.js +135 -133
- package/dist/npcs/log-watcher/buffer.d.ts +22 -0
- package/dist/npcs/log-watcher/buffer.js +58 -0
- package/dist/npcs/log-watcher/index.d.ts +22 -21
- package/dist/npcs/log-watcher/index.js +112 -106
- package/dist/npcs/log-watcher/tail.d.ts +23 -3
- package/dist/npcs/log-watcher/tail.js +101 -54
- package/dist/npcs/office-characters/index.d.ts +50 -0
- package/dist/npcs/office-characters/index.js +82 -0
- package/dist/npcs/process-watcher/index.d.ts +1 -1
- package/dist/npcs/process-watcher/index.js +61 -52
- package/dist/npcs/process-watcher/process-scanner.d.ts +31 -1
- package/dist/npcs/process-watcher/process-scanner.js +97 -70
- package/dist/npcs/receptionist/defaults.d.ts +4 -0
- package/dist/npcs/receptionist/defaults.js +4 -0
- package/dist/npcs/receptionist/index.d.ts +3 -12
- package/dist/npcs/receptionist/index.js +6 -93
- package/dist/npcs/receptionist/weather.d.ts +8 -6
- package/dist/npcs/receptionist/weather.js +39 -45
- package/dist/npcs/security/index.d.ts +12 -0
- package/dist/npcs/security/index.js +22 -0
- package/dist/npcs/system-monitor/collectors.d.ts +15 -3
- package/dist/npcs/system-monitor/collectors.js +64 -17
- package/dist/npcs/system-monitor/defaults.d.ts +1 -1
- package/dist/npcs/system-monitor/index.d.ts +2 -1
- package/dist/npcs/system-monitor/index.js +54 -30
- package/dist/npcs/web-page-watcher/basic-mode.d.ts +2 -2
- package/dist/npcs/web-page-watcher/basic-mode.js +30 -14
- package/dist/npcs/web-page-watcher/capture.d.ts +18 -0
- package/dist/npcs/web-page-watcher/capture.js +21 -1
- package/dist/npcs/web-page-watcher/defaults.d.ts +23 -0
- package/dist/npcs/web-page-watcher/defaults.js +41 -1
- package/dist/npcs/web-page-watcher/index.d.ts +18 -1
- package/dist/npcs/web-page-watcher/index.js +121 -70
- package/dist/npcs/web-page-watcher/pixelate.js +2 -1
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -5,8 +5,9 @@ export { EventNPC } from './core/event-npc.js';
|
|
|
5
5
|
export { Logger } from './core/logger.js';
|
|
6
6
|
export { parseArgs, resolveConfig, stringFlag, boolFlag, numberFlag } from './core/config.js';
|
|
7
7
|
export type { ParsedArgs } from './core/config.js';
|
|
8
|
-
export { evaluateThreshold, worstStatus } from './core/thresholds.js';
|
|
9
|
-
export { truncate } from './core/text.js';
|
|
8
|
+
export { evaluateThreshold, worstStatus, moodForStatus } from './core/thresholds.js';
|
|
9
|
+
export { truncate, sanitizeText } from './core/text.js';
|
|
10
|
+
export { NpcConfigError } from './core/errors.js';
|
|
10
11
|
export { statusColor, pushHistory } from './core/screen.js';
|
|
11
12
|
export type { ScreenColor } from './core/screen.js';
|
|
12
13
|
export type { Status, Mood, NpcConfig, CollectedMetrics, ThresholdConfig, NpcState, LogLevelName, Archetype, BehaviorHints, CompanionSpec, CompanionDecl, ScreenData, ScreenValue, } from './core/types.js';
|
|
@@ -18,6 +19,8 @@ export { handleCodexCommand, handleCodexHookEvent, codexTransition, setupCodexHo
|
|
|
18
19
|
export type { CodexSetupOptions, CodexWatcherOptions, RolloutSignal } from './npcs/codex/index.js';
|
|
19
20
|
export { describeToolUse, shortCommand, shortPath, redactSecrets, } from './npcs/agent-common/activity.js';
|
|
20
21
|
export { loadUserConfig, saveUserConfig, userConfigPath } from './core/user-config.js';
|
|
22
|
+
export { encodePixelArt, pixelArtFromPng, pixelDevice } from './core/pixel-art.js';
|
|
23
|
+
export type { PixelArtFrames } from './core/pixel-art.js';
|
|
21
24
|
export { loadSessionFilter, sessionFilterPath, sessionPublishDecision, ensureSessionFilterTemplate, } from './core/session-filter.js';
|
|
22
25
|
export type { SessionFilter, SessionRules, PublishDecision } from './core/session-filter.js';
|
|
23
26
|
export type { UserConfig } from './core/user-config.js';
|
|
@@ -38,4 +41,6 @@ export type { GeminiSetupOptions } from './npcs/gemini/index.js';
|
|
|
38
41
|
export { CURSOR_DEFAULTS, CURSOR_ADAPTER, cursorServiceId, handleCursorHookEvent, setupCursorHooks, uninstallCursorHooks, installCursorHooksInto, CURSOR_HOOK_EVENTS, } from './npcs/cursor/index.js';
|
|
39
42
|
export type { CursorSetupOptions } from './npcs/cursor/index.js';
|
|
40
43
|
export { isCursorInvocation, normalizeCursorEvent } from './npcs/cursor/normalize.js';
|
|
44
|
+
export { SecurityNPC } from './npcs/security/index.js';
|
|
45
|
+
export { JanitorNPC, BaristaNPC, OfficeCatNPC, OfficeDogNPC, PlantCaretakerNPC, ITTechnicianNPC, OutOfTouchExecutiveNPC, OFFICE_CHARACTER_NPCS, } from './npcs/office-characters/index.js';
|
|
41
46
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -4,8 +4,9 @@ export { PollingNPC } from './core/polling-npc.js';
|
|
|
4
4
|
export { EventNPC } from './core/event-npc.js';
|
|
5
5
|
export { Logger } from './core/logger.js';
|
|
6
6
|
export { parseArgs, resolveConfig, stringFlag, boolFlag, numberFlag } from './core/config.js';
|
|
7
|
-
export { evaluateThreshold, worstStatus } from './core/thresholds.js';
|
|
8
|
-
export { truncate } from './core/text.js';
|
|
7
|
+
export { evaluateThreshold, worstStatus, moodForStatus } from './core/thresholds.js';
|
|
8
|
+
export { truncate, sanitizeText } from './core/text.js';
|
|
9
|
+
export { NpcConfigError } from './core/errors.js';
|
|
9
10
|
export { statusColor, pushHistory } from './core/screen.js';
|
|
10
11
|
// NPCs
|
|
11
12
|
export { SystemMonitorNPC } from './npcs/system-monitor/index.js';
|
|
@@ -13,6 +14,7 @@ export { ClaudeCodeNPC, handleClaudeCodeCommand, handleHookEvent, setupHooks, un
|
|
|
13
14
|
export { handleCodexCommand, handleCodexHookEvent, codexTransition, setupCodexHooks, uninstallCodexHooks, parseRolloutLine, CodexWatcher, } from './npcs/codex/index.js';
|
|
14
15
|
export { describeToolUse, shortCommand, shortPath, redactSecrets, } from './npcs/agent-common/activity.js';
|
|
15
16
|
export { loadUserConfig, saveUserConfig, userConfigPath } from './core/user-config.js';
|
|
17
|
+
export { encodePixelArt, pixelArtFromPng, pixelDevice } from './core/pixel-art.js';
|
|
16
18
|
export { loadSessionFilter, sessionFilterPath, sessionPublishDecision, ensureSessionFilterTemplate, } from './core/session-filter.js';
|
|
17
19
|
export { InternetConnectivityNPC } from './npcs/internet-connectivity/index.js';
|
|
18
20
|
export { ProcessWatcherNPC } from './npcs/process-watcher/index.js';
|
|
@@ -23,4 +25,6 @@ export { WebPageWatcherNPC } from './npcs/web-page-watcher/index.js';
|
|
|
23
25
|
export { GEMINI_DEFAULTS, GEMINI_ADAPTER, geminiTransition, geminiServiceId, handleGeminiHookEvent, setupGeminiHooks, uninstallGeminiHooks, installGeminiHooksInto, GEMINI_HOOK_EVENTS, } from './npcs/gemini/index.js';
|
|
24
26
|
export { CURSOR_DEFAULTS, CURSOR_ADAPTER, cursorServiceId, handleCursorHookEvent, setupCursorHooks, uninstallCursorHooks, installCursorHooksInto, CURSOR_HOOK_EVENTS, } from './npcs/cursor/index.js';
|
|
25
27
|
export { isCursorInvocation, normalizeCursorEvent } from './npcs/cursor/normalize.js';
|
|
28
|
+
export { SecurityNPC } from './npcs/security/index.js';
|
|
29
|
+
export { JanitorNPC, BaristaNPC, OfficeCatNPC, OfficeDogNPC, PlantCaretakerNPC, ITTechnicianNPC, OutOfTouchExecutiveNPC, OFFICE_CHARACTER_NPCS, } from './npcs/office-characters/index.js';
|
|
26
30
|
//# sourceMappingURL=index.js.map
|
|
@@ -222,6 +222,7 @@ export function buildAgentPayload(adapter, state, config) {
|
|
|
222
222
|
name,
|
|
223
223
|
message: current ? truncate(current, 120) : undefined,
|
|
224
224
|
archetype: 'ai-agent',
|
|
225
|
+
kind: adapter.command,
|
|
225
226
|
metrics: { sessionStartedAt: state.startedAt ?? state.lastUpdate },
|
|
226
227
|
appearance: { family: adapter.family },
|
|
227
228
|
agentStats: state.agentStats,
|
|
@@ -77,13 +77,19 @@ try {
|
|
|
77
77
|
if (neutral) process.stdout.write('{}\\n');
|
|
78
78
|
`;
|
|
79
79
|
export function installHookShim(nodePath, cliPath) {
|
|
80
|
+
// Desktop ships this same CLI with Electron's Node runtime. Keep the flag in
|
|
81
|
+
// the launcher (never globally), inherited by flush/watchdog child processes.
|
|
82
|
+
const electron = !!process.versions['electron'];
|
|
80
83
|
writeChanged(RUNTIME_POINTER(), JSON.stringify({ node: nodePath, cli: cliPath }, null, 2) + '\n', 0o600);
|
|
81
84
|
writeChanged(join(dirname(RUNTIME_POINTER()), 'node-path'), nodePath + '\n', 0o600);
|
|
82
85
|
writeChanged(join(dirname(SHIM_PATH()), 'launch.cjs'), LAUNCHER, 0o600);
|
|
83
|
-
writeChanged(SHIM_PATH(), SHIM, 0o755);
|
|
86
|
+
writeChanged(SHIM_PATH(), electron ? SHIM.replace('exec "$NODE"', 'export ELECTRON_RUN_AS_NODE=1\nexec "$NODE"') : SHIM, 0o755);
|
|
84
87
|
chmodSync(SHIM_PATH(), 0o755);
|
|
85
88
|
// Both launchers use the same bootstrap, including imported-Cursor detection.
|
|
86
|
-
|
|
89
|
+
const windowsShim = electron
|
|
90
|
+
? WINDOWS_SHIM.replace(':launch\n', ':launch\nset "ELECTRON_RUN_AS_NODE=1"\n')
|
|
91
|
+
: WINDOWS_SHIM;
|
|
92
|
+
writeChanged(SHIM_PATH() + '.cmd', windowsShim.replace(/\n/g, '\r\n'), 0o755);
|
|
87
93
|
pruneOldRuntimes(dirname(RUNTIME_POINTER()));
|
|
88
94
|
return SHIM_PATH();
|
|
89
95
|
}
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
export declare const DEFAULTS: {
|
|
2
2
|
pollInterval: number;
|
|
3
|
+
/** Without a token GitHub allows 60 requests/hour (304s included): poll every 90s. */
|
|
4
|
+
unauthenticatedPollInterval: number;
|
|
3
5
|
fetchTimeoutMs: number;
|
|
4
6
|
perPage: number;
|
|
7
|
+
/** Commit rows on the board (plus a header row). */
|
|
8
|
+
boardRows: number;
|
|
5
9
|
/** Polls to keep the 'new-commit' sub-status active after a fresh commit. */
|
|
6
10
|
newCommitPolls: number;
|
|
11
|
+
/** Consecutive network/5xx failures tolerated (board kept, healthy) before warning. */
|
|
12
|
+
failuresBeforeWarning: number;
|
|
7
13
|
};
|
|
8
14
|
//# sourceMappingURL=defaults.d.ts.map
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
export const DEFAULTS = {
|
|
2
|
-
pollInterval: 60000, // 60s
|
|
2
|
+
pollInterval: 60000, // 60s with a token
|
|
3
|
+
/** Without a token GitHub allows 60 requests/hour (304s included): poll every 90s. */
|
|
4
|
+
unauthenticatedPollInterval: 90000,
|
|
3
5
|
fetchTimeoutMs: 5000,
|
|
4
6
|
perPage: 10,
|
|
7
|
+
/** Commit rows on the board (plus a header row). */
|
|
8
|
+
boardRows: 8,
|
|
5
9
|
/** Polls to keep the 'new-commit' sub-status active after a fresh commit. */
|
|
6
10
|
newCommitPolls: 2,
|
|
11
|
+
/** Consecutive network/5xx failures tolerated (board kept, healthy) before warning. */
|
|
12
|
+
failuresBeforeWarning: 3,
|
|
7
13
|
};
|
|
8
14
|
//# sourceMappingURL=defaults.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReadableResponse } from '../../core/http.js';
|
|
1
2
|
export interface CommitInfo {
|
|
2
3
|
sha: string;
|
|
3
4
|
author: string;
|
|
@@ -5,25 +6,26 @@ export interface CommitInfo {
|
|
|
5
6
|
date: string;
|
|
6
7
|
}
|
|
7
8
|
export interface FetchCommitsResult {
|
|
8
|
-
/** 304 Not Modified — the ETag matched, nothing changed
|
|
9
|
+
/** 304 Not Modified — the ETag matched, nothing changed. */
|
|
9
10
|
notModified: boolean;
|
|
10
11
|
commits: CommitInfo[];
|
|
11
12
|
etag: string | null;
|
|
12
13
|
rateRemaining: number | null;
|
|
14
|
+
/** When the rate-limit window resets (epoch ms), if GitHub said. */
|
|
15
|
+
rateResetAt: number | null;
|
|
16
|
+
/** HTTP status, or 0 for a network error/timeout/unreadable body. */
|
|
13
17
|
status: number;
|
|
14
18
|
}
|
|
15
19
|
/** Minimal fetch signature so tests can inject a stub. */
|
|
16
20
|
export type FetchFn = (url: string, init?: {
|
|
17
21
|
headers?: Record<string, string>;
|
|
18
22
|
signal?: AbortSignal;
|
|
19
|
-
}) => Promise<{
|
|
23
|
+
}) => Promise<ReadableResponse & {
|
|
20
24
|
ok: boolean;
|
|
21
25
|
status: number;
|
|
22
26
|
headers: {
|
|
23
27
|
get(name: string): string | null;
|
|
24
28
|
};
|
|
25
|
-
json(): Promise<unknown>;
|
|
26
|
-
text(): Promise<string>;
|
|
27
29
|
}>;
|
|
28
30
|
export interface FetchOptions {
|
|
29
31
|
token?: string;
|
|
@@ -34,15 +36,27 @@ export interface FetchOptions {
|
|
|
34
36
|
/** Injectable fetch (defaults to the global fetch). */
|
|
35
37
|
fetchFn?: FetchFn;
|
|
36
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Validate an "owner/name" repository slug. It is interpolated into the API
|
|
41
|
+
* URL path, so anything else (`../`, extra segments, query characters) is
|
|
42
|
+
* rejected rather than letting it — and the token — reach another endpoint.
|
|
43
|
+
*/
|
|
44
|
+
export declare function isValidRepo(repo: string): boolean;
|
|
37
45
|
/** Look up a repo's default branch (one call at startup). Null on failure. */
|
|
38
46
|
export declare function fetchDefaultBranch(repo: string, opts?: FetchOptions): Promise<string | null>;
|
|
39
47
|
/**
|
|
40
48
|
* Fetch recent commits from the GitHub REST API using conditional requests.
|
|
41
|
-
* Passing the previous ETag yields a 304
|
|
42
|
-
*
|
|
43
|
-
*
|
|
49
|
+
* Passing the previous ETag yields a cheap 304 when nothing has changed (free
|
|
50
|
+
* of the rate limit for authenticated requests). Never throws — network
|
|
51
|
+
* errors and unreadable bodies surface as `status: 0`.
|
|
44
52
|
*/
|
|
45
53
|
export declare function fetchCommits(repo: string, opts?: FetchOptions): Promise<FetchCommitsResult>;
|
|
54
|
+
/**
|
|
55
|
+
* Extract the displayed fields from one (untrusted) commit object: a hex sha,
|
|
56
|
+
* the subject line and author name with control/bidi characters removed and
|
|
57
|
+
* lengths capped. Null for anything without a valid sha.
|
|
58
|
+
*/
|
|
59
|
+
export declare function parseCommit(raw: unknown): CommitInfo | null;
|
|
46
60
|
/** Count how many commits landed within the last `hours`. */
|
|
47
61
|
export declare function commitsWithin(commits: CommitInfo[], hours: number, now?: number): number;
|
|
48
62
|
/**
|
|
@@ -1,96 +1,131 @@
|
|
|
1
|
+
import { NPC_USER_AGENT, asRecord, readTextCapped } from '../../core/http.js';
|
|
2
|
+
import { sanitizeText } from '../../core/text.js';
|
|
3
|
+
/** Largest GitHub API response we read (10 commits are ~30-60 KB). */
|
|
4
|
+
const MAX_BODY_BYTES = 512 * 1024;
|
|
5
|
+
// GitHub owner and repository names: letters, digits, '-', '_' and '.'.
|
|
6
|
+
const REPO_RE = /^([A-Za-z0-9-]{1,39})\/([A-Za-z0-9._-]{1,100})$/;
|
|
7
|
+
/**
|
|
8
|
+
* Validate an "owner/name" repository slug. It is interpolated into the API
|
|
9
|
+
* URL path, so anything else (`../`, extra segments, query characters) is
|
|
10
|
+
* rejected rather than letting it — and the token — reach another endpoint.
|
|
11
|
+
*/
|
|
12
|
+
export function isValidRepo(repo) {
|
|
13
|
+
const m = repo.match(REPO_RE);
|
|
14
|
+
return !!m && m[2] !== '.' && m[2] !== '..';
|
|
15
|
+
}
|
|
1
16
|
function toNum(v) {
|
|
2
|
-
if (v === null)
|
|
17
|
+
if (v === null || v.trim() === '')
|
|
3
18
|
return null;
|
|
4
19
|
const n = Number(v);
|
|
5
|
-
return Number.
|
|
20
|
+
return Number.isFinite(n) ? n : null;
|
|
6
21
|
}
|
|
7
22
|
function ghHeaders(opts) {
|
|
8
23
|
const headers = {
|
|
9
24
|
Accept: 'application/vnd.github+json',
|
|
10
|
-
'User-Agent':
|
|
25
|
+
'User-Agent': NPC_USER_AGENT,
|
|
11
26
|
'X-GitHub-Api-Version': '2022-11-28',
|
|
12
27
|
};
|
|
13
28
|
if (opts.token)
|
|
14
29
|
headers['Authorization'] = `Bearer ${opts.token}`;
|
|
15
30
|
return headers;
|
|
16
31
|
}
|
|
32
|
+
function doFetchFn(opts) {
|
|
33
|
+
return opts.fetchFn ?? globalThis.fetch;
|
|
34
|
+
}
|
|
35
|
+
async function readJson(res) {
|
|
36
|
+
const { text, truncated } = await readTextCapped(res, MAX_BODY_BYTES);
|
|
37
|
+
if (truncated)
|
|
38
|
+
throw new Error('response too large');
|
|
39
|
+
return JSON.parse(text);
|
|
40
|
+
}
|
|
17
41
|
/** Look up a repo's default branch (one call at startup). Null on failure. */
|
|
18
42
|
export async function fetchDefaultBranch(repo, opts = {}) {
|
|
19
|
-
const doFetch = opts.fetchFn ?? globalThis.fetch;
|
|
20
|
-
const controller = new AbortController();
|
|
21
|
-
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 5000);
|
|
22
43
|
try {
|
|
23
|
-
const res = await
|
|
44
|
+
const res = await doFetchFn(opts)(`https://api.github.com/repos/${repo}`, {
|
|
24
45
|
headers: ghHeaders(opts),
|
|
25
|
-
signal:
|
|
46
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 5000),
|
|
26
47
|
});
|
|
27
48
|
if (!res.ok)
|
|
28
49
|
return null;
|
|
29
|
-
const
|
|
30
|
-
return
|
|
50
|
+
const branch = asRecord(await readJson(res))?.['default_branch'];
|
|
51
|
+
return typeof branch === 'string' && branch.length > 0 && branch.length <= 255
|
|
52
|
+
? sanitizeText(branch, 255)
|
|
53
|
+
: null;
|
|
31
54
|
}
|
|
32
55
|
catch {
|
|
33
56
|
return null;
|
|
34
57
|
}
|
|
35
|
-
finally {
|
|
36
|
-
clearTimeout(timer);
|
|
37
|
-
}
|
|
38
58
|
}
|
|
39
59
|
/**
|
|
40
60
|
* Fetch recent commits from the GitHub REST API using conditional requests.
|
|
41
|
-
* Passing the previous ETag yields a 304
|
|
42
|
-
*
|
|
43
|
-
*
|
|
61
|
+
* Passing the previous ETag yields a cheap 304 when nothing has changed (free
|
|
62
|
+
* of the rate limit for authenticated requests). Never throws — network
|
|
63
|
+
* errors and unreadable bodies surface as `status: 0`.
|
|
44
64
|
*/
|
|
45
65
|
export async function fetchCommits(repo, opts = {}) {
|
|
46
66
|
const perPage = opts.perPage ?? 10;
|
|
47
|
-
const doFetch = opts.fetchFn ?? globalThis.fetch;
|
|
48
67
|
let url = `https://api.github.com/repos/${repo}/commits?per_page=${perPage}`;
|
|
49
68
|
if (opts.branch)
|
|
50
69
|
url += `&sha=${encodeURIComponent(opts.branch)}`;
|
|
51
70
|
const headers = ghHeaders(opts);
|
|
52
71
|
if (opts.etag)
|
|
53
72
|
headers['If-None-Match'] = opts.etag;
|
|
54
|
-
const
|
|
55
|
-
|
|
73
|
+
const failed = {
|
|
74
|
+
notModified: false,
|
|
75
|
+
commits: [],
|
|
76
|
+
etag: null,
|
|
77
|
+
rateRemaining: null,
|
|
78
|
+
rateResetAt: null,
|
|
79
|
+
status: 0,
|
|
80
|
+
};
|
|
56
81
|
try {
|
|
57
|
-
const res = await
|
|
82
|
+
const res = await doFetchFn(opts)(url, {
|
|
83
|
+
headers,
|
|
84
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 5000),
|
|
85
|
+
});
|
|
58
86
|
const rateRemaining = toNum(res.headers.get('x-ratelimit-remaining'));
|
|
87
|
+
const resetSecs = toNum(res.headers.get('x-ratelimit-reset'));
|
|
88
|
+
const rateResetAt = resetSecs === null ? null : resetSecs * 1000;
|
|
59
89
|
const etag = res.headers.get('etag');
|
|
90
|
+
const meta = { rateRemaining, rateResetAt, status: res.status };
|
|
60
91
|
if (res.status === 304) {
|
|
61
|
-
return {
|
|
62
|
-
notModified: true,
|
|
63
|
-
commits: [],
|
|
64
|
-
etag: opts.etag ?? null,
|
|
65
|
-
rateRemaining,
|
|
66
|
-
status: 304,
|
|
67
|
-
};
|
|
92
|
+
return { ...meta, notModified: true, commits: [], etag: opts.etag ?? null };
|
|
68
93
|
}
|
|
69
94
|
if (!res.ok) {
|
|
70
|
-
await res.
|
|
71
|
-
return { notModified: false, commits: [], etag
|
|
95
|
+
await res.body?.cancel().catch(() => undefined);
|
|
96
|
+
return { ...meta, notModified: false, commits: [], etag };
|
|
72
97
|
}
|
|
73
|
-
const body =
|
|
74
|
-
|
|
75
|
-
|
|
98
|
+
const body = await readJson(res);
|
|
99
|
+
if (!Array.isArray(body))
|
|
100
|
+
return failed;
|
|
101
|
+
const commits = body.map(parseCommit).filter((c) => c !== null);
|
|
102
|
+
return { ...meta, notModified: false, commits, etag };
|
|
76
103
|
}
|
|
77
104
|
catch {
|
|
78
|
-
return
|
|
79
|
-
}
|
|
80
|
-
finally {
|
|
81
|
-
clearTimeout(timer);
|
|
105
|
+
return failed;
|
|
82
106
|
}
|
|
83
107
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Extract the displayed fields from one (untrusted) commit object: a hex sha,
|
|
110
|
+
* the subject line and author name with control/bidi characters removed and
|
|
111
|
+
* lengths capped. Null for anything without a valid sha.
|
|
112
|
+
*/
|
|
113
|
+
export function parseCommit(raw) {
|
|
114
|
+
const c = asRecord(raw);
|
|
115
|
+
const sha = c?.['sha'];
|
|
116
|
+
if (typeof sha !== 'string' || !/^[0-9a-f]{7,64}$/i.test(sha))
|
|
117
|
+
return null;
|
|
118
|
+
const commit = asRecord(c['commit']) ?? {};
|
|
119
|
+
const commitAuthor = asRecord(commit['author']) ?? {};
|
|
120
|
+
const ghAuthor = asRecord(c['author']) ?? {};
|
|
121
|
+
const str = (v) => (typeof v === 'string' && v.trim() ? v : null);
|
|
122
|
+
const message = str(commit['message'])?.split('\n')[0] ?? '';
|
|
123
|
+
const date = str(commitAuthor['date']) ?? '';
|
|
89
124
|
return {
|
|
90
|
-
sha:
|
|
91
|
-
author:
|
|
92
|
-
message,
|
|
93
|
-
date:
|
|
125
|
+
sha: sha.toLowerCase(),
|
|
126
|
+
author: sanitizeText(str(commitAuthor['name']) ?? str(ghAuthor['login']) ?? 'unknown', 40),
|
|
127
|
+
message: sanitizeText(message, 200),
|
|
128
|
+
date: Number.isFinite(Date.parse(date)) ? date : '',
|
|
94
129
|
};
|
|
95
130
|
}
|
|
96
131
|
/** Count how many commits landed within the last `hours`. */
|
|
@@ -19,6 +19,9 @@ export declare class GitHubWatcherNPC extends PollingNPC {
|
|
|
19
19
|
private lastSeenSha;
|
|
20
20
|
private commits;
|
|
21
21
|
private rateRemaining;
|
|
22
|
+
/** While rate-limited, don't call GitHub again before this time (epoch ms). */
|
|
23
|
+
private rateLimitedUntil;
|
|
24
|
+
private consecutiveFailures;
|
|
22
25
|
private newCommitPollsLeft;
|
|
23
26
|
constructor(config: GitHubWatcherConfig);
|
|
24
27
|
get name(): string;
|
|
@@ -27,6 +30,9 @@ export declare class GitHubWatcherNPC extends PollingNPC {
|
|
|
27
30
|
get companion(): CompanionDecl;
|
|
28
31
|
protected onStart(): Promise<void>;
|
|
29
32
|
protected collect(): Promise<CollectedMetrics>;
|
|
33
|
+
/** Map an error response to a report, or null when the fetch succeeded. */
|
|
34
|
+
private classifyFailure;
|
|
35
|
+
private rateLimited;
|
|
30
36
|
private reportNewCommits;
|
|
31
37
|
private reportIdle;
|
|
32
38
|
private report;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { PollingNPC } from '../../core/polling-npc.js';
|
|
2
2
|
import { truncate } from '../../core/text.js';
|
|
3
|
+
import { moodForStatus } from '../../core/thresholds.js';
|
|
4
|
+
import { NpcConfigError } from '../../core/errors.js';
|
|
3
5
|
import { DEFAULTS } from './defaults.js';
|
|
4
|
-
import { fetchCommits, fetchDefaultBranch, newCommitsSince, commitsWithin } from './github-api.js';
|
|
6
|
+
import { fetchCommits, fetchDefaultBranch, newCommitsSince, commitsWithin, isValidRepo, } from './github-api.js';
|
|
5
7
|
import { relativeTime } from './format.js';
|
|
6
8
|
export class GitHubWatcherNPC extends PollingNPC {
|
|
7
9
|
repo;
|
|
@@ -12,12 +14,24 @@ export class GitHubWatcherNPC extends PollingNPC {
|
|
|
12
14
|
lastSeenSha = null;
|
|
13
15
|
commits = [];
|
|
14
16
|
rateRemaining = null;
|
|
17
|
+
/** While rate-limited, don't call GitHub again before this time (epoch ms). */
|
|
18
|
+
rateLimitedUntil = 0;
|
|
19
|
+
consecutiveFailures = 0;
|
|
15
20
|
newCommitPollsLeft = 0;
|
|
16
21
|
constructor(config) {
|
|
17
|
-
super({
|
|
22
|
+
super({
|
|
23
|
+
...config,
|
|
24
|
+
// Unauthenticated requests get 60/hour and 304s still count against
|
|
25
|
+
// them, so a 60s poll (plus the branch lookup) would hit the limit.
|
|
26
|
+
pollInterval: config.pollInterval ??
|
|
27
|
+
(config.token ? DEFAULTS.pollInterval : DEFAULTS.unauthenticatedPollInterval),
|
|
28
|
+
});
|
|
29
|
+
if (!isValidRepo(config.repo)) {
|
|
30
|
+
throw new NpcConfigError(`--repo must look like owner/name (got "${truncate(config.repo, 80)}")`);
|
|
31
|
+
}
|
|
18
32
|
this.repo = config.repo;
|
|
19
|
-
this.token = config.token;
|
|
20
|
-
this.branch = config.branch;
|
|
33
|
+
this.token = config.token || undefined;
|
|
34
|
+
this.branch = config.branch?.trim() || undefined;
|
|
21
35
|
this.fetchFn = config.fetchFn;
|
|
22
36
|
}
|
|
23
37
|
get name() {
|
|
@@ -39,7 +53,8 @@ export class GitHubWatcherNPC extends PollingNPC {
|
|
|
39
53
|
};
|
|
40
54
|
}
|
|
41
55
|
async onStart() {
|
|
42
|
-
// Resolve the default branch once if the user didn't pin one
|
|
56
|
+
// Resolve the default branch once if the user didn't pin one (it only
|
|
57
|
+
// labels the board; the commits API already defaults to that branch).
|
|
43
58
|
if (!this.branch) {
|
|
44
59
|
const def = await fetchDefaultBranch(this.repo, {
|
|
45
60
|
token: this.token,
|
|
@@ -54,6 +69,8 @@ export class GitHubWatcherNPC extends PollingNPC {
|
|
|
54
69
|
await super.onStart();
|
|
55
70
|
}
|
|
56
71
|
async collect() {
|
|
72
|
+
if (Date.now() < this.rateLimitedUntil)
|
|
73
|
+
return this.rateLimited();
|
|
57
74
|
const result = await fetchCommits(this.repo, {
|
|
58
75
|
token: this.token,
|
|
59
76
|
etag: this.etag,
|
|
@@ -64,39 +81,68 @@ export class GitHubWatcherNPC extends PollingNPC {
|
|
|
64
81
|
});
|
|
65
82
|
if (result.rateRemaining !== null)
|
|
66
83
|
this.rateRemaining = result.rateRemaining;
|
|
67
|
-
|
|
68
|
-
|
|
84
|
+
const failure = this.classifyFailure(result);
|
|
85
|
+
if (failure)
|
|
86
|
+
return failure;
|
|
87
|
+
this.consecutiveFailures = 0;
|
|
88
|
+
if (result.notModified)
|
|
89
|
+
return this.reportIdle();
|
|
90
|
+
if (result.status === 409)
|
|
91
|
+
return this.report('healthy', 'Repository is empty');
|
|
92
|
+
this.etag = result.etag ?? this.etag;
|
|
93
|
+
if (result.commits.length === 0)
|
|
94
|
+
return this.reportIdle();
|
|
95
|
+
const fresh = newCommitsSince(result.commits, this.lastSeenSha);
|
|
96
|
+
this.commits = result.commits;
|
|
97
|
+
const seeded = this.lastSeenSha !== null;
|
|
98
|
+
this.lastSeenSha = result.commits[0].sha;
|
|
99
|
+
if (!seeded || fresh.length === 0)
|
|
100
|
+
return this.reportIdle();
|
|
101
|
+
this.newCommitPollsLeft = DEFAULTS.newCommitPolls;
|
|
102
|
+
return this.reportNewCommits(fresh);
|
|
103
|
+
}
|
|
104
|
+
/** Map an error response to a report, or null when the fetch succeeded. */
|
|
105
|
+
classifyFailure(result) {
|
|
106
|
+
const { status } = result;
|
|
107
|
+
if (status === 304 || status === 409 || (status >= 200 && status < 300))
|
|
108
|
+
return null;
|
|
109
|
+
if (status === 429 || (status === 403 && result.rateRemaining === 0)) {
|
|
110
|
+
this.rateLimitedUntil = result.rateResetAt ?? Date.now() + 15 * 60_000;
|
|
111
|
+
if (!this.token) {
|
|
112
|
+
this.logger.warn('GitHub rate limit reached; set GITHUB_TOKEN for a higher limit');
|
|
113
|
+
}
|
|
114
|
+
return this.rateLimited();
|
|
69
115
|
}
|
|
70
|
-
if (
|
|
71
|
-
return this.report('warning', '
|
|
116
|
+
if (status === 401) {
|
|
117
|
+
return this.report('warning', 'GitHub token rejected (401)', 'auth-failed');
|
|
72
118
|
}
|
|
73
|
-
if (
|
|
74
|
-
return this.report('warning',
|
|
119
|
+
if (status === 403) {
|
|
120
|
+
return this.report('warning', `Access to ${this.repo} denied (403)`, 'forbidden');
|
|
75
121
|
}
|
|
76
|
-
if (
|
|
77
|
-
|
|
122
|
+
if (status === 404) {
|
|
123
|
+
const hint = this.token ? '' : ' (private repos need GITHUB_TOKEN)';
|
|
124
|
+
return this.report('warning', `Repo not found: ${this.repo}${hint}`, 'not-found');
|
|
78
125
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (this.lastSeenSha === null) {
|
|
84
|
-
this.lastSeenSha = result.commits[0].sha;
|
|
85
|
-
return this.reportIdle();
|
|
86
|
-
}
|
|
87
|
-
if (fresh.length > 0) {
|
|
88
|
-
this.lastSeenSha = result.commits[0].sha;
|
|
89
|
-
this.newCommitPollsLeft = DEFAULTS.newCommitPolls;
|
|
90
|
-
return this.reportNewCommits(fresh);
|
|
91
|
-
}
|
|
126
|
+
// Network error, timeout or 5xx: ride out blips while there's a board to show.
|
|
127
|
+
this.consecutiveFailures++;
|
|
128
|
+
if (this.commits.length > 0 && this.consecutiveFailures < DEFAULTS.failuresBeforeWarning) {
|
|
129
|
+
return this.reportIdle();
|
|
92
130
|
}
|
|
93
|
-
|
|
131
|
+
const what = status === 0 ? 'unreachable' : `error (HTTP ${status})`;
|
|
132
|
+
return this.report('warning', `GitHub API ${what}`, 'unreachable');
|
|
133
|
+
}
|
|
134
|
+
rateLimited() {
|
|
135
|
+
const resumes = new Date(this.rateLimitedUntil).toLocaleTimeString([], {
|
|
136
|
+
hour: '2-digit',
|
|
137
|
+
minute: '2-digit',
|
|
138
|
+
});
|
|
139
|
+
return this.report('warning', `GitHub API rate-limited until ${resumes}`, 'rate-limited');
|
|
94
140
|
}
|
|
95
141
|
reportNewCommits(fresh) {
|
|
96
142
|
const newest = fresh[0];
|
|
97
143
|
const latest = `${newest.author}: ${truncate(newest.message, 120)} (${newest.sha.slice(0, 7)})`;
|
|
98
144
|
const message = fresh.length > 1 ? `${fresh.length} new commits — latest: ${latest}` : latest;
|
|
99
|
-
return this.report('healthy',
|
|
145
|
+
return this.report('healthy', message, 'new-commit');
|
|
100
146
|
}
|
|
101
147
|
reportIdle() {
|
|
102
148
|
if (this.newCommitPollsLeft > 0)
|
|
@@ -106,16 +152,16 @@ export class GitHubWatcherNPC extends PollingNPC {
|
|
|
106
152
|
const message = newest
|
|
107
153
|
? `Last: ${truncate(newest.message, 80)} (${relativeTime(newest.date)})`
|
|
108
154
|
: `Watching ${this.repo}`;
|
|
109
|
-
return this.report('healthy',
|
|
155
|
+
return this.report('healthy', message, subStatus);
|
|
110
156
|
}
|
|
111
|
-
report(status,
|
|
112
|
-
const
|
|
113
|
-
const secondsSinceLast =
|
|
114
|
-
? Math.max(0, Math.round((Date.now() -
|
|
157
|
+
report(status, message, subStatus) {
|
|
158
|
+
const newestAt = Date.parse(this.commits[0]?.date ?? '');
|
|
159
|
+
const secondsSinceLast = Number.isFinite(newestAt)
|
|
160
|
+
? Math.max(0, Math.round((Date.now() - newestAt) / 1000))
|
|
115
161
|
: 0;
|
|
116
162
|
return {
|
|
117
163
|
status,
|
|
118
|
-
mood,
|
|
164
|
+
mood: moodForStatus(status),
|
|
119
165
|
message,
|
|
120
166
|
subStatus,
|
|
121
167
|
screenData: this.buildBoard(),
|
|
@@ -129,9 +175,9 @@ export class GitHubWatcherNPC extends PollingNPC {
|
|
|
129
175
|
buildBoard() {
|
|
130
176
|
const now = Date.now();
|
|
131
177
|
const header = this.branch ? `${this.repo}@${this.branch}` : this.repo;
|
|
132
|
-
const values = [{ label: 'repo', value: header }];
|
|
133
|
-
for (const c of this.commits.slice(0,
|
|
134
|
-
const recent =
|
|
178
|
+
const values = [{ label: 'repo', value: truncate(header, 60) }];
|
|
179
|
+
for (const c of this.commits.slice(0, DEFAULTS.boardRows)) {
|
|
180
|
+
const recent = now - Date.parse(c.date) < 3600_000;
|
|
135
181
|
values.push({
|
|
136
182
|
label: c.sha.slice(0, 7),
|
|
137
183
|
value: truncate(`${c.author}: ${c.message}`, 60),
|
|
@@ -1,24 +1,21 @@
|
|
|
1
1
|
import type { LatencyHost } from './defaults.js';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
/** One probe's outcome. `timeMs` is only meaningful when `ok`. */
|
|
3
|
+
export interface CheckResult {
|
|
4
|
+
target: string;
|
|
4
5
|
ok: boolean;
|
|
5
6
|
timeMs: number;
|
|
6
7
|
error?: string;
|
|
7
8
|
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
export
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
timeMs: number;
|
|
21
|
-
error?: string;
|
|
22
|
-
}
|
|
23
|
-
export declare function checkLatency(hosts: LatencyHost[]): Promise<LatencyCheckResult[]>;
|
|
9
|
+
/**
|
|
10
|
+
* Resolve each host's A records through a dedicated resolver with a bounded
|
|
11
|
+
* timeout (the default resolver retries for tens of seconds when DNS is down).
|
|
12
|
+
*/
|
|
13
|
+
export declare function checkDns(hosts: string[], timeoutMs: number): Promise<CheckResult[]>;
|
|
14
|
+
/**
|
|
15
|
+
* Time to response headers for each URL. The body is discarded unread, so a
|
|
16
|
+
* large or endless response costs nothing. Only 2xx counts as reachable.
|
|
17
|
+
*/
|
|
18
|
+
export declare function checkHttp(urls: string[], timeoutMs: number): Promise<CheckResult[]>;
|
|
19
|
+
/** Time to establish a TCP connection to each host:port. */
|
|
20
|
+
export declare function checkLatency(hosts: LatencyHost[], timeoutMs: number): Promise<CheckResult[]>;
|
|
24
21
|
//# sourceMappingURL=checks.d.ts.map
|