@nocville/npc 0.0.0-stage → 0.2.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 (210) hide show
  1. package/README.md +243 -2
  2. package/dist/cjs/cli.js +533 -0
  3. package/dist/cjs/core/api-key.js +69 -0
  4. package/dist/cjs/core/base-npc.js +160 -0
  5. package/dist/cjs/core/config.js +108 -0
  6. package/dist/cjs/core/event-npc.js +14 -0
  7. package/dist/cjs/core/logger.js +39 -0
  8. package/dist/cjs/core/polling-npc.js +65 -0
  9. package/dist/cjs/core/screen.js +28 -0
  10. package/dist/cjs/core/text.js +15 -0
  11. package/dist/cjs/core/thresholds.js +48 -0
  12. package/dist/cjs/core/types.js +10 -0
  13. package/dist/cjs/core/user-config.js +92 -0
  14. package/dist/cjs/doctor.js +130 -0
  15. package/dist/cjs/index.js +90 -0
  16. package/dist/cjs/npcs/agent-common/activity.js +291 -0
  17. package/dist/cjs/npcs/agent-common/git-stats.js +71 -0
  18. package/dist/cjs/npcs/agent-common/hook-runner.js +252 -0
  19. package/dist/cjs/npcs/agent-common/hook-settings.js +279 -0
  20. package/dist/cjs/npcs/agent-common/liveness.js +112 -0
  21. package/dist/cjs/npcs/agent-common/lock.js +99 -0
  22. package/dist/cjs/npcs/agent-common/runtime.js +154 -0
  23. package/dist/cjs/npcs/agent-common/session.js +390 -0
  24. package/dist/cjs/npcs/agent-common/shim.js +96 -0
  25. package/dist/cjs/npcs/agent-common/watchdog.js +149 -0
  26. package/dist/cjs/npcs/claude-code/defaults.js +20 -0
  27. package/dist/cjs/npcs/claude-code/hook-handler.js +139 -0
  28. package/dist/cjs/npcs/claude-code/index.js +104 -0
  29. package/dist/cjs/npcs/claude-code/setup.js +133 -0
  30. package/dist/cjs/npcs/claude-code/state-machine.js +130 -0
  31. package/dist/cjs/npcs/codex/defaults.js +32 -0
  32. package/dist/cjs/npcs/codex/hook-handler.js +116 -0
  33. package/dist/cjs/npcs/codex/index.js +92 -0
  34. package/dist/cjs/npcs/codex/rollout.js +246 -0
  35. package/dist/cjs/npcs/codex/setup.js +104 -0
  36. package/dist/cjs/npcs/codex/watcher.js +346 -0
  37. package/dist/cjs/npcs/cursor/defaults.js +15 -0
  38. package/dist/cjs/npcs/cursor/hook-handler.js +65 -0
  39. package/dist/cjs/npcs/cursor/index.js +72 -0
  40. package/dist/cjs/npcs/cursor/normalize.js +54 -0
  41. package/dist/cjs/npcs/cursor/setup.js +98 -0
  42. package/dist/cjs/npcs/gemini/defaults.js +15 -0
  43. package/dist/cjs/npcs/gemini/hook-handler.js +93 -0
  44. package/dist/cjs/npcs/gemini/index.js +69 -0
  45. package/dist/cjs/npcs/gemini/setup.js +98 -0
  46. package/dist/cjs/npcs/github-watcher/defaults.js +11 -0
  47. package/dist/cjs/npcs/github-watcher/format.js +21 -0
  48. package/dist/cjs/npcs/github-watcher/github-api.js +126 -0
  49. package/dist/cjs/npcs/github-watcher/index.js +148 -0
  50. package/dist/cjs/npcs/internet-connectivity/checks.js +92 -0
  51. package/dist/cjs/npcs/internet-connectivity/defaults.js +22 -0
  52. package/dist/cjs/npcs/internet-connectivity/index.js +251 -0
  53. package/dist/cjs/npcs/log-watcher/buffer.js +40 -0
  54. package/dist/cjs/npcs/log-watcher/defaults.js +12 -0
  55. package/dist/cjs/npcs/log-watcher/index.js +161 -0
  56. package/dist/cjs/npcs/log-watcher/tail.js +81 -0
  57. package/dist/cjs/npcs/process-watcher/defaults.js +9 -0
  58. package/dist/cjs/npcs/process-watcher/index.js +140 -0
  59. package/dist/cjs/npcs/process-watcher/process-scanner.js +114 -0
  60. package/dist/cjs/npcs/receptionist/defaults.js +9 -0
  61. package/dist/cjs/npcs/receptionist/index.js +120 -0
  62. package/dist/cjs/npcs/receptionist/weather.js +129 -0
  63. package/dist/cjs/npcs/system-monitor/collectors.js +68 -0
  64. package/dist/cjs/npcs/system-monitor/defaults.js +13 -0
  65. package/dist/cjs/npcs/system-monitor/index.js +105 -0
  66. package/dist/cjs/npcs/web-page-watcher/basic-mode.js +58 -0
  67. package/dist/cjs/npcs/web-page-watcher/capture.js +80 -0
  68. package/dist/cjs/npcs/web-page-watcher/defaults.js +31 -0
  69. package/dist/cjs/npcs/web-page-watcher/index.js +234 -0
  70. package/dist/cjs/npcs/web-page-watcher/pixelate.js +181 -0
  71. package/dist/cjs/package.json +1 -0
  72. package/dist/cli.d.ts +3 -0
  73. package/dist/cli.js +498 -0
  74. package/dist/core/api-key.d.ts +34 -0
  75. package/dist/core/api-key.js +64 -0
  76. package/dist/core/base-npc.d.ts +41 -0
  77. package/dist/core/base-npc.js +156 -0
  78. package/dist/core/config.d.ts +22 -0
  79. package/dist/core/config.js +101 -0
  80. package/dist/core/event-npc.d.ts +8 -0
  81. package/dist/core/event-npc.js +10 -0
  82. package/dist/core/logger.d.ts +11 -0
  83. package/dist/core/logger.js +35 -0
  84. package/dist/core/polling-npc.d.ts +18 -0
  85. package/dist/core/polling-npc.js +61 -0
  86. package/dist/core/screen.d.ts +11 -0
  87. package/dist/core/screen.js +24 -0
  88. package/dist/core/text.d.ts +6 -0
  89. package/dist/core/text.js +12 -0
  90. package/dist/core/thresholds.d.ts +10 -0
  91. package/dist/core/thresholds.js +44 -0
  92. package/dist/core/types.d.ts +60 -0
  93. package/dist/core/types.js +7 -0
  94. package/dist/core/user-config.d.ts +43 -0
  95. package/dist/core/user-config.js +85 -0
  96. package/dist/doctor.d.ts +8 -0
  97. package/dist/doctor.js +126 -0
  98. package/dist/index.d.ts +39 -0
  99. package/dist/index.js +25 -0
  100. package/dist/npcs/agent-common/activity.d.ts +42 -0
  101. package/dist/npcs/agent-common/activity.js +280 -0
  102. package/dist/npcs/agent-common/git-stats.d.ts +11 -0
  103. package/dist/npcs/agent-common/git-stats.js +67 -0
  104. package/dist/npcs/agent-common/hook-runner.d.ts +62 -0
  105. package/dist/npcs/agent-common/hook-runner.js +245 -0
  106. package/dist/npcs/agent-common/hook-settings.d.ts +75 -0
  107. package/dist/npcs/agent-common/hook-settings.js +265 -0
  108. package/dist/npcs/agent-common/liveness.d.ts +12 -0
  109. package/dist/npcs/agent-common/liveness.js +105 -0
  110. package/dist/npcs/agent-common/lock.d.ts +6 -0
  111. package/dist/npcs/agent-common/lock.js +95 -0
  112. package/dist/npcs/agent-common/runtime.d.ts +16 -0
  113. package/dist/npcs/agent-common/runtime.js +149 -0
  114. package/dist/npcs/agent-common/session.d.ts +184 -0
  115. package/dist/npcs/agent-common/session.js +368 -0
  116. package/dist/npcs/agent-common/shim.d.ts +4 -0
  117. package/dist/npcs/agent-common/shim.js +90 -0
  118. package/dist/npcs/agent-common/watchdog.d.ts +16 -0
  119. package/dist/npcs/agent-common/watchdog.js +144 -0
  120. package/dist/npcs/claude-code/defaults.d.ts +15 -0
  121. package/dist/npcs/claude-code/defaults.js +17 -0
  122. package/dist/npcs/claude-code/hook-handler.d.ts +40 -0
  123. package/dist/npcs/claude-code/hook-handler.js +131 -0
  124. package/dist/npcs/claude-code/index.d.ts +36 -0
  125. package/dist/npcs/claude-code/index.js +92 -0
  126. package/dist/npcs/claude-code/setup.d.ts +58 -0
  127. package/dist/npcs/claude-code/setup.js +121 -0
  128. package/dist/npcs/claude-code/state-machine.d.ts +28 -0
  129. package/dist/npcs/claude-code/state-machine.js +125 -0
  130. package/dist/npcs/codex/defaults.d.ts +24 -0
  131. package/dist/npcs/codex/defaults.js +28 -0
  132. package/dist/npcs/codex/hook-handler.d.ts +24 -0
  133. package/dist/npcs/codex/hook-handler.js +110 -0
  134. package/dist/npcs/codex/index.d.ts +21 -0
  135. package/dist/npcs/codex/index.js +76 -0
  136. package/dist/npcs/codex/rollout.d.ts +39 -0
  137. package/dist/npcs/codex/rollout.js +242 -0
  138. package/dist/npcs/codex/setup.d.ts +37 -0
  139. package/dist/npcs/codex/setup.js +96 -0
  140. package/dist/npcs/codex/watcher.d.ts +62 -0
  141. package/dist/npcs/codex/watcher.js +341 -0
  142. package/dist/npcs/cursor/defaults.d.ts +9 -0
  143. package/dist/npcs/cursor/defaults.js +11 -0
  144. package/dist/npcs/cursor/hook-handler.d.ts +8 -0
  145. package/dist/npcs/cursor/hook-handler.js +60 -0
  146. package/dist/npcs/cursor/index.d.ts +18 -0
  147. package/dist/npcs/cursor/index.js +57 -0
  148. package/dist/npcs/cursor/normalize.d.ts +6 -0
  149. package/dist/npcs/cursor/normalize.js +49 -0
  150. package/dist/npcs/cursor/setup.d.ts +29 -0
  151. package/dist/npcs/cursor/setup.js +90 -0
  152. package/dist/npcs/gemini/defaults.d.ts +9 -0
  153. package/dist/npcs/gemini/defaults.js +11 -0
  154. package/dist/npcs/gemini/hook-handler.d.ts +10 -0
  155. package/dist/npcs/gemini/hook-handler.js +87 -0
  156. package/dist/npcs/gemini/index.d.ts +17 -0
  157. package/dist/npcs/gemini/index.js +56 -0
  158. package/dist/npcs/gemini/setup.d.ts +29 -0
  159. package/dist/npcs/gemini/setup.js +90 -0
  160. package/dist/npcs/github-watcher/defaults.d.ts +8 -0
  161. package/dist/npcs/github-watcher/defaults.js +8 -0
  162. package/dist/npcs/github-watcher/format.d.ts +3 -0
  163. package/dist/npcs/github-watcher/format.js +18 -0
  164. package/dist/npcs/github-watcher/github-api.d.ts +55 -0
  165. package/dist/npcs/github-watcher/github-api.js +120 -0
  166. package/dist/npcs/github-watcher/index.d.ts +35 -0
  167. package/dist/npcs/github-watcher/index.js +144 -0
  168. package/dist/npcs/internet-connectivity/checks.d.ts +24 -0
  169. package/dist/npcs/internet-connectivity/checks.js +84 -0
  170. package/dist/npcs/internet-connectivity/defaults.d.ts +20 -0
  171. package/dist/npcs/internet-connectivity/defaults.js +19 -0
  172. package/dist/npcs/internet-connectivity/index.d.ts +55 -0
  173. package/dist/npcs/internet-connectivity/index.js +246 -0
  174. package/dist/npcs/log-watcher/buffer.d.ts +18 -0
  175. package/dist/npcs/log-watcher/buffer.js +34 -0
  176. package/dist/npcs/log-watcher/defaults.d.ts +9 -0
  177. package/dist/npcs/log-watcher/defaults.js +9 -0
  178. package/dist/npcs/log-watcher/index.d.ts +45 -0
  179. package/dist/npcs/log-watcher/index.js +157 -0
  180. package/dist/npcs/log-watcher/tail.d.ts +9 -0
  181. package/dist/npcs/log-watcher/tail.js +78 -0
  182. package/dist/npcs/process-watcher/defaults.d.ts +6 -0
  183. package/dist/npcs/process-watcher/defaults.js +6 -0
  184. package/dist/npcs/process-watcher/index.d.ts +26 -0
  185. package/dist/npcs/process-watcher/index.js +136 -0
  186. package/dist/npcs/process-watcher/process-scanner.d.ts +19 -0
  187. package/dist/npcs/process-watcher/process-scanner.js +110 -0
  188. package/dist/npcs/receptionist/defaults.d.ts +6 -0
  189. package/dist/npcs/receptionist/defaults.js +6 -0
  190. package/dist/npcs/receptionist/index.d.ts +29 -0
  191. package/dist/npcs/receptionist/index.js +116 -0
  192. package/dist/npcs/receptionist/weather.d.ts +37 -0
  193. package/dist/npcs/receptionist/weather.js +121 -0
  194. package/dist/npcs/system-monitor/collectors.d.ts +45 -0
  195. package/dist/npcs/system-monitor/collectors.js +60 -0
  196. package/dist/npcs/system-monitor/defaults.d.ts +7 -0
  197. package/dist/npcs/system-monitor/defaults.js +10 -0
  198. package/dist/npcs/system-monitor/index.d.ts +27 -0
  199. package/dist/npcs/system-monitor/index.js +101 -0
  200. package/dist/npcs/web-page-watcher/basic-mode.d.ts +16 -0
  201. package/dist/npcs/web-page-watcher/basic-mode.js +52 -0
  202. package/dist/npcs/web-page-watcher/capture.d.ts +49 -0
  203. package/dist/npcs/web-page-watcher/capture.js +75 -0
  204. package/dist/npcs/web-page-watcher/defaults.d.ts +19 -0
  205. package/dist/npcs/web-page-watcher/defaults.js +27 -0
  206. package/dist/npcs/web-page-watcher/index.d.ts +42 -0
  207. package/dist/npcs/web-page-watcher/index.js +230 -0
  208. package/dist/npcs/web-page-watcher/pixelate.d.ts +36 -0
  209. package/dist/npcs/web-page-watcher/pixelate.js +172 -0
  210. package/package.json +73 -3
@@ -0,0 +1,67 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { basename } from 'node:path';
3
+ import { performance } from 'node:perf_hooks';
4
+ /** Parse NUL-delimited numstat, including binary files and rename path pairs. */
5
+ export function parseNumstat(raw) {
6
+ const fields = raw.split('\0'), files = [];
7
+ for (let i = 0; i < fields.length; i++) {
8
+ const record = fields[i];
9
+ if (!record)
10
+ continue;
11
+ const match = /^(\d+|-)\t(\d+|-)\t([\s\S]*)$/.exec(record);
12
+ if (!match)
13
+ continue;
14
+ let path = match[3];
15
+ if (!path) {
16
+ i++;
17
+ path = fields[++i] ?? '';
18
+ }
19
+ if (path)
20
+ files.push({
21
+ path,
22
+ insertions: match[1] === '-' ? 0 : Number(match[1]),
23
+ deletions: match[2] === '-' ? 0 : Number(match[2]),
24
+ });
25
+ }
26
+ return files;
27
+ }
28
+ /** Both commands share one wall-clock budget; call only from detached flushes. */
29
+ export function computeGitStats(cwd, timeoutMs = 1500) {
30
+ const deadline = performance.now() + Math.max(1, Math.min(1500, timeoutMs));
31
+ const git = (args) => {
32
+ const remaining = Math.floor(deadline - performance.now());
33
+ if (remaining <= 0)
34
+ return null;
35
+ const result = spawnSync('git', ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-C', cwd, ...args], {
36
+ encoding: 'utf8',
37
+ timeout: remaining,
38
+ killSignal: 'SIGKILL',
39
+ maxBuffer: 1 << 20,
40
+ windowsHide: true,
41
+ });
42
+ return result.status === 0 && !result.error ? result.stdout : null;
43
+ };
44
+ const diff = git(['diff', '--no-ext-diff', '--no-textconv', '--numstat', '-z', 'HEAD', '--']);
45
+ if (diff === null)
46
+ return null;
47
+ const untracked = git(['ls-files', '--others', '--exclude-standard', '-z']);
48
+ if (untracked === null)
49
+ return null;
50
+ const files = parseNumstat(diff);
51
+ for (const path of untracked.split('\0'))
52
+ if (path)
53
+ files.push({ path, insertions: 0, deletions: 0 });
54
+ if (files.length > 100_000)
55
+ return null;
56
+ files.sort((a, b) => b.insertions + b.deletions - (a.insertions + a.deletions) || a.path.localeCompare(b.path));
57
+ return {
58
+ filesChanged: files.length,
59
+ insertions: files.reduce((n, file) => n + file.insertions, 0),
60
+ deletions: files.reduce((n, file) => n + file.deletions, 0),
61
+ lastFiles: files.slice(0, 5).map((file) => basename(file.path)
62
+ // eslint-disable-next-line no-control-regex -- Strip display controls from Git filenames.
63
+ .replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '')
64
+ .slice(0, 120)),
65
+ };
66
+ }
67
+ //# sourceMappingURL=git-stats.js.map
@@ -0,0 +1,62 @@
1
+ import type { StatusUpdate } from '@nocville/node';
2
+ import type { NpcConfig } from '../../core/types.js';
3
+ import { computeGitStats } from './git-stats.js';
4
+ import type { AgentAdapter, AgentSessionState, AgentTransition } from './session.js';
5
+ /** A raw hook payload (Claude Code and Codex share the same envelope shape). */
6
+ export interface AgentHookEvent {
7
+ session_id?: string;
8
+ hook_event_name?: string;
9
+ /** Legacy alias accepted by older callers/tests. */
10
+ event?: string;
11
+ cwd?: string;
12
+ tool_name?: string;
13
+ tool_input?: unknown;
14
+ [key: string]: unknown;
15
+ }
16
+ export declare function hookEventName(event: AgentHookEvent): string;
17
+ /** Everything that differs between agents. */
18
+ export interface AgentHookSpec {
19
+ adapter: AgentAdapter;
20
+ /** Required neutral response for hosts whose hook protocol always expects JSON. */
21
+ stdoutOnSuccess?: string;
22
+ statsEvents?: readonly string[];
23
+ /** Cross-source pairs (native and imported hooks), deduplicated under the state lock. */
24
+ deliveryIdentity?(event: AgentHookEvent): {
25
+ key: string;
26
+ source: string;
27
+ };
28
+ serviceIdFor(sessionId: string, cwd: string): string;
29
+ transition(event: AgentHookEvent, prev: AgentSessionState | null): AgentTransition;
30
+ /** Optional user-facing session name (e.g. Claude Code session title). */
31
+ resolveName?(event: AgentHookEvent, prev: AgentSessionState | null): string | undefined;
32
+ }
33
+ export interface HookRunOptions {
34
+ /**
35
+ * 'inline' awaits the push in-process (library use, tests). 'detach' (the CLI
36
+ * default) hands pushes to a background process so the hook exits at once.
37
+ */
38
+ mode?: 'inline' | 'detach';
39
+ minPushGapMs?: number;
40
+ now?: () => number;
41
+ /** Inline-mode push override (tests). */
42
+ push?: (serviceId: string, payload: StatusUpdate) => Promise<{
43
+ success: boolean;
44
+ error?: string;
45
+ }>;
46
+ /** Override how deferred flushes are scheduled (tests). */
47
+ scheduleFlush?: (sessionId: string, delayMs: number) => void;
48
+ /** cli.js path for detached flushes (default: process.argv[1]). */
49
+ cliPath?: string;
50
+ }
51
+ /** Read all of stdin synchronously; '' on error. */
52
+ export declare function readStdin(): string;
53
+ /**
54
+ * Process one hook invocation. Never throws. Only a configured neutral JSON response
55
+ * reaches stdout; other hosts receive no output.
56
+ */
57
+ export declare function runAgentHook(spec: AgentHookSpec, config: NpcConfig, rawInput: string | AgentHookEvent, opts?: HookRunOptions): Promise<void>;
58
+ /** `<agent> flush <session> --delay=ms`: the detached half of a hook push. */
59
+ export declare function runFlushCommand(adapter: AgentAdapter, config: NpcConfig, sessionId: string | undefined, delayMs: number | undefined, stats?: boolean): Promise<void>;
60
+ /** Git runs outside the state lock, and a newer turn can invalidate its result. */
61
+ export declare function collectSessionGitStats(adapter: AgentAdapter, sessionId: string, compute?: typeof computeGitStats): Promise<void>;
62
+ //# sourceMappingURL=hook-runner.d.ts.map
@@ -0,0 +1,245 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describeToolUse } from './activity.js';
3
+ import { computeGitStats } from './git-stats.js';
4
+ import { resolveAgentPid, reserveAgentPidAttempt } from './liveness.js';
5
+ import { withStateLock } from './lock.js';
6
+ import { applySubagentEvent, pushHistory, maybeCleanupSessionStates, stateFilePath, applyTransition, flushSession, readSessionState, sleep, spawnDetachedFlush, writeSessionState, DEFAULT_MIN_PUSH_GAP_MS, } from './session.js';
7
+ import { ensureWatchdog } from './watchdog.js';
8
+ export function hookEventName(event) {
9
+ return event.hook_event_name ?? event.event ?? '';
10
+ }
11
+ /** Read all of stdin synchronously; '' on error. */
12
+ export function readStdin() {
13
+ try {
14
+ return readFileSync(0, 'utf-8');
15
+ }
16
+ catch {
17
+ return '';
18
+ }
19
+ }
20
+ /**
21
+ * Process one hook invocation. Never throws. Only a configured neutral JSON response
22
+ * reaches stdout; other hosts receive no output.
23
+ */
24
+ export async function runAgentHook(spec, config, rawInput, opts = {}) {
25
+ try {
26
+ await runAgentHookUnsafe(spec, config, rawInput, opts);
27
+ }
28
+ catch (err) {
29
+ if (config.debug) {
30
+ console.error(`[nocville ${spec.adapter.command} hook] ${err?.message ?? err}`);
31
+ }
32
+ }
33
+ finally {
34
+ if (spec.stdoutOnSuccess !== undefined)
35
+ process.stdout.write(spec.stdoutOnSuccess + '\n');
36
+ }
37
+ }
38
+ async function runAgentHookUnsafe(spec, config, rawInput, opts) {
39
+ if (typeof rawInput === 'string' && !rawInput.trim())
40
+ return;
41
+ let event;
42
+ try {
43
+ event = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
44
+ }
45
+ catch {
46
+ return;
47
+ }
48
+ if (!event || typeof event !== 'object')
49
+ return;
50
+ const sessionId = typeof event.session_id === 'string' ? event.session_id : '';
51
+ if (!sessionId)
52
+ return;
53
+ const { adapter } = spec;
54
+ const eventName = hookEventName(event);
55
+ // Leave headroom for launcher startup and the 1.5s HTTP attempt within Codex's 3s cap.
56
+ const shutdown = eventName === 'SessionEnd' || eventName === 'Interrupt';
57
+ const lockWaitMs = shutdown ? 150 : adapter.family === 'gemini' ? 500 : undefined;
58
+ const canResolvePid = adapter.processNames?.length !== 0 &&
59
+ !shutdown &&
60
+ opts.mode === 'detach' &&
61
+ process.env['NOCVILLE_NO_WATCHDOG'] !== '1';
62
+ const now = (opts.now ?? Date.now)();
63
+ if (eventName === 'SessionStart')
64
+ await maybeCleanupSessionStates(adapter.stateDir, now);
65
+ const { decision, offline, resolvePid, collectStats } = await withStateLock(stateFilePath(adapter.stateDir, sessionId), () => {
66
+ const prev = readSessionState(adapter.stateDir, sessionId);
67
+ if (prev && adapter.processNames?.length === 0 && (prev.agentPid || prev.watchdogPid)) {
68
+ // Retire observers installed before an adapter opted out of process liveness.
69
+ prev.agentPid = undefined;
70
+ prev.agentStartedAt = undefined;
71
+ prev.watchdogPid = undefined;
72
+ writeSessionState(adapter.stateDir, prev);
73
+ }
74
+ const identity = spec.deliveryIdentity?.(event);
75
+ const deliveries = (prev?.hookDeliveries ?? []).filter((d) => now - d.at < 5000);
76
+ if (identity) {
77
+ const duplicate = deliveries.findIndex((d) => d.key === identity.key && d.source !== identity.source);
78
+ if (duplicate >= 0 && prev) {
79
+ deliveries.splice(duplicate, 1);
80
+ prev.hookDeliveries = deliveries;
81
+ writeSessionState(adapter.stateDir, prev);
82
+ return {
83
+ decision: { kind: 'none' },
84
+ offline: !!prev.offline,
85
+ resolvePid: false,
86
+ collectStats: false,
87
+ };
88
+ }
89
+ deliveries.push({ ...identity, at: now });
90
+ }
91
+ const cwd = (typeof event.cwd === 'string' && event.cwd) || prev?.projectDir || process.cwd();
92
+ // An offline sentinel awaiting retry cannot be revived by late async tool hooks.
93
+ if (prev?.offline && eventName !== 'SessionStart')
94
+ return {
95
+ decision: { kind: 'now' },
96
+ offline: true,
97
+ resolvePid: false,
98
+ collectStats: false,
99
+ };
100
+ const agentId = typeof event['agent_id'] === 'string' ? event['agent_id'] : '';
101
+ if (adapter.family === 'claude' &&
102
+ agentId &&
103
+ prev &&
104
+ eventName !== 'PermissionRequest' &&
105
+ eventName !== 'SubagentStart') {
106
+ const next = applySubagentEvent(prev, agentId, typeof event['agent_type'] === 'string' ? event['agent_type'] : 'subagent', eventName, describeToolUse(event.tool_name, event.tool_input, cwd), now);
107
+ let decision = { kind: 'none' };
108
+ if (next.rev !== prev.rev) {
109
+ const gap = opts.minPushGapMs ?? DEFAULT_MIN_PUSH_GAP_MS;
110
+ const elapsed = now - (prev.lastPushAt ?? 0);
111
+ if (!prev.lastPushAt || elapsed >= gap) {
112
+ next.lastPushAt = now;
113
+ decision = { kind: 'now' };
114
+ }
115
+ else if (!next.flushAt || next.flushAt <= now - 2000) {
116
+ const delayMs = Math.max(50, gap - elapsed);
117
+ next.flushAt = now + delayMs;
118
+ decision = { kind: 'defer', delayMs };
119
+ }
120
+ }
121
+ const resolvePid = canResolvePid && reserveAgentPidAttempt(next, now);
122
+ writeSessionState(adapter.stateDir, next);
123
+ return { decision, offline: false, resolvePid, collectStats: false };
124
+ }
125
+ // Codex has no documented agent_id in hook payloads: keep its parent transitions.
126
+ // https://developers.openai.com/codex/hooks
127
+ const { next, decision } = applyTransition(prev, {
128
+ sessionId,
129
+ serviceId: spec.serviceIdFor(sessionId, cwd),
130
+ projectDir: cwd,
131
+ eventName,
132
+ name: spec.resolveName?.(event, prev),
133
+ transition: spec.transition(event, prev),
134
+ now,
135
+ }, opts.minPushGapMs ?? DEFAULT_MIN_PUSH_GAP_MS);
136
+ if (!prev || prev.offline) {
137
+ next.agentPid = undefined;
138
+ next.agentStartedAt = undefined;
139
+ next.agentPidResolveAttempts = 0;
140
+ next.agentPidLastResolveAt = undefined;
141
+ next.watchdogPid = undefined;
142
+ next.offlineRetryStartedAt = undefined;
143
+ next.offlineDeliveryStopped = undefined;
144
+ next.subagents = {};
145
+ next.agentStats = undefined;
146
+ next.statsPendingRev = undefined;
147
+ }
148
+ if (identity)
149
+ next.hookDeliveries = deliveries.slice(-64);
150
+ const resolvePid = canResolvePid && reserveAgentPidAttempt(next, now);
151
+ const collectStats = (spec.statsEvents ?? ['Stop']).includes(eventName) && !next.offline;
152
+ if (collectStats)
153
+ next.statsPendingRev = next.rev ?? 0;
154
+ else if (next.state === 'WORKING' || next.offline)
155
+ next.statsPendingRev = undefined;
156
+ writeSessionState(adapter.stateDir, next);
157
+ return { decision, offline: !!next.offline, resolvePid, collectStats };
158
+ }, lockWaitMs);
159
+ if (resolvePid && !offline) {
160
+ const proc = resolveAgentPid(adapter, sessionId);
161
+ await withStateLock(stateFilePath(adapter.stateDir, sessionId), () => {
162
+ const fresh = readSessionState(adapter.stateDir, sessionId);
163
+ if (!fresh || fresh.offline)
164
+ return;
165
+ fresh.agentPid = proc?.pid;
166
+ fresh.agentStartedAt = proc?.startedAt;
167
+ writeSessionState(adapter.stateDir, fresh);
168
+ }, lockWaitMs);
169
+ }
170
+ if (opts.mode === 'detach' && !offline)
171
+ await ensureWatchdog(adapter, config, sessionId, opts.cliPath, lockWaitMs);
172
+ // SessionEnd is synchronous and bounded by the agent's short shutdown timeout.
173
+ if (offline) {
174
+ await flushSession(adapter, config, sessionId, {
175
+ push: opts.push,
176
+ timeoutMs: 1500,
177
+ lockWaitMs: 150,
178
+ });
179
+ // If delivery failed, the watchdog retries; unsupported hosts get one detached retry.
180
+ const pending = readSessionState(adapter.stateDir, sessionId);
181
+ if (opts.mode === 'detach' && pending && (pending.rev ?? 0) > (pending.pushedRev ?? 0))
182
+ spawnDetachedFlush(adapter, config, sessionId, 0, opts.cliPath);
183
+ return;
184
+ }
185
+ if (decision.kind === 'none' && !collectStats)
186
+ return;
187
+ const mode = opts.mode ?? 'inline';
188
+ const delayMs = decision.kind === 'defer' ? decision.delayMs : 0;
189
+ if (opts.scheduleFlush && decision.kind === 'defer') {
190
+ opts.scheduleFlush(sessionId, delayMs);
191
+ return;
192
+ }
193
+ if (mode === 'detach') {
194
+ spawnDetachedFlush(adapter, config, sessionId, delayMs, opts.cliPath, collectStats);
195
+ return;
196
+ }
197
+ if (delayMs > 0) {
198
+ const timer = setTimeout(() => {
199
+ void flushSession(adapter, config, sessionId, { push: opts.push });
200
+ }, delayMs);
201
+ timer.unref?.();
202
+ return;
203
+ }
204
+ await flushSession(adapter, config, sessionId, { push: opts.push });
205
+ }
206
+ /** `<agent> flush <session> --delay=ms`: the detached half of a hook push. */
207
+ export async function runFlushCommand(adapter, config, sessionId, delayMs, stats = false) {
208
+ if (!sessionId)
209
+ return;
210
+ try {
211
+ if (delayMs && delayMs > 0)
212
+ await sleep(Math.min(delayMs, 10_000));
213
+ if (stats || readSessionState(adapter.stateDir, sessionId)?.statsPendingRev !== undefined)
214
+ await collectSessionGitStats(adapter, sessionId);
215
+ await flushSession(adapter, config, sessionId);
216
+ }
217
+ catch {
218
+ // never surface errors from a background flush
219
+ }
220
+ }
221
+ /** Git runs outside the state lock, and a newer turn can invalidate its result. */
222
+ export async function collectSessionGitStats(adapter, sessionId, compute = computeGitStats) {
223
+ const snapshot = readSessionState(adapter.stateDir, sessionId);
224
+ if (!snapshot || snapshot.offline || snapshot.state === 'WORKING')
225
+ return;
226
+ // Another scheduled flush may already have consumed an explicit --stats request.
227
+ if (snapshot.statsPendingRev === undefined)
228
+ return;
229
+ const revision = snapshot.statsPendingRev;
230
+ const stats = compute(snapshot.projectDir);
231
+ await withStateLock(stateFilePath(adapter.stateDir, sessionId), () => {
232
+ const fresh = readSessionState(adapter.stateDir, sessionId);
233
+ if (!fresh || fresh.offline || fresh.state === 'WORKING' || fresh.statsPendingRev !== revision)
234
+ return;
235
+ fresh.statsPendingRev = undefined;
236
+ fresh.agentStats = stats ?? undefined;
237
+ if (stats && stats.filesChanged > 0) {
238
+ const line = `Uncommitted changes — ${stats.filesChanged} file${stats.filesChanged === 1 ? '' : 's'}, +${stats.insertions}/−${stats.deletions}`;
239
+ fresh.activity = pushHistory(fresh.activity, line);
240
+ }
241
+ fresh.rev = (fresh.rev ?? 0) + 1;
242
+ writeSessionState(adapter.stateDir, fresh);
243
+ });
244
+ }
245
+ //# sourceMappingURL=hook-runner.js.map
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Matcher-group hook config shared by Claude Code (`~/.claude/settings.json`)
3
+ * and Codex (`~/.codex/hooks.json`) — both use:
4
+ *
5
+ * { "hooks": { "<Event>": [ { "matcher": "", "hooks": [ { "type": "command", "command": "…" } ] } ] } }
6
+ *
7
+ * Install/uninstall only ever touch entries whose command carries the
8
+ * `nocville-npc` marker; the user's own hooks are preserved verbatim.
9
+ */
10
+ /** Marker used to identify nocville-npc hook commands (flat or nested). */
11
+ export declare const HOOK_MARKER = "nocville-npc";
12
+ /**
13
+ * Trailing shell comment appended to hook commands. `/bin/sh` ignores it, but it
14
+ * guarantees the marker is present for idempotency regardless of install path.
15
+ */
16
+ export declare const HOOK_MARKER_COMMENT = "# nocville-npc-hook";
17
+ export interface CommandHook {
18
+ type: string;
19
+ command: string;
20
+ timeout?: number;
21
+ [key: string]: unknown;
22
+ }
23
+ export interface HookGroup {
24
+ matcher?: string;
25
+ hooks: CommandHook[];
26
+ [key: string]: unknown;
27
+ }
28
+ export interface HooksFile {
29
+ hooks?: Record<string, unknown>;
30
+ [key: string]: unknown;
31
+ }
32
+ /**
33
+ * Install one nocville hook group into each of `events`. Idempotent: existing
34
+ * nocville entries (flat legacy or nested) are removed first, preserving the
35
+ * user's own hooks, then a single group per event is appended. Mutates `file`.
36
+ */
37
+ export declare function installHookGroups(file: HooksFile, command: string, events: readonly string[], extra?: {
38
+ timeout?: number;
39
+ perEvent?: Record<string, Partial<CommandHook>>;
40
+ }): {
41
+ migrated: number;
42
+ installed: number;
43
+ };
44
+ /** Remove every nocville hook entry. Mutates `file`; returns the count removed. */
45
+ export declare function removeHookGroups(file: HooksFile): number;
46
+ /** First nocville hook command found in any event (any format). */
47
+ export declare function findNocvilleCommand(file: HooksFile): string | undefined;
48
+ /** Extract a `--flag` value from a command string; supports `=`, quotes, or the space form. */
49
+ export declare function extractFlag(command: string, flag: string): string | undefined;
50
+ /** POSIX single-quote a string so /bin/sh treats it as one literal token. */
51
+ export declare function shellQuote(s: string): string;
52
+ /**
53
+ * Absolute hook command: `'<node>' '<cli.js>' <agent> hook # nocville-npc-hook`.
54
+ *
55
+ * Agents run hooks via `/bin/sh` without the user's interactive PATH (nvm /
56
+ * homebrew shims are absent), so a bare `nocville-npc` would fail; absolute
57
+ * paths fix that. No API key on the command line — the hook reads the saved
58
+ * user config (~/.config/nocville/config.json).
59
+ */
60
+ export declare function buildAgentHookCommand(cliPath: string, agent: string, extraArgs?: string[], nodePath?: string): string;
61
+ /**
62
+ * The node binary to bake into hook commands. `process.execPath` is often a
63
+ * versioned path (e.g. Homebrew's /opt/homebrew/Cellar/node/24.4.1/bin/node)
64
+ * that disappears on upgrade, so prefer an unversioned PATH entry (e.g.
65
+ * /opt/homebrew/bin/node) that resolves to the same binary.
66
+ */
67
+ export declare function stableNodePath(execPath?: string, pathEnv?: string): string;
68
+ /** Realpath the running CLI (follows npm bin symlinks). */
69
+ export declare function resolveCliPath(explicit?: string): string;
70
+ /** Read a JSON hooks/settings file; missing → {}; unparsable → null (refuse to overwrite). */
71
+ export declare function readJsonFile(path: string): HooksFile | null;
72
+ /** Write JSON, keeping a one-time `.nocville-backup` of the original file. */
73
+ export declare function writeJsonFile(path: string, data: HooksFile): void;
74
+ export declare function buildShimHookCommand(agent: string, platform?: NodeJS.Platform): string;
75
+ //# sourceMappingURL=hook-settings.d.ts.map