cliclaw 1.0.17 → 1.0.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cliclaw",
3
- "version": "1.0.17",
3
+ "version": "1.0.18",
4
4
  "description": "Telegram bot bridging AI CLIs (Claude Code, Codex) to Forum Topics",
5
5
  "main": "index.ts",
6
6
  "scripts": {
@@ -1,10 +1,9 @@
1
- import { spawn } from 'child_process'
2
- import type { Session, Message } from '../storage'
1
+ import { spawn, ChildProcess } from 'child_process'
2
+ import { randomUUID } from 'crypto'
3
3
  import type { TokenUsage } from './claude'
4
4
 
5
5
  const HOME = process.env.HOME || process.env.USERPROFILE || '/root'
6
6
  const SEP = process.platform === 'win32' ? ';' : ':'
7
- // Keep system PATH (contains npm global bin) and append Linux fallbacks for non-Windows
8
7
  const BASE_ENV = {
9
8
  PATH: [
10
9
  process.env.PATH || '',
@@ -15,119 +14,195 @@ const BASE_ENV = {
15
14
  LANG: 'en_US.UTF-8',
16
15
  }
17
16
 
18
- function extractTextFromObj(obj: any): string | null {
19
- // New codex format: {"id":"...","msg":{"type":"agent_message","text":"..."}}
20
- if (obj.msg?.type === 'agent_message' && obj.msg?.text) return obj.msg.text
21
- // Old format: item.completed with agent_message
22
- if (obj.type === 'item.completed' && obj.item?.type === 'agent_message' && obj.item?.text)
23
- return obj.item.text
24
- // OpenAI Responses API: response.output_item.done with message content array
25
- if (obj.item?.type === 'message' && Array.isArray(obj.item?.content)) {
26
- const parts = obj.item.content
27
- .filter((c: any) => c.type === 'output_text' && c.text)
28
- .map((c: any) => c.text)
29
- if (parts.length > 0) return parts.join('')
30
- }
31
- // Fallback: top-level result/output
32
- if (typeof obj.result === 'string' && obj.result) return obj.result
33
- if (typeof obj.output === 'string' && obj.output) return obj.output
34
- return null
17
+ const SESSION_TTL_MS = 30 * 60 * 1000 // kill idle processes after 30 min
18
+ const RESPONSE_TIMEOUT_MS = 120_000 // 2 min per message
19
+
20
+ interface PendingReq {
21
+ resolve: (r: { text: string; usage?: TokenUsage }) => void
22
+ reject: (e: Error) => void
23
+ texts: string[]
24
+ usage?: TokenUsage
25
+ timer: ReturnType<typeof setTimeout>
35
26
  }
36
27
 
37
- function buildPrompt(history: Message[], userMessage: string): string {
38
- // history already contains the current user message as the last item (added before askCodex)
39
- const prior = history.slice(0, -1).slice(-20) // up to last 20 prior messages
40
- if (prior.length === 0) return userMessage
41
- const lines = prior.map(m => {
42
- let content = m.content
43
- // Strip usage stats suffix (e.g. "\n`📊 ↑1234 ↓56`")
44
- const usageIdx = content.lastIndexOf('\n`📊')
45
- if (usageIdx > 0) content = content.slice(0, usageIdx)
46
- if (content.length > 600) content = content.slice(0, 600) + '…'
47
- return `${m.role === 'user' ? 'User' : 'Assistant'}: ${content}`
48
- })
49
- return `<conversation_history>\n${lines.join('\n')}\n</conversation_history>\n\nUser: ${userMessage}`
28
+ interface ProtoSession {
29
+ proc: ChildProcess
30
+ sessionId: string
31
+ lastUsed: number
32
+ pending: Map<string, PendingReq>
33
+ buf: string // incomplete stdout line buffer
50
34
  }
51
35
 
52
- function spawnCodex(args: string[], stdinText?: string): Promise<{ text: string; threadId: string | null; usage?: TokenUsage }> {
53
- return new Promise((resolve, reject) => {
54
- let stdout = ''
55
- let stderr = ''
56
- const isWin = process.platform === 'win32'
57
- const proc = spawn('codex', args, {
58
- env: { ...process.env, ...BASE_ENV },
59
- cwd: process.cwd(),
60
- shell: isWin,
61
- windowsHide: true,
62
- // Use pipe for stdin when we have text to send; otherwise ignore to prevent PM2 hang
63
- stdio: [stdinText != null ? 'pipe' : 'ignore', 'pipe', 'pipe'],
64
- })
65
- if (stdinText != null) {
66
- proc.stdin!.end(stdinText, 'utf8')
36
+ // Map from cliclaw session.id ProtoSession
37
+ const protoSessions = new Map<string, ProtoSession>()
38
+
39
+ // ─── cleanup idle sessions every 5 min ───────────────────────────────────────
40
+ setInterval(() => {
41
+ const now = Date.now()
42
+ for (const [id, ps] of protoSessions) {
43
+ if (ps.pending.size === 0 && now - ps.lastUsed > SESSION_TTL_MS) {
44
+ console.log(`[Codex] closing idle proto session ${ps.sessionId}`)
45
+ try { ps.proc.stdin!.write(JSON.stringify({ id: 'shutdown', op: { type: 'shutdown' } }) + '\n') } catch {}
46
+ setTimeout(() => { try { ps.proc.kill() } catch {} }, 2000)
47
+ protoSessions.delete(id)
67
48
  }
68
- proc.stdout.on('data', (d: Buffer) => { stdout += d.toString() })
69
- proc.stderr.on('data', (d: Buffer) => {
70
- const s = d.toString().trim()
71
- if (s) { stderr += s + '\n'; console.error('[Codex stderr]', s) }
72
- })
73
- proc.on('close', (code) => {
74
- const lines = stdout.split('\n').filter(l => l.trim())
75
- const texts: string[] = []
76
- let threadId: string | null = null
77
- let usage: TokenUsage | undefined
78
-
79
- for (const line of lines) {
80
- try {
81
- const obj = JSON.parse(line)
82
- if (obj.type === 'thread.started' && obj.thread_id)
83
- threadId = obj.thread_id
84
- const extracted = extractTextFromObj(obj)
85
- if (extracted) texts.push(extracted)
86
- // Codex/OpenAI usage events (response.completed carries usage)
87
- const u = obj.usage ?? obj.response?.usage ?? obj.item?.usage
88
- if (u && (u.input_tokens != null || u.prompt_tokens != null)) {
89
- usage = {
90
- input: u.input_tokens ?? u.prompt_tokens ?? 0,
91
- output: u.output_tokens ?? u.completion_tokens ?? 0,
92
- cacheRead: u.input_tokens_details?.cached_tokens ?? 0,
93
- cacheWrite: 0,
94
- }
95
- }
96
- } catch {}
97
- }
49
+ }
50
+ }, 5 * 60 * 1000)
98
51
 
99
- if (texts.length > 0) {
100
- resolve({ text: texts.join('\n'), threadId, usage })
101
- } else if (code !== 0) {
102
- reject(new Error(`Codex exited with code ${code}${stderr ? ': ' + stderr.trim() : ''}`))
103
- } else {
104
- // Log raw output to help diagnose format issues
105
- if (stdout.trim()) console.error('[Codex no-parse] raw stdout:', stdout.trim().slice(0, 500))
106
- if (stderr.trim()) console.error('[Codex no-parse] stderr:', stderr.trim().slice(0, 500))
107
- // Fall back to raw stdout if it's plain text (not JSON)
108
- const raw = stdout.trim()
109
- const looksLikeJson = raw.startsWith('{') || raw.startsWith('[')
110
- resolve({ text: raw && !looksLikeJson ? raw : '[no response]', threadId, usage })
111
- }
112
- })
113
- proc.on('error', reject)
52
+ // ─── start a codex proto process ─────────────────────────────────────────────
53
+ function startProto(appSessionId: string): ProtoSession {
54
+ const isWin = process.platform === 'win32'
55
+ const proc = spawn('codex', ['proto'], {
56
+ env: { ...process.env, ...BASE_ENV },
57
+ shell: isWin,
58
+ windowsHide: true,
59
+ stdio: ['pipe', 'pipe', 'pipe'],
114
60
  })
61
+
62
+ const ps: ProtoSession = {
63
+ proc,
64
+ sessionId: '',
65
+ lastUsed: Date.now(),
66
+ pending: new Map(),
67
+ buf: '',
68
+ }
69
+
70
+ proc.stdout!.on('data', (d: Buffer) => {
71
+ const lines = (ps.buf + d.toString()).split('\n')
72
+ ps.buf = lines.pop() || ''
73
+ for (const line of lines) {
74
+ if (!line.trim()) continue
75
+ try {
76
+ const obj = JSON.parse(line)
77
+ handleProtoEvent(ps, obj)
78
+ } catch {}
79
+ }
80
+ })
81
+
82
+ proc.stderr!.on('data', (d: Buffer) => {
83
+ const s = d.toString()
84
+ // Only log actual errors, not Rust INFO/DEBUG log lines
85
+ if (s.includes(' ERROR ') || (!s.includes(' INFO ') && !s.includes(' WARN ') && s.trim()))
86
+ console.error('[Codex proto]', s.trim())
87
+ })
88
+
89
+ proc.on('close', () => {
90
+ for (const [, req] of ps.pending) {
91
+ clearTimeout(req.timer)
92
+ req.reject(new Error('Codex process exited unexpectedly'))
93
+ }
94
+ protoSessions.delete(appSessionId)
95
+ })
96
+
97
+ proc.on('error', (err) => {
98
+ for (const [, req] of ps.pending) {
99
+ clearTimeout(req.timer)
100
+ req.reject(err)
101
+ }
102
+ protoSessions.delete(appSessionId)
103
+ })
104
+
105
+ protoSessions.set(appSessionId, ps)
106
+ return ps
115
107
  }
116
108
 
109
+ function handleProtoEvent(ps: ProtoSession, obj: any) {
110
+ const msgId: string = obj.id
111
+ const msg = obj.msg
112
+
113
+ if (msg?.type === 'session_configured') {
114
+ ps.sessionId = msg.session_id ?? ''
115
+ return
116
+ }
117
+
118
+ const req = msgId ? ps.pending.get(msgId) : null
119
+ if (!req) return
120
+
121
+ switch (msg?.type) {
122
+ case 'agent_message':
123
+ if (msg.message) req.texts.push(msg.message)
124
+ break
125
+ case 'token_count': {
126
+ const u = msg.info?.last_token_usage
127
+ if (u) {
128
+ req.usage = {
129
+ input: u.input_tokens ?? 0,
130
+ output: u.output_tokens ?? 0,
131
+ cacheRead: u.cached_input_tokens ?? 0,
132
+ cacheWrite: 0,
133
+ }
134
+ }
135
+ break
136
+ }
137
+ case 'task_complete': {
138
+ clearTimeout(req.timer)
139
+ const text = req.texts.join('\n') || msg.last_agent_message || '[no response]'
140
+ req.resolve({ text, usage: req.usage })
141
+ ps.pending.delete(msgId)
142
+ break
143
+ }
144
+ case 'error': {
145
+ clearTimeout(req.timer)
146
+ req.reject(new Error(msg.message ?? 'Codex error'))
147
+ ps.pending.delete(msgId)
148
+ break
149
+ }
150
+ }
151
+ }
152
+
153
+ function getOrStart(appSessionId: string): ProtoSession {
154
+ const existing = protoSessions.get(appSessionId)
155
+ if (existing && existing.proc.exitCode === null) {
156
+ existing.lastUsed = Date.now()
157
+ return existing
158
+ }
159
+ return startProto(appSessionId)
160
+ }
161
+
162
+ // ─── public API ──────────────────────────────────────────────────────────────
117
163
  export async function askCodex(
118
- session: Session,
164
+ session: { id: string; codexThreadId?: string },
119
165
  userMessage: string,
120
- onNewThreadId?: (id: string) => void
166
+ _onNewThreadId?: (id: string) => void
121
167
  ): Promise<{ text: string; usage?: TokenUsage }> {
122
- try {
123
- // Build full prompt with conversation history, send via stdin (avoids Windows arg escaping issues)
124
- const prompt = buildPrompt(session.history, userMessage)
125
- // `-` tells codex exec to read PROMPT from stdin
126
- const args = ['exec', '--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check', '--json', '-']
127
- const { text, threadId, usage } = await spawnCodex(args, prompt)
128
- if (threadId && !session.codexThreadId) onNewThreadId?.(threadId)
129
- return { text, usage }
130
- } catch (err: any) {
131
- return { text: `❌ Codex error: ${err.message}` }
168
+ const ps = getOrStart(session.id)
169
+ const msgId = randomUUID()
170
+
171
+ return new Promise((resolve, reject) => {
172
+ const timer = setTimeout(() => {
173
+ ps.pending.delete(msgId)
174
+ reject(new Error('Codex response timeout (120s)'))
175
+ }, RESPONSE_TIMEOUT_MS)
176
+
177
+ ps.pending.set(msgId, {
178
+ resolve,
179
+ reject,
180
+ texts: [],
181
+ timer,
182
+ })
183
+
184
+ const op = JSON.stringify({
185
+ id: msgId,
186
+ op: { type: 'user_input', items: [{ type: 'text', text: userMessage }] },
187
+ })
188
+
189
+ try {
190
+ ps.proc.stdin!.write(op + '\n')
191
+ } catch (err: any) {
192
+ clearTimeout(timer)
193
+ ps.pending.delete(msgId)
194
+ reject(new Error(`Failed to write to codex: ${err.message}`))
195
+ }
196
+ }).then(
197
+ (r: any) => r,
198
+ async (err: Error) => ({ text: `❌ Codex error: ${err.message}` })
199
+ )
200
+ }
201
+
202
+ export function killCodexSession(appSessionId: string) {
203
+ const ps = protoSessions.get(appSessionId)
204
+ if (ps) {
205
+ try { ps.proc.kill() } catch {}
206
+ protoSessions.delete(appSessionId)
132
207
  }
133
208
  }
@@ -2,6 +2,7 @@ import type { Bot, Context } from 'grammy'
2
2
  import type { Storage } from '../storage'
3
3
  import type { Config, AgentName } from '../config'
4
4
  import { killSession } from '../agents/claude'
5
+ import { killCodexSession } from '../agents/codex'
5
6
  import { formatTelegramMarkdown, splitTelegramMessage, TELEGRAM_MARKDOWN_OPTS } from '../telegram'
6
7
  import { dockerHelpText, pm2HelpText, runDockerCommand, runPm2Command } from '../admin'
7
8
  import { getLang, t } from '../i18n'
@@ -180,6 +181,7 @@ export function registerCommands(bot: Bot<Context>, storage: Storage, config: Co
180
181
  : storage.getActiveSession(chatId)
181
182
  if (!session) { await ctx.reply(t(lang, 'noActiveSession')); return }
182
183
  killSession(session.id)
184
+ killCodexSession(session.id)
183
185
  storage.clearSession(chatId, session.id)
184
186
  const opts: any = session.threadId ? { message_thread_id: session.threadId } : {}
185
187
  await ctx.reply(