-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Expand file tree
/
Copy pathidle-cooldown.ts
More file actions
149 lines (133 loc) · 5.1 KB
/
idle-cooldown.ts
File metadata and controls
149 lines (133 loc) · 5.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
/**
* Idle Notification Cooldown
*
* Prevents flooding users with session-idle notifications by enforcing a
* minimum interval between dispatches. Ported from OMC persistent-mode hook.
*
* Config key : notifications.idleCooldownSeconds in ~/.codex/.omx-config.json
* Env var : OMX_IDLE_COOLDOWN_SECONDS (overrides config)
* State file : .omx/state/idle-notif-cooldown.json
* (session-scoped when sessionId is available)
*
* A cooldown value of 0 disables throttling entirely.
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { codexHome } from '../utils/paths.js';
const DEFAULT_COOLDOWN_SECONDS = 60;
const SESSION_ID_SAFE_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/;
const MAX_IDLE_FINGERPRINT_LENGTH = 512;
interface IdleNotificationState {
lastSentAt?: string;
fingerprint?: string;
}
/**
* Read the idle notification cooldown in seconds.
*
* Resolution order:
* 1. OMX_IDLE_COOLDOWN_SECONDS env var
* 2. notifications.idleCooldownSeconds in ~/.codex/.omx-config.json
* 3. Default: 60 seconds
*/
export function getIdleNotificationCooldownSeconds(): number {
// 1. Environment variable override
const envVal = process.env.OMX_IDLE_COOLDOWN_SECONDS;
if (envVal !== undefined) {
const parsed = Number(envVal);
if (Number.isFinite(parsed) && parsed >= 0) {
return Math.floor(parsed);
}
}
// 2. Config file
try {
const configPath = join(codexHome(), '.omx-config.json');
if (existsSync(configPath)) {
const raw = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
const notifications = raw?.notifications as Record<string, unknown> | undefined;
const val = notifications?.idleCooldownSeconds;
if (typeof val === 'number' && Number.isFinite(val)) {
return Math.max(0, Math.floor(val));
}
}
} catch {
// ignore parse errors — fall through to default
}
return DEFAULT_COOLDOWN_SECONDS;
}
/**
* Resolve the path to the cooldown state file.
* Uses a session-scoped path when sessionId is provided and safe.
*/
function getCooldownStatePath(stateDir: string, sessionId?: string): string {
if (sessionId && SESSION_ID_SAFE_PATTERN.test(sessionId)) {
return join(stateDir, 'sessions', sessionId, 'idle-notif-cooldown.json');
}
return join(stateDir, 'idle-notif-cooldown.json');
}
function normalizeIdleFingerprint(fingerprint: string | null | undefined): string {
if (typeof fingerprint !== 'string') return '';
const normalized = fingerprint.trim();
if (!normalized) return '';
return normalized.length > MAX_IDLE_FINGERPRINT_LENGTH
? normalized.slice(0, MAX_IDLE_FINGERPRINT_LENGTH)
: normalized;
}
function readIdleNotificationState(cooldownPath: string): IdleNotificationState | null {
try {
if (!existsSync(cooldownPath)) return null;
const data = JSON.parse(readFileSync(cooldownPath, 'utf-8')) as Record<string, unknown>;
return {
lastSentAt: typeof data?.lastSentAt === 'string' ? data.lastSentAt : undefined,
fingerprint: normalizeIdleFingerprint(typeof data?.fingerprint === 'string' ? data.fingerprint : ''),
};
} catch {
return null;
}
}
/**
* Check whether an idle notification should be sent.
*
* Without a fingerprint this preserves the legacy cooldown-only behavior.
* With a fingerprint it suppresses unchanged idle-state repeats until the
* fingerprint meaningfully changes.
*/
export function shouldSendIdleNotification(stateDir: string, sessionId?: string, fingerprint?: string): boolean {
const cooldownSecs = getIdleNotificationCooldownSeconds();
const normalizedFingerprint = normalizeIdleFingerprint(fingerprint);
// Cooldown of 0 means disabled — always send, including fingerprinted repeats
if (cooldownSecs === 0) return true;
const cooldownPath = getCooldownStatePath(stateDir, sessionId);
const state = readIdleNotificationState(cooldownPath);
if (!state) return true;
if (normalizedFingerprint) {
return state.fingerprint !== normalizedFingerprint;
}
if (state.lastSentAt) {
const lastSentMs = new Date(state.lastSentAt).getTime();
if (Number.isFinite(lastSentMs)) {
const elapsedSecs = (Date.now() - lastSentMs) / 1000;
if (elapsedSecs < cooldownSecs) return false;
}
}
return true;
}
/**
* Record that an idle notification was sent at the current timestamp.
* Call this after a successful dispatch to arm the cooldown and optionally
* persist the current idle-state fingerprint.
*/
export function recordIdleNotificationSent(stateDir: string, sessionId?: string, fingerprint?: string): void {
const cooldownPath = getCooldownStatePath(stateDir, sessionId);
try {
const dir = dirname(cooldownPath);
mkdirSync(dir, { recursive: true });
const normalizedFingerprint = normalizeIdleFingerprint(fingerprint);
const state: IdleNotificationState = { lastSentAt: new Date().toISOString() };
if (normalizedFingerprint) {
state.fingerprint = normalizedFingerprint;
}
writeFileSync(cooldownPath, JSON.stringify(state, null, 2));
} catch {
// ignore write errors — best effort
}
}