codeclaw 0.1.0 → 0.2.2
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 +165 -170
- package/dist/bot-telegram.js +652 -0
- package/dist/bot.js +288 -0
- package/dist/channel-base.js +30 -0
- package/dist/channel-telegram.js +474 -0
- package/dist/cli.js +209 -0
- package/dist/code-agent.js +472 -0
- package/package.json +8 -4
- package/bin/codeclaw.js +0 -3
- package/src/channel-telegram.js +0 -1070
- package/src/codeclaw.js +0 -781
package/src/codeclaw.js
DELETED
|
@@ -1,781 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* codeclaw — zero config, bridge AI coding agents to any IM.
|
|
3
|
-
*
|
|
4
|
-
* Core orchestrator: config, state management, engine execution, CLI entry point.
|
|
5
|
-
* Channel-specific interaction is in separate files (channel-telegram.js, etc.).
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { spawn } from 'node:child_process';
|
|
9
|
-
import fs from 'node:fs';
|
|
10
|
-
import path from 'node:path';
|
|
11
|
-
import { createInterface } from 'node:readline';
|
|
12
|
-
import { execSync } from 'node:child_process';
|
|
13
|
-
import { fileURLToPath } from 'node:url';
|
|
14
|
-
|
|
15
|
-
export const VERSION = '0.1.0';
|
|
16
|
-
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
// Helpers
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
|
|
21
|
-
export function envBool(name, defaultVal) {
|
|
22
|
-
const raw = process.env[name];
|
|
23
|
-
if (raw === undefined || raw === null) return defaultVal;
|
|
24
|
-
return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase());
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function envInt(name, defaultVal) {
|
|
28
|
-
const raw = process.env[name];
|
|
29
|
-
if (raw === undefined || raw === null || raw.trim() === '') return defaultVal;
|
|
30
|
-
const n = parseInt(raw, 10);
|
|
31
|
-
return Number.isNaN(n) ? defaultVal : n;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function parseAllowedChatIds(raw) {
|
|
35
|
-
const ids = new Set();
|
|
36
|
-
for (const token of raw.split(',')) {
|
|
37
|
-
const t = token.trim();
|
|
38
|
-
if (!t) continue;
|
|
39
|
-
const n = parseInt(t, 10);
|
|
40
|
-
if (!Number.isNaN(n)) ids.add(n);
|
|
41
|
-
}
|
|
42
|
-
return ids;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function normalizeReasoningEffort(raw) {
|
|
46
|
-
const value = raw.trim().toLowerCase();
|
|
47
|
-
const allowed = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']);
|
|
48
|
-
if (!allowed.has(value)) {
|
|
49
|
-
throw new Error(
|
|
50
|
-
'Invalid CODEX_REASONING_EFFORT. Use one of: none, minimal, low, medium, high, xhigh'
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
return value;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function normalizeSessionName(raw) {
|
|
57
|
-
const name = raw.trim().toLowerCase();
|
|
58
|
-
if (!name) return 'default';
|
|
59
|
-
if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(name)) {
|
|
60
|
-
throw new Error(
|
|
61
|
-
'Invalid session name. Use 1-32 chars: a-z, 0-9, _ or -, start with letter/number.'
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
return name;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export const VALID_ENGINES = new Set(['codex', 'claude']);
|
|
68
|
-
|
|
69
|
-
export function normalizeEngine(raw) {
|
|
70
|
-
const value = raw.trim().toLowerCase();
|
|
71
|
-
if (!VALID_ENGINES.has(value)) {
|
|
72
|
-
throw new Error(`Invalid engine. Use one of: ${[...VALID_ENGINES].sort().join(', ')}`);
|
|
73
|
-
}
|
|
74
|
-
return value;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function shellSplit(str) {
|
|
78
|
-
const args = [];
|
|
79
|
-
let current = '';
|
|
80
|
-
let inSingle = false;
|
|
81
|
-
let inDouble = false;
|
|
82
|
-
for (let i = 0; i < str.length; i++) {
|
|
83
|
-
const ch = str[i];
|
|
84
|
-
if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; }
|
|
85
|
-
if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; }
|
|
86
|
-
if (ch === ' ' && !inSingle && !inDouble) {
|
|
87
|
-
if (current) { args.push(current); current = ''; }
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
current += ch;
|
|
91
|
-
}
|
|
92
|
-
if (current) args.push(current);
|
|
93
|
-
return args;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function whichSync(cmd) {
|
|
97
|
-
try {
|
|
98
|
-
return execSync(`which ${cmd} 2>/dev/null`, { encoding: 'utf-8' }).trim() || null;
|
|
99
|
-
} catch {
|
|
100
|
-
return null;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// ---------------------------------------------------------------------------
|
|
105
|
-
// Core
|
|
106
|
-
// ---------------------------------------------------------------------------
|
|
107
|
-
|
|
108
|
-
export class CodeClaw {
|
|
109
|
-
constructor() {
|
|
110
|
-
const token = (process.env.TELEGRAM_BOT_TOKEN || process.env.CODECLAW_TOKEN || '').trim();
|
|
111
|
-
if (!token) {
|
|
112
|
-
throw new Error('Missing token. Use -t TOKEN or set CODECLAW_TOKEN / TELEGRAM_BOT_TOKEN');
|
|
113
|
-
}
|
|
114
|
-
this.token = token;
|
|
115
|
-
|
|
116
|
-
const defaultWorkdir = process.cwd();
|
|
117
|
-
this.workdir = path.resolve((process.env.CODECLAW_WORKDIR || defaultWorkdir).replace(/^~/, process.env.HOME || ''));
|
|
118
|
-
this.stateDir = path.resolve((process.env.CODECLAW_STATE_DIR || '~/.codeclaw').replace(/^~/, process.env.HOME || ''));
|
|
119
|
-
|
|
120
|
-
fs.mkdirSync(this.stateDir, { recursive: true });
|
|
121
|
-
this.stateFile = path.join(this.stateDir, 'state.json');
|
|
122
|
-
this.lockFile = path.join(this.stateDir, 'bridge.lock');
|
|
123
|
-
|
|
124
|
-
this.pollTimeout = envInt('TELEGRAM_POLL_TIMEOUT', 45);
|
|
125
|
-
this.runTimeout = envInt('CODECLAW_TIMEOUT', 300);
|
|
126
|
-
this.requireMention = envBool('TELEGRAM_REQUIRE_MENTION_IN_GROUP', true);
|
|
127
|
-
this.allowedChatIds = parseAllowedChatIds(
|
|
128
|
-
process.env.TELEGRAM_ALLOWED_CHAT_IDS || process.env.CODECLAW_ALLOWED_IDS || ''
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
// Codex settings
|
|
132
|
-
this.codexModel = (process.env.CODEX_MODEL || 'gpt-5.4').trim();
|
|
133
|
-
this.codexReasoningEffort = normalizeReasoningEffort(
|
|
134
|
-
process.env.CODEX_REASONING_EFFORT || 'xhigh'
|
|
135
|
-
);
|
|
136
|
-
this.codexFullAccess = envBool('CODEX_FULL_ACCESS', true);
|
|
137
|
-
this.codexExtraArgs = shellSplit(process.env.CODEX_EXTRA_ARGS || '');
|
|
138
|
-
|
|
139
|
-
// Claude settings
|
|
140
|
-
this.claudeModel = (process.env.CLAUDE_MODEL || 'claude-opus-4-6').trim();
|
|
141
|
-
this.claudePermissionMode = (process.env.CLAUDE_PERMISSION_MODE || 'bypassPermissions').trim();
|
|
142
|
-
this.claudeExtraArgs = shellSplit(process.env.CLAUDE_EXTRA_ARGS || '');
|
|
143
|
-
|
|
144
|
-
// Default engine
|
|
145
|
-
this.defaultEngine = normalizeEngine(process.env.DEFAULT_ENGINE || 'claude');
|
|
146
|
-
|
|
147
|
-
this.botUsername = '';
|
|
148
|
-
this.botId = 0;
|
|
149
|
-
this.running = true;
|
|
150
|
-
this.lockFd = null;
|
|
151
|
-
this._replacedOldProcess = false;
|
|
152
|
-
|
|
153
|
-
this.state = { last_update_id: 0, chats: {} };
|
|
154
|
-
this._resetState();
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// -------------------------------------------------------------------
|
|
158
|
-
// Logging
|
|
159
|
-
// -------------------------------------------------------------------
|
|
160
|
-
|
|
161
|
-
_log(msg, { err = false } = {}) {
|
|
162
|
-
const ts = new Date().toTimeString().slice(0, 8);
|
|
163
|
-
const out = err ? process.stderr : process.stdout;
|
|
164
|
-
out.write(`[codeclaw ${ts}] ${msg}\n`);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// -------------------------------------------------------------------
|
|
168
|
-
// State management
|
|
169
|
-
// -------------------------------------------------------------------
|
|
170
|
-
|
|
171
|
-
_resetState() {
|
|
172
|
-
if (!fs.existsSync(this.stateFile)) return;
|
|
173
|
-
try {
|
|
174
|
-
const parsed = JSON.parse(fs.readFileSync(this.stateFile, 'utf-8'));
|
|
175
|
-
if (parsed && typeof parsed === 'object') {
|
|
176
|
-
this.state.last_update_id = parseInt(parsed.last_update_id, 10) || 0;
|
|
177
|
-
}
|
|
178
|
-
} catch { /* ignore */ }
|
|
179
|
-
this.state.chats = {};
|
|
180
|
-
this._saveState();
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
_ensureChatState(chatId) {
|
|
184
|
-
const key = String(chatId);
|
|
185
|
-
if (!this.state.chats[key]) {
|
|
186
|
-
this.state.chats[key] = {
|
|
187
|
-
active: 'default',
|
|
188
|
-
threads: { default: '' },
|
|
189
|
-
engine: this.defaultEngine,
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
const cs = this.state.chats[key];
|
|
193
|
-
let active = normalizeSessionName(String(cs.active || 'default'));
|
|
194
|
-
cs.active = active;
|
|
195
|
-
if (!cs.engine) cs.engine = this.defaultEngine;
|
|
196
|
-
const raw = cs.threads && typeof cs.threads === 'object' ? cs.threads : {};
|
|
197
|
-
const norm = {};
|
|
198
|
-
for (const [name, tid] of Object.entries(raw)) {
|
|
199
|
-
norm[normalizeSessionName(String(name))] = String(tid).trim();
|
|
200
|
-
}
|
|
201
|
-
if (!norm[active]) norm[active] = '';
|
|
202
|
-
cs.threads = norm;
|
|
203
|
-
return cs;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
_saveState() {
|
|
207
|
-
fs.writeFileSync(this.stateFile, JSON.stringify(this.state, null, 2), 'utf-8');
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// -------------------------------------------------------------------
|
|
211
|
-
// Session helpers
|
|
212
|
-
// -------------------------------------------------------------------
|
|
213
|
-
|
|
214
|
-
_sessionForChat(chatId) {
|
|
215
|
-
const cs = this._ensureChatState(chatId);
|
|
216
|
-
const name = cs.active;
|
|
217
|
-
const tid = (cs.threads[name] || '').trim() || null;
|
|
218
|
-
return [name, tid];
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
_engineForChat(chatId) {
|
|
222
|
-
return this._ensureChatState(chatId).engine || this.defaultEngine;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
_setEngineForChat(chatId, engine) {
|
|
226
|
-
this._ensureChatState(chatId).engine = normalizeEngine(engine);
|
|
227
|
-
this._saveState();
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
_setActiveSession(chatId, sessionName) {
|
|
231
|
-
const cs = this._ensureChatState(chatId);
|
|
232
|
-
const name = normalizeSessionName(sessionName);
|
|
233
|
-
cs.active = name;
|
|
234
|
-
if (!cs.threads[name]) cs.threads[name] = '';
|
|
235
|
-
this._saveState();
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
_setSessionThread(chatId, sessionName, threadId) {
|
|
239
|
-
const cs = this._ensureChatState(chatId);
|
|
240
|
-
cs.threads[normalizeSessionName(sessionName)] = (threadId || '').trim();
|
|
241
|
-
this._saveState();
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
_deleteSession(chatId, sessionName) {
|
|
245
|
-
const cs = this._ensureChatState(chatId);
|
|
246
|
-
const name = normalizeSessionName(sessionName);
|
|
247
|
-
delete cs.threads[name];
|
|
248
|
-
if (Object.keys(cs.threads).length === 0) cs.threads.default = '';
|
|
249
|
-
if (cs.active === name) {
|
|
250
|
-
cs.active = 'default';
|
|
251
|
-
if (!cs.threads.default) cs.threads.default = '';
|
|
252
|
-
}
|
|
253
|
-
this._saveState();
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// -------------------------------------------------------------------
|
|
257
|
-
// Process lock & lifecycle
|
|
258
|
-
// -------------------------------------------------------------------
|
|
259
|
-
|
|
260
|
-
_readPidFile(filePath) {
|
|
261
|
-
try {
|
|
262
|
-
const content = fs.readFileSync(filePath, 'utf-8').trim();
|
|
263
|
-
return content ? parseInt(content, 10) : null;
|
|
264
|
-
} catch {
|
|
265
|
-
return null;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
_killProcess(pid) {
|
|
270
|
-
this._replacedOldProcess = true;
|
|
271
|
-
this._log(`killing existing process (PID ${pid}) ...`);
|
|
272
|
-
try {
|
|
273
|
-
process.kill(pid, 'SIGTERM');
|
|
274
|
-
const start = Date.now();
|
|
275
|
-
while (Date.now() - start < 3000) {
|
|
276
|
-
try { process.kill(pid, 0); } catch { break; }
|
|
277
|
-
const wait = ms => { const end = Date.now() + ms; while (Date.now() < end) { /* busy wait */ } };
|
|
278
|
-
wait(100);
|
|
279
|
-
}
|
|
280
|
-
try {
|
|
281
|
-
process.kill(pid, 0);
|
|
282
|
-
this._log(`force killing PID ${pid}`);
|
|
283
|
-
process.kill(pid, 'SIGKILL');
|
|
284
|
-
} catch { /* already dead */ }
|
|
285
|
-
} catch { /* process doesn't exist */ }
|
|
286
|
-
this._log(`old process (PID ${pid}) terminated`);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
_acquireLock() {
|
|
290
|
-
const oldPid = this._readPidFile(this.lockFile);
|
|
291
|
-
try {
|
|
292
|
-
this.lockFd = fs.openSync(this.lockFile, 'w');
|
|
293
|
-
} catch (e) {
|
|
294
|
-
throw new Error(`Failed to open lock file: ${this.lockFile}`);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// Try to write PID - if another process is running, kill it
|
|
298
|
-
if (oldPid && oldPid !== process.pid) {
|
|
299
|
-
try { process.kill(oldPid, 0); this._killProcess(oldPid); } catch { /* not running */ }
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
fs.writeSync(this.lockFd, String(process.pid));
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
_ensureSingleBot() {
|
|
306
|
-
const pidFile = path.join(this.stateDir, `bot_${this.botId}.pid`);
|
|
307
|
-
const oldPid = this._readPidFile(pidFile);
|
|
308
|
-
if (oldPid && oldPid !== process.pid) {
|
|
309
|
-
try {
|
|
310
|
-
process.kill(oldPid, 0);
|
|
311
|
-
this._log(`same bot @${this.botUsername} running elsewhere (PID ${oldPid})`);
|
|
312
|
-
this._killProcess(oldPid);
|
|
313
|
-
} catch { /* not running */ }
|
|
314
|
-
}
|
|
315
|
-
fs.writeFileSync(pidFile, String(process.pid), 'utf-8');
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
_handleSignal(sig) {
|
|
319
|
-
this.running = false;
|
|
320
|
-
this._log(`signal ${sig}, shutting down...`);
|
|
321
|
-
this._stopKeepAlive();
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// -------------------------------------------------------------------
|
|
325
|
-
// Keep-alive (prevent idle sleep)
|
|
326
|
-
// -------------------------------------------------------------------
|
|
327
|
-
|
|
328
|
-
_startKeepAlive() {
|
|
329
|
-
this._keepAliveProc = null;
|
|
330
|
-
const platform = process.platform;
|
|
331
|
-
|
|
332
|
-
if (platform === 'darwin') {
|
|
333
|
-
const caffeinate = whichSync('caffeinate');
|
|
334
|
-
if (caffeinate) {
|
|
335
|
-
this._keepAliveProc = spawn('caffeinate', ['-dis'], {
|
|
336
|
-
stdio: 'ignore',
|
|
337
|
-
detached: true,
|
|
338
|
-
});
|
|
339
|
-
this._keepAliveProc.unref();
|
|
340
|
-
this._log(`keep-alive: caffeinate started (PID ${this._keepAliveProc.pid})`);
|
|
341
|
-
} else {
|
|
342
|
-
this._log('keep-alive: caffeinate not found, skipping', { err: true });
|
|
343
|
-
}
|
|
344
|
-
} else if (platform === 'linux') {
|
|
345
|
-
const inhibit = whichSync('systemd-inhibit');
|
|
346
|
-
if (inhibit) {
|
|
347
|
-
this._keepAliveProc = spawn('systemd-inhibit', [
|
|
348
|
-
'--what=idle', '--who=codeclaw',
|
|
349
|
-
'--why=AI coding agent running', 'sleep', 'infinity',
|
|
350
|
-
], { stdio: 'ignore', detached: true });
|
|
351
|
-
this._keepAliveProc.unref();
|
|
352
|
-
this._log(`keep-alive: systemd-inhibit started (PID ${this._keepAliveProc.pid})`);
|
|
353
|
-
} else {
|
|
354
|
-
this._log('keep-alive: systemd-inhibit not found, skipping', { err: true });
|
|
355
|
-
}
|
|
356
|
-
} else {
|
|
357
|
-
this._log(`keep-alive: unsupported platform (${platform}), skipping`);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
_stopKeepAlive() {
|
|
362
|
-
if (this._keepAliveProc) {
|
|
363
|
-
try { this._keepAliveProc.kill('SIGTERM'); } catch { /* ignore */ }
|
|
364
|
-
this._keepAliveProc = null;
|
|
365
|
-
this._log('keep-alive: stopped');
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// -------------------------------------------------------------------
|
|
370
|
-
// Engine command builders
|
|
371
|
-
// -------------------------------------------------------------------
|
|
372
|
-
|
|
373
|
-
_buildCodexCmd(threadId) {
|
|
374
|
-
const common = ['--json'];
|
|
375
|
-
if (this.codexModel) common.push('-m', this.codexModel);
|
|
376
|
-
common.push('-c', `model_reasoning_effort="${this.codexReasoningEffort}"`);
|
|
377
|
-
if (this.codexFullAccess) common.push('--dangerously-bypass-approvals-and-sandbox');
|
|
378
|
-
common.push(...this.codexExtraArgs);
|
|
379
|
-
if (threadId) {
|
|
380
|
-
return ['codex', 'exec', 'resume', ...common, threadId, '-'];
|
|
381
|
-
}
|
|
382
|
-
return ['codex', 'exec', ...common, '-'];
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
_buildClaudeCmd(threadId) {
|
|
386
|
-
const cmd = ['claude', '-p', '--verbose', '--output-format', 'stream-json', '--include-partial-messages'];
|
|
387
|
-
if (this.claudeModel) cmd.push('--model', this.claudeModel);
|
|
388
|
-
if (this.claudePermissionMode) cmd.push('--permission-mode', this.claudePermissionMode);
|
|
389
|
-
if (threadId) cmd.push('--resume', threadId);
|
|
390
|
-
cmd.push(...this.claudeExtraArgs);
|
|
391
|
-
return cmd;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// -------------------------------------------------------------------
|
|
395
|
-
// Engine execution
|
|
396
|
-
// -------------------------------------------------------------------
|
|
397
|
-
|
|
398
|
-
spawnEngine(prompt, engine, threadId) {
|
|
399
|
-
const cmd = engine === 'codex'
|
|
400
|
-
? this._buildCodexCmd(threadId)
|
|
401
|
-
: this._buildClaudeCmd(threadId);
|
|
402
|
-
const resume = threadId ? ` resume=${threadId.slice(0, 12)}` : ' new-thread';
|
|
403
|
-
this._log(`spawn ${engine}${resume} prompt=${JSON.stringify(prompt.slice(0, 80))}`);
|
|
404
|
-
this._log(` cmd: ${cmd.join(' ').slice(0, 200)}`);
|
|
405
|
-
|
|
406
|
-
const proc = spawn(cmd[0], cmd.slice(1), {
|
|
407
|
-
cwd: this.workdir,
|
|
408
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
if (proc.stdin) {
|
|
412
|
-
try {
|
|
413
|
-
proc.stdin.write(prompt);
|
|
414
|
-
proc.stdin.end();
|
|
415
|
-
} catch { /* broken pipe */ }
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
return proc;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
parseEvents(proc, engine, threadId, onText) {
|
|
422
|
-
return new Promise((resolve) => {
|
|
423
|
-
const start = Date.now();
|
|
424
|
-
let collectedText = '';
|
|
425
|
-
let discoveredThread = threadId;
|
|
426
|
-
let usageInput = null;
|
|
427
|
-
let usageCached = null;
|
|
428
|
-
let usageOutput = null;
|
|
429
|
-
const messagesBuffer = [];
|
|
430
|
-
let stderr = '';
|
|
431
|
-
const deadline = Date.now() + this.runTimeout * 1000;
|
|
432
|
-
|
|
433
|
-
if (proc.stderr) {
|
|
434
|
-
proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
|
|
438
|
-
|
|
439
|
-
rl.on('line', (rawLine) => {
|
|
440
|
-
if (Date.now() > deadline) {
|
|
441
|
-
proc.kill('SIGKILL');
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
const line = rawLine.trim();
|
|
445
|
-
if (!line || !line.startsWith('{')) return;
|
|
446
|
-
|
|
447
|
-
let event;
|
|
448
|
-
try { event = JSON.parse(line); } catch { return; }
|
|
449
|
-
|
|
450
|
-
const evType = event.type || '';
|
|
451
|
-
|
|
452
|
-
// --- Codex events ---
|
|
453
|
-
if (evType === 'thread.started') {
|
|
454
|
-
discoveredThread = event.thread_id || discoveredThread;
|
|
455
|
-
}
|
|
456
|
-
if (evType === 'item.completed') {
|
|
457
|
-
const item = event.item || {};
|
|
458
|
-
if (item.type === 'agent_message') {
|
|
459
|
-
const text = (item.text || '').trim();
|
|
460
|
-
if (text) {
|
|
461
|
-
messagesBuffer.push(text);
|
|
462
|
-
collectedText = messagesBuffer.join('\n\n');
|
|
463
|
-
onText(collectedText);
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
if (evType === 'turn.completed') {
|
|
468
|
-
const usage = event.usage;
|
|
469
|
-
if (usage && typeof usage === 'object') {
|
|
470
|
-
if (typeof usage.input_tokens === 'number') usageInput = usage.input_tokens;
|
|
471
|
-
if (typeof usage.cached_input_tokens === 'number') usageCached = usage.cached_input_tokens;
|
|
472
|
-
if (typeof usage.output_tokens === 'number') usageOutput = usage.output_tokens;
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// --- Claude Code events ---
|
|
477
|
-
if (evType === 'system') {
|
|
478
|
-
if (event.session_id) discoveredThread = event.session_id;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
if (evType === 'stream_event') {
|
|
482
|
-
const inner = event.event || {};
|
|
483
|
-
const innerType = inner.type || '';
|
|
484
|
-
if (innerType === 'content_block_delta') {
|
|
485
|
-
const delta = inner.delta || {};
|
|
486
|
-
if (delta.type === 'text_delta') {
|
|
487
|
-
collectedText += delta.text || '';
|
|
488
|
-
onText(collectedText);
|
|
489
|
-
}
|
|
490
|
-
} else if (innerType === 'message_delta') {
|
|
491
|
-
const su = inner.usage;
|
|
492
|
-
if (su && typeof su === 'object') {
|
|
493
|
-
if (typeof su.input_tokens === 'number') usageInput = su.input_tokens;
|
|
494
|
-
if (typeof su.cache_read_input_tokens === 'number') usageCached = su.cache_read_input_tokens;
|
|
495
|
-
if (typeof su.output_tokens === 'number') usageOutput = su.output_tokens;
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
if (event.session_id) discoveredThread = event.session_id;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
if (evType === 'assistant') {
|
|
502
|
-
const contents = (event.message || {}).content || [];
|
|
503
|
-
const fullText = contents
|
|
504
|
-
.filter(b => b && b.type === 'text')
|
|
505
|
-
.map(b => b.text || '')
|
|
506
|
-
.join('');
|
|
507
|
-
if (fullText && !collectedText.trim()) {
|
|
508
|
-
collectedText = fullText;
|
|
509
|
-
onText(collectedText);
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
if (evType === 'result') {
|
|
514
|
-
if (event.session_id) discoveredThread = event.session_id;
|
|
515
|
-
const resultText = event.result || '';
|
|
516
|
-
if (resultText && !collectedText.trim()) collectedText = resultText;
|
|
517
|
-
const usageData = event.usage;
|
|
518
|
-
if (usageData && typeof usageData === 'object') {
|
|
519
|
-
if (typeof usageData.input_tokens === 'number') usageInput = usageData.input_tokens;
|
|
520
|
-
const ca = usageData.cache_read_input_tokens ?? usageData.cached_input_tokens;
|
|
521
|
-
if (typeof ca === 'number') usageCached = ca;
|
|
522
|
-
if (typeof usageData.output_tokens === 'number') usageOutput = usageData.output_tokens;
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
});
|
|
526
|
-
|
|
527
|
-
proc.on('close', (code) => {
|
|
528
|
-
const elapsed = (Date.now() - start) / 1000;
|
|
529
|
-
const ok = code === 0;
|
|
530
|
-
|
|
531
|
-
if (!collectedText.trim() && messagesBuffer.length) {
|
|
532
|
-
collectedText = messagesBuffer.join('\n\n');
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
let message;
|
|
536
|
-
if (collectedText.trim()) {
|
|
537
|
-
message = collectedText.trim();
|
|
538
|
-
} else if (ok) {
|
|
539
|
-
message = '(no textual response)';
|
|
540
|
-
} else {
|
|
541
|
-
message = `Failed (exit=${code}).\n\n${stderr.trim() || '(no output)'}`;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
resolve({
|
|
545
|
-
threadId: discoveredThread,
|
|
546
|
-
message,
|
|
547
|
-
ok,
|
|
548
|
-
elapsedS: elapsed,
|
|
549
|
-
inputTokens: usageInput,
|
|
550
|
-
cachedInputTokens: usageCached,
|
|
551
|
-
outputTokens: usageOutput,
|
|
552
|
-
});
|
|
553
|
-
});
|
|
554
|
-
|
|
555
|
-
proc.on('error', (err) => {
|
|
556
|
-
this._log(`stream error: ${err}`, { err: true });
|
|
557
|
-
const elapsed = (Date.now() - start) / 1000;
|
|
558
|
-
resolve({
|
|
559
|
-
threadId: discoveredThread,
|
|
560
|
-
message: `Failed: ${err.message}`,
|
|
561
|
-
ok: false,
|
|
562
|
-
elapsedS: elapsed,
|
|
563
|
-
inputTokens: usageInput,
|
|
564
|
-
cachedInputTokens: usageCached,
|
|
565
|
-
outputTokens: usageOutput,
|
|
566
|
-
});
|
|
567
|
-
});
|
|
568
|
-
});
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
// -------------------------------------------------------------------
|
|
572
|
-
// Preflight & run
|
|
573
|
-
// -------------------------------------------------------------------
|
|
574
|
-
|
|
575
|
-
async preflight() {
|
|
576
|
-
const url = `https://api.telegram.org/bot${this.token}/getMe`;
|
|
577
|
-
this._log('preflight: validating bot token...');
|
|
578
|
-
|
|
579
|
-
const resp = await fetch(url, {
|
|
580
|
-
method: 'POST',
|
|
581
|
-
headers: { 'Content-Type': 'application/json' },
|
|
582
|
-
body: '{}',
|
|
583
|
-
});
|
|
584
|
-
const data = await resp.json();
|
|
585
|
-
const me = data.result || {};
|
|
586
|
-
this.botUsername = me.username || '';
|
|
587
|
-
this.botId = parseInt(me.id, 10) || 0;
|
|
588
|
-
this._log(`bot: @${this.botUsername} (id=${this.botId})`);
|
|
589
|
-
|
|
590
|
-
if (!fs.existsSync(this.workdir)) {
|
|
591
|
-
throw new Error(`Workdir not found: ${this.workdir}`);
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
for (const eng of [...VALID_ENGINES].sort()) {
|
|
595
|
-
const p = whichSync(eng);
|
|
596
|
-
this._log(`engine ${eng}: ${p || 'NOT FOUND'}`);
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
this._log(`config: default_engine=${this.defaultEngine} workdir=${this.workdir}`);
|
|
600
|
-
this._log(`config: timeout=${this.runTimeout}s full_access=${this.codexFullAccess} claude_mode=${this.claudePermissionMode}`);
|
|
601
|
-
if (this.allowedChatIds.size) {
|
|
602
|
-
this._log(`config: allowed_ids=${[...this.allowedChatIds].sort()}`);
|
|
603
|
-
} else {
|
|
604
|
-
this._log('config: allowed_ids=ANY (no restriction)');
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
async run() {
|
|
609
|
-
this._acquireLock();
|
|
610
|
-
process.on('SIGINT', () => this._handleSignal('SIGINT'));
|
|
611
|
-
process.on('SIGTERM', () => this._handleSignal('SIGTERM'));
|
|
612
|
-
await this.preflight();
|
|
613
|
-
this._ensureSingleBot();
|
|
614
|
-
this._startKeepAlive();
|
|
615
|
-
|
|
616
|
-
try {
|
|
617
|
-
const { TelegramChannel } = await import('./channel-telegram.js');
|
|
618
|
-
const channel = new TelegramChannel(this);
|
|
619
|
-
await channel.run();
|
|
620
|
-
} finally {
|
|
621
|
-
this._stopKeepAlive();
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
// ---------------------------------------------------------------------------
|
|
627
|
-
// CLI
|
|
628
|
-
// ---------------------------------------------------------------------------
|
|
629
|
-
|
|
630
|
-
const SUPPORTED_CHANNELS = new Set(['telegram']);
|
|
631
|
-
const PLANNED_CHANNELS = new Set(['slack', 'discord', 'dingtalk', 'feishu']);
|
|
632
|
-
const ALL_CHANNELS = new Set([...SUPPORTED_CHANNELS, ...PLANNED_CHANNELS]);
|
|
633
|
-
|
|
634
|
-
function parseArgs(argv) {
|
|
635
|
-
const args = {
|
|
636
|
-
channel: process.env.CODECLAW_CHANNEL || 'telegram',
|
|
637
|
-
token: null,
|
|
638
|
-
engine: null,
|
|
639
|
-
model: null,
|
|
640
|
-
workdir: null,
|
|
641
|
-
fullAccess: null,
|
|
642
|
-
safeMode: false,
|
|
643
|
-
allowedIds: null,
|
|
644
|
-
timeout: null,
|
|
645
|
-
selfCheck: false,
|
|
646
|
-
version: false,
|
|
647
|
-
help: false,
|
|
648
|
-
};
|
|
649
|
-
|
|
650
|
-
const it = argv[Symbol.iterator]();
|
|
651
|
-
for (const arg of it) {
|
|
652
|
-
switch (arg) {
|
|
653
|
-
case '-c': case '--channel': args.channel = it.next().value; break;
|
|
654
|
-
case '-t': case '--token': args.token = it.next().value; break;
|
|
655
|
-
case '-e': case '--engine': args.engine = it.next().value; break;
|
|
656
|
-
case '-m': case '--model': args.model = it.next().value; break;
|
|
657
|
-
case '-w': case '--workdir': args.workdir = it.next().value; break;
|
|
658
|
-
case '--full-access': args.fullAccess = true; break;
|
|
659
|
-
case '--safe-mode': args.safeMode = true; break;
|
|
660
|
-
case '--allowed-ids': args.allowedIds = it.next().value; break;
|
|
661
|
-
case '--timeout': args.timeout = parseInt(it.next().value, 10); break;
|
|
662
|
-
case '--self-check': args.selfCheck = true; break;
|
|
663
|
-
case '-v': case '--version': args.version = true; break;
|
|
664
|
-
case '-h': case '--help': args.help = true; break;
|
|
665
|
-
default:
|
|
666
|
-
if (arg.startsWith('-')) {
|
|
667
|
-
process.stderr.write(`Unknown option: ${arg}\n`);
|
|
668
|
-
process.exit(1);
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
return args;
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
function printHelp() {
|
|
676
|
-
process.stdout.write(`codeclaw — bridge AI coding agents to your IM.
|
|
677
|
-
|
|
678
|
-
Usage: codeclaw [options]
|
|
679
|
-
npx codeclaw [options]
|
|
680
|
-
|
|
681
|
-
Connection:
|
|
682
|
-
-c, --channel <ch> IM channel (default: telegram)
|
|
683
|
-
-t, --token <token> Bot token
|
|
684
|
-
|
|
685
|
-
Engine:
|
|
686
|
-
-e, --engine <eng> AI engine: claude or codex (default: claude)
|
|
687
|
-
-m, --model <model> Model override
|
|
688
|
-
-w, --workdir <dir> Working directory (default: cwd)
|
|
689
|
-
|
|
690
|
-
Access control:
|
|
691
|
-
--full-access Agent runs without confirmation (default)
|
|
692
|
-
--safe-mode Require confirmation for destructive ops
|
|
693
|
-
--allowed-ids <ids> Comma-separated user/chat ID whitelist
|
|
694
|
-
--timeout <secs> Max seconds per request (default: 300)
|
|
695
|
-
|
|
696
|
-
Other:
|
|
697
|
-
--self-check Validate setup and exit
|
|
698
|
-
-v, --version Show version
|
|
699
|
-
-h, --help Show this help
|
|
700
|
-
|
|
701
|
-
Environment variables:
|
|
702
|
-
CODECLAW_TOKEN Bot token (same as -t)
|
|
703
|
-
CODECLAW_WORKDIR Working directory (same as -w)
|
|
704
|
-
CODECLAW_TIMEOUT Timeout in seconds (same as --timeout)
|
|
705
|
-
DEFAULT_ENGINE AI engine (same as -e)
|
|
706
|
-
CLAUDE_MODEL Claude model name
|
|
707
|
-
CLAUDE_PERMISSION_MODE bypassPermissions (default) or default
|
|
708
|
-
CLAUDE_EXTRA_ARGS Extra args passed to claude CLI
|
|
709
|
-
CODEX_MODEL Codex model name
|
|
710
|
-
CODEX_REASONING_EFFORT none | minimal | low | medium | high | xhigh
|
|
711
|
-
CODEX_EXTRA_ARGS Extra args passed to codex CLI
|
|
712
|
-
|
|
713
|
-
Examples:
|
|
714
|
-
codeclaw -t $BOT_TOKEN
|
|
715
|
-
codeclaw -t $BOT_TOKEN -e codex --safe-mode --allowed-ids 123456,789012
|
|
716
|
-
codeclaw -t $BOT_TOKEN -m sonnet -w ~/projects/my-app
|
|
717
|
-
codeclaw -t $BOT_TOKEN --self-check
|
|
718
|
-
`);
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
export async function main() {
|
|
722
|
-
const argv = process.argv.slice(2);
|
|
723
|
-
const args = parseArgs(argv);
|
|
724
|
-
|
|
725
|
-
if (args.version) {
|
|
726
|
-
process.stdout.write(`codeclaw ${VERSION}\n`);
|
|
727
|
-
return 0;
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
if (args.help) {
|
|
731
|
-
printHelp();
|
|
732
|
-
return 0;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
if (args.channel && !ALL_CHANNELS.has(args.channel)) {
|
|
736
|
-
process.stderr.write(`Unknown channel: ${args.channel}\n`);
|
|
737
|
-
return 1;
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
if (PLANNED_CHANNELS.has(args.channel)) {
|
|
741
|
-
process.stderr.write(
|
|
742
|
-
`[codeclaw] '${args.channel}' is planned but not yet implemented. Currently supported: ${[...SUPPORTED_CHANNELS].sort().join(', ')}\n`
|
|
743
|
-
);
|
|
744
|
-
return 1;
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
// Map CLI flags to env vars
|
|
748
|
-
const token = args.token || process.env.CODECLAW_TOKEN || '';
|
|
749
|
-
if (token) process.env.TELEGRAM_BOT_TOKEN = token;
|
|
750
|
-
if (args.engine) process.env.DEFAULT_ENGINE = args.engine;
|
|
751
|
-
if (args.workdir) process.env.CODECLAW_WORKDIR = args.workdir;
|
|
752
|
-
if (args.model) {
|
|
753
|
-
const engine = args.engine || process.env.DEFAULT_ENGINE || 'claude';
|
|
754
|
-
if (engine === 'codex') {
|
|
755
|
-
process.env.CODEX_MODEL = args.model;
|
|
756
|
-
} else {
|
|
757
|
-
process.env.CLAUDE_MODEL = args.model;
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
if (args.allowedIds) process.env.TELEGRAM_ALLOWED_CHAT_IDS = args.allowedIds;
|
|
761
|
-
if (args.timeout != null) process.env.CODECLAW_TIMEOUT = String(args.timeout);
|
|
762
|
-
|
|
763
|
-
if (args.safeMode) {
|
|
764
|
-
process.env.CODEX_FULL_ACCESS = 'false';
|
|
765
|
-
process.env.CLAUDE_PERMISSION_MODE = 'default';
|
|
766
|
-
} else if (args.fullAccess || envBool('CODECLAW_FULL_ACCESS', true)) {
|
|
767
|
-
process.env.CODEX_FULL_ACCESS = 'true';
|
|
768
|
-
process.env.CLAUDE_PERMISSION_MODE = 'bypassPermissions';
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
const claw = new CodeClaw();
|
|
772
|
-
if (args.selfCheck) {
|
|
773
|
-
claw._acquireLock();
|
|
774
|
-
await claw.preflight();
|
|
775
|
-
process.stdout.write('[codeclaw] ok\n');
|
|
776
|
-
return 0;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
await claw.run();
|
|
780
|
-
return 0;
|
|
781
|
-
}
|