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.
- package/README.md +25 -0
- package/dist/cli/__tests__/doctor-team.test.js +58 -0
- package/dist/cli/__tests__/doctor-team.test.js.map +1 -1
- package/dist/cli/__tests__/index.test.js +9 -3
- package/dist/cli/__tests__/index.test.js.map +1 -1
- package/dist/cli/__tests__/lifecycle-notifications.test.d.ts +2 -0
- package/dist/cli/__tests__/lifecycle-notifications.test.d.ts.map +1 -0
- package/dist/cli/__tests__/lifecycle-notifications.test.js +48 -0
- package/dist/cli/__tests__/lifecycle-notifications.test.js.map +1 -0
- package/dist/cli/doctor.js +28 -0
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/hooks.d.ts +4 -0
- package/dist/cli/hooks.d.ts.map +1 -0
- package/dist/cli/hooks.js +201 -0
- package/dist/cli/hooks.js.map +1 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +181 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/config/__tests__/models.test.d.ts +2 -0
- package/dist/config/__tests__/models.test.d.ts.map +1 -0
- package/dist/config/__tests__/models.test.js +69 -0
- package/dist/config/__tests__/models.test.js.map +1 -0
- package/dist/config/models.d.ts +24 -0
- package/dist/config/models.d.ts.map +1 -0
- package/dist/config/models.js +53 -0
- package/dist/config/models.js.map +1 -0
- package/dist/hooks/__tests__/notify-hook-linked-sync.test.js +6 -0
- package/dist/hooks/__tests__/notify-hook-linked-sync.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-session-scope.test.js +6 -0
- package/dist/hooks/__tests__/notify-hook-session-scope.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +224 -36
- package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js +4 -0
- package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js.map +1 -1
- package/dist/hooks/__tests__/tmux-hook-engine.test.js +1 -1
- package/dist/hooks/__tests__/tmux-hook-engine.test.js.map +1 -1
- package/dist/hooks/extensibility/__tests__/example-hook-plugins.test.d.ts +2 -0
- package/dist/hooks/extensibility/__tests__/example-hook-plugins.test.d.ts.map +1 -0
- package/dist/hooks/extensibility/__tests__/example-hook-plugins.test.js +153 -0
- package/dist/hooks/extensibility/__tests__/example-hook-plugins.test.js.map +1 -0
- package/dist/hooks/extensibility/dispatcher.d.ts +4 -0
- package/dist/hooks/extensibility/dispatcher.d.ts.map +1 -0
- package/dist/hooks/extensibility/dispatcher.js +223 -0
- package/dist/hooks/extensibility/dispatcher.js.map +1 -0
- package/dist/hooks/extensibility/events.d.ts +18 -0
- package/dist/hooks/extensibility/events.d.ts.map +1 -0
- package/dist/hooks/extensibility/events.js +53 -0
- package/dist/hooks/extensibility/events.js.map +1 -0
- package/dist/hooks/extensibility/index.d.ts +6 -0
- package/dist/hooks/extensibility/index.d.ts.map +1 -0
- package/dist/hooks/extensibility/index.js +6 -0
- package/dist/hooks/extensibility/index.js.map +1 -0
- package/dist/hooks/extensibility/loader.d.ts +14 -0
- package/dist/hooks/extensibility/loader.d.ts.map +1 -0
- package/dist/hooks/extensibility/loader.js +102 -0
- package/dist/hooks/extensibility/loader.js.map +1 -0
- package/dist/hooks/extensibility/logging.d.ts +4 -0
- package/dist/hooks/extensibility/logging.d.ts.map +1 -0
- package/dist/hooks/extensibility/logging.js +16 -0
- package/dist/hooks/extensibility/logging.js.map +1 -0
- package/dist/hooks/extensibility/plugin-runner.d.ts +2 -0
- package/dist/hooks/extensibility/plugin-runner.d.ts.map +1 -0
- package/dist/hooks/extensibility/plugin-runner.js +69 -0
- package/dist/hooks/extensibility/plugin-runner.js.map +1 -0
- package/dist/hooks/extensibility/runtime.d.ts +3 -0
- package/dist/hooks/extensibility/runtime.d.ts.map +1 -0
- package/dist/hooks/extensibility/runtime.js +29 -0
- package/dist/hooks/extensibility/runtime.js.map +1 -0
- package/dist/hooks/extensibility/sdk.d.ts +11 -0
- package/dist/hooks/extensibility/sdk.d.ts.map +1 -0
- package/dist/hooks/extensibility/sdk.js +240 -0
- package/dist/hooks/extensibility/sdk.js.map +1 -0
- package/dist/hooks/extensibility/types.d.ts +122 -0
- package/dist/hooks/extensibility/types.d.ts.map +1 -0
- package/dist/hooks/extensibility/types.js +2 -0
- package/dist/hooks/extensibility/types.js.map +1 -0
- package/dist/mcp/__tests__/state-paths.test.js +21 -1
- package/dist/mcp/__tests__/state-paths.test.js.map +1 -1
- package/dist/mcp/__tests__/state-server-team-tools.test.js +53 -1
- package/dist/mcp/__tests__/state-server-team-tools.test.js.map +1 -1
- package/dist/mcp/state-paths.d.ts +1 -0
- package/dist/mcp/state-paths.d.ts.map +1 -1
- package/dist/mcp/state-paths.js +34 -1
- package/dist/mcp/state-paths.js.map +1 -1
- package/dist/mcp/state-server.d.ts.map +1 -1
- package/dist/mcp/state-server.js +46 -11
- package/dist/mcp/state-server.js.map +1 -1
- package/dist/notifications/__tests__/config.test.d.ts +2 -0
- package/dist/notifications/__tests__/config.test.d.ts.map +1 -0
- package/dist/notifications/__tests__/config.test.js +186 -0
- package/dist/notifications/__tests__/config.test.js.map +1 -0
- package/dist/notifications/__tests__/dispatcher.test.d.ts +2 -0
- package/dist/notifications/__tests__/dispatcher.test.d.ts.map +1 -0
- package/dist/notifications/__tests__/dispatcher.test.js +202 -0
- package/dist/notifications/__tests__/dispatcher.test.js.map +1 -0
- package/dist/notifications/__tests__/formatter.test.d.ts +2 -0
- package/dist/notifications/__tests__/formatter.test.d.ts.map +1 -0
- package/dist/notifications/__tests__/formatter.test.js +103 -0
- package/dist/notifications/__tests__/formatter.test.js.map +1 -0
- package/dist/notifications/__tests__/notifier.test.d.ts +2 -0
- package/dist/notifications/__tests__/notifier.test.d.ts.map +1 -0
- package/dist/notifications/__tests__/notifier.test.js +104 -0
- package/dist/notifications/__tests__/notifier.test.js.map +1 -0
- package/dist/notifications/__tests__/profiles.test.d.ts +2 -0
- package/dist/notifications/__tests__/profiles.test.d.ts.map +1 -0
- package/dist/notifications/__tests__/profiles.test.js +404 -0
- package/dist/notifications/__tests__/profiles.test.js.map +1 -0
- package/dist/notifications/__tests__/reply-listener.test.d.ts +2 -0
- package/dist/notifications/__tests__/reply-listener.test.d.ts.map +1 -0
- package/dist/notifications/__tests__/reply-listener.test.js +58 -0
- package/dist/notifications/__tests__/reply-listener.test.js.map +1 -0
- package/dist/notifications/__tests__/session-registry.test.d.ts +2 -0
- package/dist/notifications/__tests__/session-registry.test.d.ts.map +1 -0
- package/dist/notifications/__tests__/session-registry.test.js +147 -0
- package/dist/notifications/__tests__/session-registry.test.js.map +1 -0
- package/dist/notifications/__tests__/tmux-detector.test.d.ts +2 -0
- package/dist/notifications/__tests__/tmux-detector.test.d.ts.map +1 -0
- package/dist/notifications/__tests__/tmux-detector.test.js +77 -0
- package/dist/notifications/__tests__/tmux-detector.test.js.map +1 -0
- package/dist/notifications/__tests__/tmux.test.d.ts +2 -0
- package/dist/notifications/__tests__/tmux.test.d.ts.map +1 -0
- package/dist/notifications/__tests__/tmux.test.js +90 -0
- package/dist/notifications/__tests__/tmux.test.js.map +1 -0
- package/dist/notifications/config.d.ts +44 -0
- package/dist/notifications/config.d.ts.map +1 -0
- package/dist/notifications/config.js +407 -0
- package/dist/notifications/config.js.map +1 -0
- package/dist/notifications/dispatcher.d.ts +15 -0
- package/dist/notifications/dispatcher.d.ts.map +1 -0
- package/dist/notifications/dispatcher.js +410 -0
- package/dist/notifications/dispatcher.js.map +1 -0
- package/dist/notifications/formatter.d.ts +14 -0
- package/dist/notifications/formatter.d.ts.map +1 -0
- package/dist/notifications/formatter.js +134 -0
- package/dist/notifications/formatter.js.map +1 -0
- package/dist/notifications/index.d.ts +32 -0
- package/dist/notifications/index.d.ts.map +1 -0
- package/dist/notifications/index.js +93 -0
- package/dist/notifications/index.js.map +1 -0
- package/dist/notifications/reply-listener.d.ts +47 -0
- package/dist/notifications/reply-listener.d.ts.map +1 -0
- package/dist/notifications/reply-listener.js +656 -0
- package/dist/notifications/reply-listener.js.map +1 -0
- package/dist/notifications/session-registry.d.ts +26 -0
- package/dist/notifications/session-registry.d.ts.map +1 -0
- package/dist/notifications/session-registry.js +275 -0
- package/dist/notifications/session-registry.js.map +1 -0
- package/dist/notifications/tmux-detector.d.ts +17 -0
- package/dist/notifications/tmux-detector.d.ts.map +1 -0
- package/dist/notifications/tmux-detector.js +82 -0
- package/dist/notifications/tmux-detector.js.map +1 -0
- package/dist/notifications/tmux.d.ts +28 -0
- package/dist/notifications/tmux.d.ts.map +1 -0
- package/dist/notifications/tmux.js +210 -0
- package/dist/notifications/tmux.js.map +1 -0
- package/dist/notifications/types.d.ts +181 -0
- package/dist/notifications/types.d.ts.map +1 -0
- package/dist/notifications/types.js +9 -0
- package/dist/notifications/types.js.map +1 -0
- package/dist/team/__tests__/runtime.test.js +54 -2
- package/dist/team/__tests__/runtime.test.js.map +1 -1
- package/dist/team/__tests__/state.test.js +30 -0
- package/dist/team/__tests__/state.test.js.map +1 -1
- package/dist/team/__tests__/worker-bootstrap.test.js +2 -0
- package/dist/team/__tests__/worker-bootstrap.test.js.map +1 -1
- package/dist/team/runtime.d.ts +2 -2
- package/dist/team/runtime.d.ts.map +1 -1
- package/dist/team/runtime.js +19 -12
- package/dist/team/runtime.js.map +1 -1
- package/dist/team/state.d.ts +1 -1
- package/dist/team/state.d.ts.map +1 -1
- package/dist/team/state.js +5 -0
- package/dist/team/state.js.map +1 -1
- package/dist/team/tmux-session.d.ts.map +1 -1
- package/dist/team/tmux-session.js +59 -15
- package/dist/team/tmux-session.js.map +1 -1
- package/dist/team/worker-bootstrap.d.ts.map +1 -1
- package/dist/team/worker-bootstrap.js +4 -0
- package/dist/team/worker-bootstrap.js.map +1 -1
- package/package.json +1 -1
- package/scripts/hook-derived-watcher.js +335 -0
- package/scripts/notify-hook.js +168 -7
- 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
|