oh-my-codex 0.3.9 → 0.4.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 (184) hide show
  1. package/README.md +25 -0
  2. package/dist/cli/__tests__/doctor-team.test.js +58 -0
  3. package/dist/cli/__tests__/doctor-team.test.js.map +1 -1
  4. package/dist/cli/__tests__/index.test.js +9 -3
  5. package/dist/cli/__tests__/index.test.js.map +1 -1
  6. package/dist/cli/__tests__/lifecycle-notifications.test.d.ts +2 -0
  7. package/dist/cli/__tests__/lifecycle-notifications.test.d.ts.map +1 -0
  8. package/dist/cli/__tests__/lifecycle-notifications.test.js +48 -0
  9. package/dist/cli/__tests__/lifecycle-notifications.test.js.map +1 -0
  10. package/dist/cli/doctor.js +28 -0
  11. package/dist/cli/doctor.js.map +1 -1
  12. package/dist/cli/hooks.d.ts +4 -0
  13. package/dist/cli/hooks.d.ts.map +1 -0
  14. package/dist/cli/hooks.js +201 -0
  15. package/dist/cli/hooks.js.map +1 -0
  16. package/dist/cli/index.d.ts +1 -1
  17. package/dist/cli/index.d.ts.map +1 -1
  18. package/dist/cli/index.js +181 -2
  19. package/dist/cli/index.js.map +1 -1
  20. package/dist/config/__tests__/models.test.d.ts +2 -0
  21. package/dist/config/__tests__/models.test.d.ts.map +1 -0
  22. package/dist/config/__tests__/models.test.js +69 -0
  23. package/dist/config/__tests__/models.test.js.map +1 -0
  24. package/dist/config/models.d.ts +24 -0
  25. package/dist/config/models.d.ts.map +1 -0
  26. package/dist/config/models.js +53 -0
  27. package/dist/config/models.js.map +1 -0
  28. package/dist/hooks/__tests__/notify-hook-linked-sync.test.js +6 -0
  29. package/dist/hooks/__tests__/notify-hook-linked-sync.test.js.map +1 -1
  30. package/dist/hooks/__tests__/notify-hook-session-scope.test.js +6 -0
  31. package/dist/hooks/__tests__/notify-hook-session-scope.test.js.map +1 -1
  32. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +224 -36
  33. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
  34. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js +4 -0
  35. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js.map +1 -1
  36. package/dist/hooks/__tests__/tmux-hook-engine.test.js +1 -1
  37. package/dist/hooks/__tests__/tmux-hook-engine.test.js.map +1 -1
  38. package/dist/hooks/extensibility/__tests__/example-hook-plugins.test.d.ts +2 -0
  39. package/dist/hooks/extensibility/__tests__/example-hook-plugins.test.d.ts.map +1 -0
  40. package/dist/hooks/extensibility/__tests__/example-hook-plugins.test.js +153 -0
  41. package/dist/hooks/extensibility/__tests__/example-hook-plugins.test.js.map +1 -0
  42. package/dist/hooks/extensibility/dispatcher.d.ts +4 -0
  43. package/dist/hooks/extensibility/dispatcher.d.ts.map +1 -0
  44. package/dist/hooks/extensibility/dispatcher.js +223 -0
  45. package/dist/hooks/extensibility/dispatcher.js.map +1 -0
  46. package/dist/hooks/extensibility/events.d.ts +18 -0
  47. package/dist/hooks/extensibility/events.d.ts.map +1 -0
  48. package/dist/hooks/extensibility/events.js +53 -0
  49. package/dist/hooks/extensibility/events.js.map +1 -0
  50. package/dist/hooks/extensibility/index.d.ts +6 -0
  51. package/dist/hooks/extensibility/index.d.ts.map +1 -0
  52. package/dist/hooks/extensibility/index.js +6 -0
  53. package/dist/hooks/extensibility/index.js.map +1 -0
  54. package/dist/hooks/extensibility/loader.d.ts +14 -0
  55. package/dist/hooks/extensibility/loader.d.ts.map +1 -0
  56. package/dist/hooks/extensibility/loader.js +102 -0
  57. package/dist/hooks/extensibility/loader.js.map +1 -0
  58. package/dist/hooks/extensibility/logging.d.ts +4 -0
  59. package/dist/hooks/extensibility/logging.d.ts.map +1 -0
  60. package/dist/hooks/extensibility/logging.js +16 -0
  61. package/dist/hooks/extensibility/logging.js.map +1 -0
  62. package/dist/hooks/extensibility/plugin-runner.d.ts +2 -0
  63. package/dist/hooks/extensibility/plugin-runner.d.ts.map +1 -0
  64. package/dist/hooks/extensibility/plugin-runner.js +69 -0
  65. package/dist/hooks/extensibility/plugin-runner.js.map +1 -0
  66. package/dist/hooks/extensibility/runtime.d.ts +3 -0
  67. package/dist/hooks/extensibility/runtime.d.ts.map +1 -0
  68. package/dist/hooks/extensibility/runtime.js +29 -0
  69. package/dist/hooks/extensibility/runtime.js.map +1 -0
  70. package/dist/hooks/extensibility/sdk.d.ts +11 -0
  71. package/dist/hooks/extensibility/sdk.d.ts.map +1 -0
  72. package/dist/hooks/extensibility/sdk.js +240 -0
  73. package/dist/hooks/extensibility/sdk.js.map +1 -0
  74. package/dist/hooks/extensibility/types.d.ts +122 -0
  75. package/dist/hooks/extensibility/types.d.ts.map +1 -0
  76. package/dist/hooks/extensibility/types.js +2 -0
  77. package/dist/hooks/extensibility/types.js.map +1 -0
  78. package/dist/mcp/__tests__/state-paths.test.js +21 -1
  79. package/dist/mcp/__tests__/state-paths.test.js.map +1 -1
  80. package/dist/mcp/__tests__/state-server-team-tools.test.js +53 -1
  81. package/dist/mcp/__tests__/state-server-team-tools.test.js.map +1 -1
  82. package/dist/mcp/state-paths.d.ts +1 -0
  83. package/dist/mcp/state-paths.d.ts.map +1 -1
  84. package/dist/mcp/state-paths.js +34 -1
  85. package/dist/mcp/state-paths.js.map +1 -1
  86. package/dist/mcp/state-server.d.ts.map +1 -1
  87. package/dist/mcp/state-server.js +46 -11
  88. package/dist/mcp/state-server.js.map +1 -1
  89. package/dist/notifications/__tests__/config.test.d.ts +2 -0
  90. package/dist/notifications/__tests__/config.test.d.ts.map +1 -0
  91. package/dist/notifications/__tests__/config.test.js +186 -0
  92. package/dist/notifications/__tests__/config.test.js.map +1 -0
  93. package/dist/notifications/__tests__/dispatcher.test.d.ts +2 -0
  94. package/dist/notifications/__tests__/dispatcher.test.d.ts.map +1 -0
  95. package/dist/notifications/__tests__/dispatcher.test.js +202 -0
  96. package/dist/notifications/__tests__/dispatcher.test.js.map +1 -0
  97. package/dist/notifications/__tests__/formatter.test.d.ts +2 -0
  98. package/dist/notifications/__tests__/formatter.test.d.ts.map +1 -0
  99. package/dist/notifications/__tests__/formatter.test.js +103 -0
  100. package/dist/notifications/__tests__/formatter.test.js.map +1 -0
  101. package/dist/notifications/__tests__/notifier.test.d.ts +2 -0
  102. package/dist/notifications/__tests__/notifier.test.d.ts.map +1 -0
  103. package/dist/notifications/__tests__/notifier.test.js +104 -0
  104. package/dist/notifications/__tests__/notifier.test.js.map +1 -0
  105. package/dist/notifications/__tests__/profiles.test.d.ts +2 -0
  106. package/dist/notifications/__tests__/profiles.test.d.ts.map +1 -0
  107. package/dist/notifications/__tests__/profiles.test.js +404 -0
  108. package/dist/notifications/__tests__/profiles.test.js.map +1 -0
  109. package/dist/notifications/__tests__/reply-listener.test.d.ts +2 -0
  110. package/dist/notifications/__tests__/reply-listener.test.d.ts.map +1 -0
  111. package/dist/notifications/__tests__/reply-listener.test.js +58 -0
  112. package/dist/notifications/__tests__/reply-listener.test.js.map +1 -0
  113. package/dist/notifications/__tests__/session-registry.test.d.ts +2 -0
  114. package/dist/notifications/__tests__/session-registry.test.d.ts.map +1 -0
  115. package/dist/notifications/__tests__/session-registry.test.js +147 -0
  116. package/dist/notifications/__tests__/session-registry.test.js.map +1 -0
  117. package/dist/notifications/__tests__/tmux-detector.test.d.ts +2 -0
  118. package/dist/notifications/__tests__/tmux-detector.test.d.ts.map +1 -0
  119. package/dist/notifications/__tests__/tmux-detector.test.js +77 -0
  120. package/dist/notifications/__tests__/tmux-detector.test.js.map +1 -0
  121. package/dist/notifications/__tests__/tmux.test.d.ts +2 -0
  122. package/dist/notifications/__tests__/tmux.test.d.ts.map +1 -0
  123. package/dist/notifications/__tests__/tmux.test.js +90 -0
  124. package/dist/notifications/__tests__/tmux.test.js.map +1 -0
  125. package/dist/notifications/config.d.ts +44 -0
  126. package/dist/notifications/config.d.ts.map +1 -0
  127. package/dist/notifications/config.js +407 -0
  128. package/dist/notifications/config.js.map +1 -0
  129. package/dist/notifications/dispatcher.d.ts +15 -0
  130. package/dist/notifications/dispatcher.d.ts.map +1 -0
  131. package/dist/notifications/dispatcher.js +410 -0
  132. package/dist/notifications/dispatcher.js.map +1 -0
  133. package/dist/notifications/formatter.d.ts +14 -0
  134. package/dist/notifications/formatter.d.ts.map +1 -0
  135. package/dist/notifications/formatter.js +134 -0
  136. package/dist/notifications/formatter.js.map +1 -0
  137. package/dist/notifications/index.d.ts +32 -0
  138. package/dist/notifications/index.d.ts.map +1 -0
  139. package/dist/notifications/index.js +93 -0
  140. package/dist/notifications/index.js.map +1 -0
  141. package/dist/notifications/reply-listener.d.ts +47 -0
  142. package/dist/notifications/reply-listener.d.ts.map +1 -0
  143. package/dist/notifications/reply-listener.js +656 -0
  144. package/dist/notifications/reply-listener.js.map +1 -0
  145. package/dist/notifications/session-registry.d.ts +26 -0
  146. package/dist/notifications/session-registry.d.ts.map +1 -0
  147. package/dist/notifications/session-registry.js +275 -0
  148. package/dist/notifications/session-registry.js.map +1 -0
  149. package/dist/notifications/tmux-detector.d.ts +17 -0
  150. package/dist/notifications/tmux-detector.d.ts.map +1 -0
  151. package/dist/notifications/tmux-detector.js +82 -0
  152. package/dist/notifications/tmux-detector.js.map +1 -0
  153. package/dist/notifications/tmux.d.ts +28 -0
  154. package/dist/notifications/tmux.d.ts.map +1 -0
  155. package/dist/notifications/tmux.js +210 -0
  156. package/dist/notifications/tmux.js.map +1 -0
  157. package/dist/notifications/types.d.ts +181 -0
  158. package/dist/notifications/types.d.ts.map +1 -0
  159. package/dist/notifications/types.js +9 -0
  160. package/dist/notifications/types.js.map +1 -0
  161. package/dist/team/__tests__/runtime.test.js +54 -2
  162. package/dist/team/__tests__/runtime.test.js.map +1 -1
  163. package/dist/team/__tests__/state.test.js +30 -0
  164. package/dist/team/__tests__/state.test.js.map +1 -1
  165. package/dist/team/__tests__/worker-bootstrap.test.js +2 -0
  166. package/dist/team/__tests__/worker-bootstrap.test.js.map +1 -1
  167. package/dist/team/runtime.d.ts +2 -2
  168. package/dist/team/runtime.d.ts.map +1 -1
  169. package/dist/team/runtime.js +19 -12
  170. package/dist/team/runtime.js.map +1 -1
  171. package/dist/team/state.d.ts +1 -1
  172. package/dist/team/state.d.ts.map +1 -1
  173. package/dist/team/state.js +5 -0
  174. package/dist/team/state.js.map +1 -1
  175. package/dist/team/tmux-session.d.ts.map +1 -1
  176. package/dist/team/tmux-session.js +59 -15
  177. package/dist/team/tmux-session.js.map +1 -1
  178. package/dist/team/worker-bootstrap.d.ts.map +1 -1
  179. package/dist/team/worker-bootstrap.js +4 -0
  180. package/dist/team/worker-bootstrap.js.map +1 -1
  181. package/package.json +1 -1
  182. package/scripts/hook-derived-watcher.js +335 -0
  183. package/scripts/notify-hook.js +168 -7
  184. package/scripts/tmux-hook-engine.js +3 -2
@@ -0,0 +1,656 @@
1
+ /**
2
+ * Reply Listener Daemon
3
+ *
4
+ * Background daemon that polls Discord and Telegram for replies to notification messages,
5
+ * sanitizes input, verifies the target pane, and injects reply text via sendToPane().
6
+ *
7
+ * Security considerations:
8
+ * - State/PID/log files use restrictive permissions (0600)
9
+ * - Bot tokens stored in state file, NOT in environment variables
10
+ * - Two-layer input sanitization (sanitizeReplyInput + sanitizeForTmux)
11
+ * - Pane verification via analyzePaneContent before every injection
12
+ * - Authorization: only configured user IDs (Discord) / chat ID (Telegram) can inject
13
+ * - Rate limiting to prevent spam/abuse
14
+ */
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, chmodSync, statSync, appendFileSync, renameSync } from 'fs';
16
+ import { join } from 'path';
17
+ import { fileURLToPath } from 'url';
18
+ import { homedir } from 'os';
19
+ import { spawn } from 'child_process';
20
+ import { request as httpsRequest } from 'https';
21
+ import { capturePaneContent, analyzePaneContent, sendToPane, isTmuxAvailable, } from './tmux-detector.js';
22
+ import { lookupByMessageId, removeMessagesByPane, pruneStale, } from './session-registry.js';
23
+ import { parseMentionAllowedMentions } from './config.js';
24
+ const __filename = fileURLToPath(import.meta.url);
25
+ const SECURE_FILE_MODE = 0o600;
26
+ const MAX_LOG_SIZE_BYTES = 1 * 1024 * 1024;
27
+ const DAEMON_ENV_ALLOWLIST = [
28
+ 'PATH', 'HOME', 'USERPROFILE',
29
+ 'USER', 'USERNAME', 'LOGNAME',
30
+ 'LANG', 'LC_ALL', 'LC_CTYPE',
31
+ 'TERM', 'TMUX', 'TMUX_PANE',
32
+ 'TMPDIR', 'TMP', 'TEMP',
33
+ 'XDG_RUNTIME_DIR', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME',
34
+ 'SHELL',
35
+ 'NODE_ENV',
36
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'NO_PROXY', 'no_proxy',
37
+ 'SystemRoot', 'SYSTEMROOT', 'windir', 'COMSPEC',
38
+ ];
39
+ const DEFAULT_STATE_DIR = join(homedir(), '.omx', 'state');
40
+ const PID_FILE_PATH = join(DEFAULT_STATE_DIR, 'reply-listener.pid');
41
+ const STATE_FILE_PATH = join(DEFAULT_STATE_DIR, 'reply-listener-state.json');
42
+ const LOG_FILE_PATH = join(DEFAULT_STATE_DIR, 'reply-listener.log');
43
+ function createMinimalDaemonEnv() {
44
+ const env = {};
45
+ for (const key of DAEMON_ENV_ALLOWLIST) {
46
+ if (process.env[key] !== undefined) {
47
+ env[key] = process.env[key];
48
+ }
49
+ }
50
+ return env;
51
+ }
52
+ function ensureStateDir() {
53
+ if (!existsSync(DEFAULT_STATE_DIR)) {
54
+ mkdirSync(DEFAULT_STATE_DIR, { recursive: true, mode: 0o700 });
55
+ }
56
+ }
57
+ function writeSecureFile(filePath, content) {
58
+ ensureStateDir();
59
+ writeFileSync(filePath, content, { mode: SECURE_FILE_MODE });
60
+ try {
61
+ chmodSync(filePath, SECURE_FILE_MODE);
62
+ }
63
+ catch {
64
+ // Ignore permission errors
65
+ }
66
+ }
67
+ function rotateLogIfNeeded(logPath) {
68
+ try {
69
+ if (!existsSync(logPath))
70
+ return;
71
+ const stats = statSync(logPath);
72
+ if (stats.size > MAX_LOG_SIZE_BYTES) {
73
+ const backupPath = `${logPath}.old`;
74
+ if (existsSync(backupPath)) {
75
+ unlinkSync(backupPath);
76
+ }
77
+ renameSync(logPath, backupPath);
78
+ }
79
+ }
80
+ catch {
81
+ // Ignore rotation errors
82
+ }
83
+ }
84
+ function log(message) {
85
+ try {
86
+ ensureStateDir();
87
+ rotateLogIfNeeded(LOG_FILE_PATH);
88
+ const timestamp = new Date().toISOString();
89
+ const logLine = `[${timestamp}] ${message}\n`;
90
+ appendFileSync(LOG_FILE_PATH, logLine, { mode: SECURE_FILE_MODE });
91
+ }
92
+ catch {
93
+ // Ignore log write errors
94
+ }
95
+ }
96
+ function readDaemonState() {
97
+ try {
98
+ if (!existsSync(STATE_FILE_PATH))
99
+ return null;
100
+ const content = readFileSync(STATE_FILE_PATH, 'utf-8');
101
+ return JSON.parse(content);
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ function writeDaemonState(state) {
108
+ writeSecureFile(STATE_FILE_PATH, JSON.stringify(state, null, 2));
109
+ }
110
+ function readDaemonConfig() {
111
+ try {
112
+ const configPath = join(DEFAULT_STATE_DIR, 'reply-listener-config.json');
113
+ if (!existsSync(configPath))
114
+ return null;
115
+ const content = readFileSync(configPath, 'utf-8');
116
+ return JSON.parse(content);
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ }
122
+ function writeDaemonConfig(config) {
123
+ const configPath = join(DEFAULT_STATE_DIR, 'reply-listener-config.json');
124
+ writeSecureFile(configPath, JSON.stringify(config, null, 2));
125
+ }
126
+ function readPidFile() {
127
+ try {
128
+ if (!existsSync(PID_FILE_PATH))
129
+ return null;
130
+ const content = readFileSync(PID_FILE_PATH, 'utf-8');
131
+ return parseInt(content.trim(), 10);
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }
137
+ function writePidFile(pid) {
138
+ writeSecureFile(PID_FILE_PATH, String(pid));
139
+ }
140
+ function removePidFile() {
141
+ if (existsSync(PID_FILE_PATH)) {
142
+ unlinkSync(PID_FILE_PATH);
143
+ }
144
+ }
145
+ function isProcessRunning(pid) {
146
+ try {
147
+ process.kill(pid, 0);
148
+ return true;
149
+ }
150
+ catch {
151
+ return false;
152
+ }
153
+ }
154
+ export function isDaemonRunning() {
155
+ const pid = readPidFile();
156
+ if (pid === null)
157
+ return false;
158
+ if (!isProcessRunning(pid)) {
159
+ removePidFile();
160
+ return false;
161
+ }
162
+ return true;
163
+ }
164
+ // ============================================================================
165
+ // Input Sanitization
166
+ // ============================================================================
167
+ export function sanitizeReplyInput(text) {
168
+ return text
169
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
170
+ .replace(/\r?\n/g, ' ')
171
+ .replace(/\\/g, '\\\\')
172
+ .replace(/`/g, '\\`')
173
+ .replace(/\$\(/g, '\\$(')
174
+ .replace(/\$\{/g, '\\${')
175
+ .trim();
176
+ }
177
+ // ============================================================================
178
+ // Rate Limiting
179
+ // ============================================================================
180
+ class RateLimiter {
181
+ maxPerMinute;
182
+ timestamps = [];
183
+ windowMs = 60 * 1000;
184
+ constructor(maxPerMinute) {
185
+ this.maxPerMinute = maxPerMinute;
186
+ }
187
+ canProceed() {
188
+ const now = Date.now();
189
+ this.timestamps = this.timestamps.filter(t => now - t < this.windowMs);
190
+ if (this.timestamps.length >= this.maxPerMinute)
191
+ return false;
192
+ this.timestamps.push(now);
193
+ return true;
194
+ }
195
+ reset() {
196
+ this.timestamps = [];
197
+ }
198
+ }
199
+ // ============================================================================
200
+ // Injection
201
+ // ============================================================================
202
+ function injectReply(paneId, text, platform, config) {
203
+ const content = capturePaneContent(paneId, 15);
204
+ const analysis = analyzePaneContent(content);
205
+ if (analysis.confidence < 0.4) {
206
+ log(`WARN: Pane ${paneId} does not appear to be running Codex CLI (confidence: ${analysis.confidence}). Skipping injection, removing stale mapping.`);
207
+ removeMessagesByPane(paneId);
208
+ return false;
209
+ }
210
+ const prefix = config.includePrefix ? `[reply:${platform}] ` : '';
211
+ const sanitized = sanitizeReplyInput(prefix + text);
212
+ const truncated = sanitized.slice(0, config.maxMessageLength);
213
+ const success = sendToPane(paneId, truncated, true);
214
+ if (success) {
215
+ log(`Injected reply from ${platform} into pane ${paneId}: "${truncated.slice(0, 50)}${truncated.length > 50 ? '...' : ''}"`);
216
+ }
217
+ else {
218
+ log(`ERROR: Failed to inject reply into pane ${paneId}`);
219
+ }
220
+ return success;
221
+ }
222
+ // ============================================================================
223
+ // Discord Polling
224
+ // ============================================================================
225
+ let discordBackoffUntil = 0;
226
+ async function pollDiscord(config, state, rateLimiter) {
227
+ if (!config.discordBotToken || !config.discordChannelId)
228
+ return;
229
+ if (config.authorizedDiscordUserIds.length === 0)
230
+ return;
231
+ if (Date.now() < discordBackoffUntil)
232
+ return;
233
+ try {
234
+ const after = state.discordLastMessageId ? `?after=${state.discordLastMessageId}&limit=10` : '?limit=10';
235
+ const url = `https://discord.com/api/v10/channels/${config.discordChannelId}/messages${after}`;
236
+ const response = await fetch(url, {
237
+ method: 'GET',
238
+ headers: { 'Authorization': `Bot ${config.discordBotToken}` },
239
+ signal: AbortSignal.timeout(10000),
240
+ });
241
+ const remaining = response.headers.get('x-ratelimit-remaining');
242
+ const reset = response.headers.get('x-ratelimit-reset');
243
+ if (remaining !== null && parseInt(remaining, 10) < 2) {
244
+ const resetTime = reset ? parseFloat(reset) * 1000 : Date.now() + 10_000;
245
+ discordBackoffUntil = resetTime;
246
+ log(`WARN: Discord rate limit low (remaining: ${remaining}), backing off until ${new Date(resetTime).toISOString()}`);
247
+ }
248
+ if (!response.ok) {
249
+ log(`Discord API error: HTTP ${response.status}`);
250
+ return;
251
+ }
252
+ const messages = await response.json();
253
+ if (!Array.isArray(messages) || messages.length === 0)
254
+ return;
255
+ const sorted = [...messages].reverse();
256
+ for (const msg of sorted) {
257
+ if (!msg.message_reference?.message_id) {
258
+ state.discordLastMessageId = msg.id;
259
+ writeDaemonState(state);
260
+ continue;
261
+ }
262
+ if (!config.authorizedDiscordUserIds.includes(msg.author.id)) {
263
+ state.discordLastMessageId = msg.id;
264
+ writeDaemonState(state);
265
+ continue;
266
+ }
267
+ const mapping = lookupByMessageId('discord-bot', msg.message_reference.message_id);
268
+ if (!mapping) {
269
+ state.discordLastMessageId = msg.id;
270
+ writeDaemonState(state);
271
+ continue;
272
+ }
273
+ if (!rateLimiter.canProceed()) {
274
+ log(`WARN: Rate limit exceeded, dropping Discord message ${msg.id}`);
275
+ state.discordLastMessageId = msg.id;
276
+ writeDaemonState(state);
277
+ state.errors++;
278
+ continue;
279
+ }
280
+ state.discordLastMessageId = msg.id;
281
+ writeDaemonState(state);
282
+ const success = injectReply(mapping.tmuxPaneId, msg.content, 'discord', config);
283
+ if (success) {
284
+ state.messagesInjected++;
285
+ // Add ✅ reaction to the user's reply
286
+ try {
287
+ await fetch(`https://discord.com/api/v10/channels/${config.discordChannelId}/messages/${msg.id}/reactions/%E2%9C%85/@me`, {
288
+ method: 'PUT',
289
+ headers: { 'Authorization': `Bot ${config.discordBotToken}` },
290
+ signal: AbortSignal.timeout(5000),
291
+ });
292
+ }
293
+ catch (e) {
294
+ log(`WARN: Failed to add confirmation reaction: ${e}`);
295
+ }
296
+ // Send injection notification as a reply to the user's message (non-critical)
297
+ try {
298
+ const feedbackAllowedMentions = config.discordMention
299
+ ? parseMentionAllowedMentions(config.discordMention)
300
+ : { parse: [] };
301
+ await fetch(`https://discord.com/api/v10/channels/${config.discordChannelId}/messages`, {
302
+ method: 'POST',
303
+ headers: {
304
+ 'Authorization': `Bot ${config.discordBotToken}`,
305
+ 'Content-Type': 'application/json',
306
+ },
307
+ body: JSON.stringify({
308
+ content: 'Injected into Codex CLI session.',
309
+ message_reference: { message_id: msg.id },
310
+ allowed_mentions: feedbackAllowedMentions,
311
+ }),
312
+ signal: AbortSignal.timeout(5000),
313
+ });
314
+ }
315
+ catch (e) {
316
+ log(`WARN: Failed to send injection channel notification: ${e}`);
317
+ }
318
+ }
319
+ else {
320
+ state.errors++;
321
+ }
322
+ }
323
+ }
324
+ catch (error) {
325
+ state.errors++;
326
+ state.lastError = error instanceof Error ? error.message : String(error);
327
+ log(`Discord polling error: ${state.lastError}`);
328
+ }
329
+ }
330
+ // ============================================================================
331
+ // Telegram Polling
332
+ // ============================================================================
333
+ async function pollTelegram(config, state, rateLimiter) {
334
+ if (!config.telegramBotToken || !config.telegramChatId)
335
+ return;
336
+ try {
337
+ const offset = state.telegramLastUpdateId ? state.telegramLastUpdateId + 1 : 0;
338
+ const path = `/bot${config.telegramBotToken}/getUpdates?offset=${offset}&timeout=0`;
339
+ const updates = await new Promise((resolve, reject) => {
340
+ const req = httpsRequest({
341
+ hostname: 'api.telegram.org',
342
+ path,
343
+ method: 'GET',
344
+ family: 4,
345
+ timeout: 10000,
346
+ }, (res) => {
347
+ const chunks = [];
348
+ res.on('data', (chunk) => chunks.push(chunk));
349
+ res.on('end', () => {
350
+ try {
351
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
352
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
353
+ resolve(body.result || []);
354
+ }
355
+ else {
356
+ reject(new Error(`HTTP ${res.statusCode}`));
357
+ }
358
+ }
359
+ catch (e) {
360
+ reject(e);
361
+ }
362
+ });
363
+ });
364
+ req.on('error', reject);
365
+ req.on('timeout', () => {
366
+ req.destroy();
367
+ reject(new Error('Request timeout'));
368
+ });
369
+ req.end();
370
+ });
371
+ for (const update of updates) {
372
+ const msg = update.message;
373
+ if (!msg) {
374
+ state.telegramLastUpdateId = update.update_id;
375
+ writeDaemonState(state);
376
+ continue;
377
+ }
378
+ if (!msg.reply_to_message?.message_id) {
379
+ state.telegramLastUpdateId = update.update_id;
380
+ writeDaemonState(state);
381
+ continue;
382
+ }
383
+ if (String(msg.chat.id) !== config.telegramChatId) {
384
+ state.telegramLastUpdateId = update.update_id;
385
+ writeDaemonState(state);
386
+ continue;
387
+ }
388
+ const mapping = lookupByMessageId('telegram', String(msg.reply_to_message.message_id));
389
+ if (!mapping) {
390
+ state.telegramLastUpdateId = update.update_id;
391
+ writeDaemonState(state);
392
+ continue;
393
+ }
394
+ const text = msg.text || '';
395
+ if (!text) {
396
+ state.telegramLastUpdateId = update.update_id;
397
+ writeDaemonState(state);
398
+ continue;
399
+ }
400
+ if (!rateLimiter.canProceed()) {
401
+ log(`WARN: Rate limit exceeded, dropping Telegram message ${msg.message_id}`);
402
+ state.telegramLastUpdateId = update.update_id;
403
+ writeDaemonState(state);
404
+ state.errors++;
405
+ continue;
406
+ }
407
+ state.telegramLastUpdateId = update.update_id;
408
+ writeDaemonState(state);
409
+ const success = injectReply(mapping.tmuxPaneId, text, 'telegram', config);
410
+ if (success) {
411
+ state.messagesInjected++;
412
+ try {
413
+ const replyBody = JSON.stringify({
414
+ chat_id: config.telegramChatId,
415
+ text: 'Injected into Codex CLI session.',
416
+ reply_to_message_id: msg.message_id,
417
+ });
418
+ await new Promise((resolve) => {
419
+ const replyReq = httpsRequest({
420
+ hostname: 'api.telegram.org',
421
+ path: `/bot${config.telegramBotToken}/sendMessage`,
422
+ method: 'POST',
423
+ family: 4,
424
+ headers: {
425
+ 'Content-Type': 'application/json',
426
+ 'Content-Length': Buffer.byteLength(replyBody),
427
+ },
428
+ timeout: 5000,
429
+ }, (res) => {
430
+ res.resume();
431
+ resolve();
432
+ });
433
+ replyReq.on('error', () => resolve());
434
+ replyReq.on('timeout', () => {
435
+ replyReq.destroy();
436
+ resolve();
437
+ });
438
+ replyReq.write(replyBody);
439
+ replyReq.end();
440
+ });
441
+ }
442
+ catch (e) {
443
+ log(`WARN: Failed to send confirmation reply: ${e}`);
444
+ }
445
+ }
446
+ else {
447
+ state.errors++;
448
+ }
449
+ }
450
+ }
451
+ catch (error) {
452
+ state.errors++;
453
+ state.lastError = error instanceof Error ? error.message : String(error);
454
+ log(`Telegram polling error: ${state.lastError}`);
455
+ }
456
+ }
457
+ // ============================================================================
458
+ // Main Daemon Loop
459
+ // ============================================================================
460
+ const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
461
+ async function pollLoop() {
462
+ log('Reply listener daemon starting poll loop');
463
+ const config = readDaemonConfig();
464
+ if (!config) {
465
+ log('ERROR: No daemon config found, exiting');
466
+ process.exit(1);
467
+ }
468
+ const state = readDaemonState() || {
469
+ isRunning: true,
470
+ pid: process.pid,
471
+ startedAt: new Date().toISOString(),
472
+ lastPollAt: null,
473
+ telegramLastUpdateId: null,
474
+ discordLastMessageId: null,
475
+ messagesInjected: 0,
476
+ errors: 0,
477
+ };
478
+ state.isRunning = true;
479
+ state.pid = process.pid;
480
+ const rateLimiter = new RateLimiter(config.rateLimitPerMinute);
481
+ let lastPruneAt = Date.now();
482
+ const shutdown = () => {
483
+ log('Shutdown signal received');
484
+ state.isRunning = false;
485
+ writeDaemonState(state);
486
+ removePidFile();
487
+ process.exit(0);
488
+ };
489
+ process.on('SIGTERM', shutdown);
490
+ process.on('SIGINT', shutdown);
491
+ try {
492
+ pruneStale();
493
+ log('Pruned stale registry entries');
494
+ }
495
+ catch (e) {
496
+ log(`WARN: Failed to prune stale entries: ${e}`);
497
+ }
498
+ while (state.isRunning) {
499
+ try {
500
+ state.lastPollAt = new Date().toISOString();
501
+ await pollDiscord(config, state, rateLimiter);
502
+ await pollTelegram(config, state, rateLimiter);
503
+ if (Date.now() - lastPruneAt > PRUNE_INTERVAL_MS) {
504
+ try {
505
+ pruneStale();
506
+ lastPruneAt = Date.now();
507
+ log('Pruned stale registry entries');
508
+ }
509
+ catch (e) {
510
+ log(`WARN: Prune failed: ${e instanceof Error ? e.message : String(e)}`);
511
+ }
512
+ }
513
+ writeDaemonState(state);
514
+ await new Promise((resolve) => setTimeout(resolve, config.pollIntervalMs));
515
+ }
516
+ catch (error) {
517
+ state.errors++;
518
+ state.lastError = error instanceof Error ? error.message : String(error);
519
+ log(`Poll error: ${state.lastError}`);
520
+ writeDaemonState(state);
521
+ await new Promise((resolve) => setTimeout(resolve, config.pollIntervalMs * 2));
522
+ }
523
+ }
524
+ log('Poll loop ended');
525
+ }
526
+ // ============================================================================
527
+ // Daemon Control
528
+ // ============================================================================
529
+ export function startReplyListener(config) {
530
+ if (isDaemonRunning()) {
531
+ const state = readDaemonState();
532
+ return {
533
+ success: true,
534
+ message: 'Reply listener daemon is already running',
535
+ state: state ?? undefined,
536
+ };
537
+ }
538
+ if (!isTmuxAvailable()) {
539
+ return {
540
+ success: false,
541
+ message: 'tmux not available - reply injection requires tmux',
542
+ };
543
+ }
544
+ writeDaemonConfig(config);
545
+ ensureStateDir();
546
+ const modulePath = __filename.replace(/\.ts$/, '.js');
547
+ const daemonScript = `
548
+ import('${modulePath}').then(({ pollLoop }) => {
549
+ return pollLoop();
550
+ }).catch((err) => { console.error(err); process.exit(1); });
551
+ `;
552
+ try {
553
+ const child = spawn('node', ['-e', daemonScript], {
554
+ detached: true,
555
+ stdio: 'ignore',
556
+ cwd: process.cwd(),
557
+ env: createMinimalDaemonEnv(),
558
+ });
559
+ child.unref();
560
+ const pid = child.pid;
561
+ if (pid) {
562
+ writePidFile(pid);
563
+ const state = {
564
+ isRunning: true,
565
+ pid,
566
+ startedAt: new Date().toISOString(),
567
+ lastPollAt: null,
568
+ telegramLastUpdateId: null,
569
+ discordLastMessageId: null,
570
+ messagesInjected: 0,
571
+ errors: 0,
572
+ };
573
+ writeDaemonState(state);
574
+ log(`Reply listener daemon started with PID ${pid}`);
575
+ return {
576
+ success: true,
577
+ message: `Reply listener daemon started with PID ${pid}`,
578
+ state,
579
+ };
580
+ }
581
+ return {
582
+ success: false,
583
+ message: 'Failed to start daemon process',
584
+ };
585
+ }
586
+ catch (error) {
587
+ return {
588
+ success: false,
589
+ message: 'Failed to start daemon',
590
+ error: error instanceof Error ? error.message : String(error),
591
+ };
592
+ }
593
+ }
594
+ export function stopReplyListener() {
595
+ const pid = readPidFile();
596
+ if (pid === null) {
597
+ return {
598
+ success: true,
599
+ message: 'Reply listener daemon is not running',
600
+ };
601
+ }
602
+ if (!isProcessRunning(pid)) {
603
+ removePidFile();
604
+ return {
605
+ success: true,
606
+ message: 'Reply listener daemon was not running (cleaned up stale PID file)',
607
+ };
608
+ }
609
+ try {
610
+ process.kill(pid, 'SIGTERM');
611
+ removePidFile();
612
+ const state = readDaemonState();
613
+ if (state) {
614
+ state.isRunning = false;
615
+ state.pid = null;
616
+ writeDaemonState(state);
617
+ }
618
+ log(`Reply listener daemon stopped (PID ${pid})`);
619
+ return {
620
+ success: true,
621
+ message: `Reply listener daemon stopped (PID ${pid})`,
622
+ state: state ?? undefined,
623
+ };
624
+ }
625
+ catch (error) {
626
+ return {
627
+ success: false,
628
+ message: 'Failed to stop daemon',
629
+ error: error instanceof Error ? error.message : String(error),
630
+ };
631
+ }
632
+ }
633
+ export function getReplyListenerStatus() {
634
+ const state = readDaemonState();
635
+ const running = isDaemonRunning();
636
+ if (!running && !state) {
637
+ return {
638
+ success: true,
639
+ message: 'Reply listener daemon has never been started',
640
+ };
641
+ }
642
+ if (!running && state) {
643
+ return {
644
+ success: true,
645
+ message: 'Reply listener daemon is not running',
646
+ state: { ...state, isRunning: false, pid: null },
647
+ };
648
+ }
649
+ return {
650
+ success: true,
651
+ message: 'Reply listener daemon is running',
652
+ state: state ?? undefined,
653
+ };
654
+ }
655
+ export { pollLoop };
656
+ //# sourceMappingURL=reply-listener.js.map