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 +1 -1
- package/src/agents/codex.ts +180 -105
- package/src/handlers/commands.ts +2 -0
package/package.json
CHANGED
package/src/agents/codex.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { spawn } from 'child_process'
|
|
2
|
-
import
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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:
|
|
164
|
+
session: { id: string; codexThreadId?: string },
|
|
119
165
|
userMessage: string,
|
|
120
|
-
|
|
166
|
+
_onNewThreadId?: (id: string) => void
|
|
121
167
|
): Promise<{ text: string; usage?: TokenUsage }> {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
}
|
package/src/handlers/commands.ts
CHANGED
|
@@ -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(
|