imtoagent 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +234 -0
  2. package/bin/imtoagent +453 -0
  3. package/index.ts +1129 -0
  4. package/modules/agent/claude-adapter.ts +258 -0
  5. package/modules/agent/claude.ts +160 -0
  6. package/modules/agent/codex-adapter.ts +232 -0
  7. package/modules/agent/codex-exec-server.ts +513 -0
  8. package/modules/agent/codex.ts +275 -0
  9. package/modules/agent/opencode-adapter.ts +308 -0
  10. package/modules/agent/opencode.ts +247 -0
  11. package/modules/bot-context.ts +26 -0
  12. package/modules/capabilities.ts +189 -0
  13. package/modules/cli/setup.ts +424 -0
  14. package/modules/core/config.ts +275 -0
  15. package/modules/core/error.ts +124 -0
  16. package/modules/core/index.ts +39 -0
  17. package/modules/core/runtime.ts +282 -0
  18. package/modules/core/session.ts +256 -0
  19. package/modules/core/stats.ts +92 -0
  20. package/modules/core/types.ts +250 -0
  21. package/modules/im/feishu.ts +731 -0
  22. package/modules/im/telegram.ts +639 -0
  23. package/modules/im/wechat.ts +1094 -0
  24. package/modules/im/wecom.ts +603 -0
  25. package/modules/media/feishu-inbound-adapter.ts +108 -0
  26. package/modules/media/index.ts +27 -0
  27. package/modules/media/media-store.ts +273 -0
  28. package/modules/media/resolver.ts +178 -0
  29. package/modules/media/telegram-inbound-adapter.ts +124 -0
  30. package/modules/media/types.ts +76 -0
  31. package/modules/prompt-builder.ts +123 -0
  32. package/modules/proxy/anthropic-proxy.ts +1083 -0
  33. package/modules/proxy/codex-proxy.ts +657 -0
  34. package/modules/rate-limiter.ts +58 -0
  35. package/modules/types.ts +144 -0
  36. package/modules/utils/backend-check.ts +121 -0
  37. package/modules/utils/paths.ts +218 -0
  38. package/package.json +53 -0
  39. package/scripts/postinstall.ts +70 -0
  40. package/templates/config.template.json +57 -0
  41. package/templates/opencode.template.json +28 -0
  42. package/templates/providers.template.json +19 -0
  43. package/templates/soul.template/identity.md +6 -0
  44. package/templates/soul.template/profile.md +11 -0
  45. package/templates/soul.template/rules.md +7 -0
  46. package/templates/soul.template/skills.md +3 -0
  47. package/templates/soul.template/workspace.md +4 -0
@@ -0,0 +1,258 @@
1
+ // ================================================================
2
+ // Claude Agent SDK Adapter — 实现 SDK AgentAdapter 接口
3
+ // ================================================================
4
+ // 职责:对接 Claude Agent SDK,将 AgentInput 转换为 AgentOutput
5
+ // 不负责:session 管理、统计、格式化、错误处理(由 SDK Runtime 接管)
6
+ // ================================================================
7
+
8
+ import { query } from '@anthropic-ai/claude-agent-sdk';
9
+ import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
10
+ import type { AgentAdapter, AgentInput, AgentOutput, Session } from '../core/types';
11
+ import { buildAttachmentHint } from '../core/types';
12
+ import { buildSystemPrompt } from '../prompt-builder';
13
+
14
+ // ================================================================
15
+ // ClaudeAdapter 上下文
16
+ // ================================================================
17
+
18
+ export interface ClaudeAdapterContext {
19
+ /** 用于构建 system prompt(IM 能力 + bot 名 + soul) */
20
+ imModule?: { getCapabilities(): any } | null;
21
+ botName: string;
22
+ /** 模型别名映射(sonnet/opus/haiku → 实际供应商/模型) */
23
+ modelAliases: Record<string, string>;
24
+ }
25
+
26
+ // ================================================================
27
+ // 工具函数
28
+ // ================================================================
29
+
30
+ function resolveAlias(modelSpec: string): string {
31
+ const i = modelSpec.indexOf('/');
32
+ return i >= 0 ? modelSpec.slice(i + 1) : modelSpec;
33
+ }
34
+
35
+ function extractText(msg: SDKMessage): string | null {
36
+ if (msg.type !== 'assistant') return null;
37
+ const content = (msg as any).message?.content;
38
+ if (!Array.isArray(content)) return null;
39
+ return content.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('') || null;
40
+ }
41
+
42
+ function extractToolCalls(msg: SDKMessage): Array<{ name: string; summary: string }> {
43
+ if (msg.type !== 'assistant') return [];
44
+ const content = (msg as any).message?.content;
45
+ if (!Array.isArray(content)) return [];
46
+ const results: Array<{ name: string; summary: string }> = [];
47
+ for (const block of content) {
48
+ if (block.type === 'tool_use' && block.name) {
49
+ const input = block.input || {};
50
+ let summary = '';
51
+ if (['Read', 'Edit', 'Write'].includes(block.name) && input.file_path) {
52
+ const p = String(input.file_path);
53
+ summary = p.includes('/') ? p.split('/').pop()! : p;
54
+ } else if (block.name === 'Bash' && input.command) {
55
+ summary = String(input.command).trim().slice(0, 60);
56
+ }
57
+ results.push({ name: block.name, summary });
58
+ }
59
+ }
60
+ return results;
61
+ }
62
+
63
+ // ================================================================
64
+ // ClaudeAdapter — 实现 AgentAdapter
65
+ // ================================================================
66
+
67
+ export class ClaudeAdapter implements AgentAdapter {
68
+ readonly name = 'Claude Agent SDK';
69
+ private ctx: ClaudeAdapterContext;
70
+ private activeControllers: AbortController[] = [];
71
+ /** 单次调用最大超时(毫秒),0 = 不限制 */
72
+ static MAX_CALL_TIMEOUT_MS = 5 * 60 * 1000; // 5 分钟
73
+
74
+ constructor(ctx: ClaudeAdapterContext) {
75
+ this.ctx = ctx;
76
+ }
77
+
78
+ /**
79
+ * 清理所有活跃的子进程和请求。
80
+ * 在 gracefulShutdown 时由 index.ts 调用。
81
+ */
82
+ cleanup(): void {
83
+ const count = this.activeControllers.length;
84
+ if (count > 0) {
85
+ console.log(`[ClaudeAdapter] cleanup: aborting ${count} active request(s)`);
86
+ for (const ctrl of this.activeControllers) {
87
+ try { ctrl.abort(); } catch {}
88
+ }
89
+ this.activeControllers = [];
90
+ }
91
+ }
92
+
93
+ /**
94
+ * 处理单条用户消息
95
+ *
96
+ * 接收 AgentInput,调用 Claude SDK,返回 AgentOutput
97
+ * Session 管理、统计、格式化由 SDK Runtime 负责
98
+ */
99
+ async handleMessage(input: AgentInput): Promise<AgentOutput> {
100
+ const { text, session, workingDir, model, systemPrompt: overrideSystemPrompt } = input;
101
+ const sessionAny = session as any; // 向后兼容:访问旧字段
102
+
103
+ // 创建 AbortController 并注册(用于超时 + shutdown 清理)
104
+ const abortCtrl = new AbortController();
105
+ this.activeControllers.push(abortCtrl);
106
+
107
+ // 确定模型名
108
+ const modelName = model.includes('/') ? model.slice(model.indexOf('/') + 1) : model;
109
+ const aliases = this.ctx.modelAliases;
110
+
111
+ // 附件信息注入:让 Agent 知道用户发送了附件(图片/文件/语音)及本地路径
112
+ let effectiveText = text;
113
+ if (input.attachments && input.attachments.length > 0) {
114
+ effectiveText = buildAttachmentHint(input.attachments) + '\n\n---\n\n' + effectiveText;
115
+ }
116
+
117
+ // Claude SDK 环境变量(走本地 :18899 代理)
118
+ const customEnv: Record<string, string> = {
119
+ ...process.env,
120
+ ANTHROPIC_BASE_URL: 'http://localhost:18899',
121
+ ANTHROPIC_API_KEY: '',
122
+ ANTHROPIC_AUTH_TOKEN: '',
123
+ ANTHROPIC_MODEL: '',
124
+ ANTHROPIC_DEFAULT_SONNET_MODEL: resolveAlias(aliases.sonnet || ''),
125
+ ANTHROPIC_DEFAULT_OPUS_MODEL: resolveAlias(aliases.opus || ''),
126
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: resolveAlias(aliases.haiku || ''),
127
+ };
128
+
129
+ // 构建查询选项
130
+ const queryOptions: any = {
131
+ cwd: workingDir,
132
+ maxTurns: 50,
133
+ model: modelName,
134
+ permissionMode: sessionAny.permissionMode || 'bypassPermissions',
135
+ persistSession: true,
136
+ abortController: abortCtrl,
137
+ };
138
+
139
+ // 超时保护:防止 Claude CLI 子进程无限运行
140
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
141
+ if (ClaudeAdapter.MAX_CALL_TIMEOUT_MS > 0) {
142
+ timeoutId = setTimeout(() => {
143
+ console.log(`[ClaudeAdapter] ⏰ 超时 (${ClaudeAdapter.MAX_CALL_TIMEOUT_MS / 1000}s),中止请求`);
144
+ abortCtrl.abort();
145
+ }, ClaudeAdapter.MAX_CALL_TIMEOUT_MS);
146
+ }
147
+
148
+ // System Prompt(优先使用传入的,否则自行构建)
149
+ if (overrideSystemPrompt) {
150
+ queryOptions.systemPrompt = overrideSystemPrompt;
151
+ } else {
152
+ queryOptions.systemPrompt = buildSystemPrompt({
153
+ imModule: this.ctx.imModule || null,
154
+ botName: this.ctx.botName,
155
+ });
156
+ }
157
+
158
+ // 恢复/创建 SDK 会话 ID
159
+ const shouldClear = session.startFresh;
160
+ session.startFresh = false;
161
+ const sdkSessionId = shouldClear ? undefined : (session.metadata?.sdkSessionId || sessionAny.sdkSessionId);
162
+ if (sdkSessionId) {
163
+ queryOptions.resume = sdkSessionId;
164
+ console.log(`[ClaudeAdapter] resuming sdkSessionId=${sdkSessionId}`);
165
+ } else {
166
+ const newId = crypto.randomUUID();
167
+ queryOptions.sessionId = newId;
168
+ // 立即写入 metadata,避免流中丢失
169
+ session.metadata.sdkSessionId = newId;
170
+ sessionAny.sdkSessionId = newId;
171
+ console.log(`[ClaudeAdapter] new sessionId=${newId}`);
172
+ }
173
+
174
+ console.log(`[ClaudeAdapter] query model=${modelName} cwd=${workingDir} resume=${!!sdkSessionId}`);
175
+
176
+ try {
177
+ // 执行 Claude SDK 查询(流式)
178
+ const q = query({
179
+ prompt: [{
180
+ type: 'user',
181
+ message: { role: 'user', content: [{ type: 'text', text: effectiveText }] },
182
+ }],
183
+ options: queryOptions,
184
+ env: customEnv,
185
+ });
186
+
187
+ let fullResponse = '';
188
+ const toolCalls: Array<{ name: string; summary: string }> = [];
189
+
190
+ for await (const msg of q) {
191
+ const msgAny = msg as any;
192
+
193
+ // 捕获 SDK session ID(存入 metadata 供 SDK Runtime 持久化)
194
+ if (msgAny.session_id && !session.metadata?.sdkSessionId) {
195
+ session.metadata.sdkSessionId = msgAny.session_id;
196
+ // 向后兼容:也写回旧字段
197
+ sessionAny.sdkSessionId = msgAny.session_id;
198
+ }
199
+
200
+ // 提取文本
201
+ const extractedText = extractText(msg);
202
+ if (extractedText) fullResponse += extractedText;
203
+
204
+ // 提取工具调用
205
+ const calls = extractToolCalls(msg);
206
+ toolCalls.push(...calls);
207
+
208
+ // 处理最终结果
209
+ if (msg.type === 'result') {
210
+ const result = msgAny;
211
+
212
+ if (result.subtype === 'error' || result.subtype === 'cancelled') {
213
+ throw new Error(result.error || result.result || '未知错误');
214
+ }
215
+
216
+ const usage = {
217
+ inputTokens: result.usage?.input_tokens || 0,
218
+ outputTokens: result.usage?.output_tokens || 0,
219
+ costUSD: result.total_cost_usd || 0,
220
+ durationMs: result.duration_ms || 0,
221
+ numTurns: result.num_turns || 0,
222
+ };
223
+
224
+ if (timeoutId) clearTimeout(timeoutId);
225
+ const responseText = fullResponse || `✅ 已完成 (${toolCalls.length} 步操作)`;
226
+
227
+ return {
228
+ text: responseText,
229
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
230
+ usage,
231
+ };
232
+ }
233
+ }
234
+
235
+ // 流结束但没有 result 消息
236
+ return {
237
+ text: fullResponse || '✅ 完成',
238
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
239
+ };
240
+
241
+ } catch (err: any) {
242
+ if (timeoutId) clearTimeout(timeoutId);
243
+ // 如果是被 abort(超时或手动清理),提供有意义的消息
244
+ if (abortCtrl.signal.aborted) {
245
+ console.log(`[ClaudeAdapter] 请求已被中止 (${err.message || 'aborted'})`);
246
+ return {
247
+ text: '⚠️ 请求超时或已被取消,请稍后重试。',
248
+ };
249
+ }
250
+ throw err;
251
+ } finally {
252
+ // 清理当前 AbortController
253
+ const idx = this.activeControllers.indexOf(abortCtrl);
254
+ if (idx >= 0) this.activeControllers.splice(idx, 1);
255
+ if (timeoutId) clearTimeout(timeoutId);
256
+ }
257
+ }
258
+ }
@@ -0,0 +1,160 @@
1
+ // Claude Agent 模块
2
+ // 对接 Claude Agent SDK,通过 :18899 Proxy 调用 Provider
3
+
4
+ import { query } from '@anthropic-ai/claude-agent-sdk';
5
+ import type { AgentContext } from '../types';
6
+ import { parseToBlocks, type UnifiedBlock } from '../capabilities';
7
+ import { buildSystemPrompt, resolveCapabilities } from '../prompt-builder';
8
+ import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
9
+
10
+ // ================================================================
11
+ // 工具函数
12
+ // ================================================================
13
+ function resolveAlias(modelSpec: string): string {
14
+ const i = modelSpec.indexOf('/');
15
+ return i >= 0 ? modelSpec.slice(i + 1) : modelSpec;
16
+ }
17
+
18
+ function extractText(msg: SDKMessage): string | null {
19
+ if (msg.type !== 'assistant') return null;
20
+ const content = (msg as any).message?.content;
21
+ if (!Array.isArray(content)) return null;
22
+ return content.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('') || null;
23
+ }
24
+
25
+ function extractToolInfo(msg: SDKMessage): { name: string; summary: string } | null {
26
+ if (msg.type !== 'assistant') return null;
27
+ const content = (msg as any).message?.content;
28
+ if (!Array.isArray(content)) return null;
29
+ const tool = content.find((b: any) => b.type === 'tool_use');
30
+ if (!tool?.name) return null;
31
+ const input = tool.input || {};
32
+ let summary = '';
33
+ if (['Read','Edit','Write'].includes(tool.name) && input.file_path) {
34
+ const p = String(input.file_path);
35
+ summary = p.includes('/') ? p.split('/').pop()! : p;
36
+ } else if (tool.name === 'Bash' && input.command) {
37
+ summary = String(input.command).trim().slice(0, 60);
38
+ }
39
+ return { name: tool.name, summary };
40
+ }
41
+
42
+ // ================================================================
43
+ // Claude 模块类
44
+ // ================================================================
45
+
46
+ /**
47
+ * 注意:此模块目前依赖宿主 Bot 实例的方法(reply/sendProgress/addToolLog 等)。
48
+ * Phase 2 第一步先原样提取,后续逐步解耦为干净接口。
49
+ */
50
+ export class ClaudeAgentModule {
51
+ private ctx: AgentContext;
52
+
53
+ constructor(ctx: AgentContext) {
54
+ this.ctx = ctx;
55
+ }
56
+
57
+ async handleMessage(chatId: string, text: string, session: any) {
58
+ session.generator.push({
59
+ type: 'user', message: { role: 'user', content: [{ type: 'text', text }] },
60
+ });
61
+ if (!session.running) this._startLoop(chatId);
62
+ }
63
+
64
+ private async _startLoop(chatId: string) {
65
+ const ctx = this.ctx;
66
+ const session = ctx.sessions.get(chatId);
67
+ if (!session || session.running) return;
68
+ session.running = true;
69
+ console.log(`[${ctx.name}] Claude 循环启动 chat=${chatId.slice(-8)}`);
70
+
71
+ try {
72
+ const modelSpec = ctx.activeModel;
73
+ const modelName = modelSpec.slice(modelSpec.indexOf('/') + 1);
74
+ const aliases = ctx.modelAliases;
75
+ const customEnv: Record<string, string> = {
76
+ ...process.env,
77
+ ANTHROPIC_BASE_URL: 'http://localhost:18899',
78
+ ANTHROPIC_API_KEY: '', ANTHROPIC_AUTH_TOKEN: '', ANTHROPIC_MODEL: '',
79
+ ANTHROPIC_DEFAULT_SONNET_MODEL: resolveAlias(aliases.sonnet),
80
+ ANTHROPIC_DEFAULT_OPUS_MODEL: resolveAlias(aliases.opus),
81
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: resolveAlias(aliases.haiku),
82
+ };
83
+
84
+ const queryOptions: any = {
85
+ cwd: session.cwd || ctx.defaultCwd,
86
+ maxTurns: 50, model: modelName,
87
+ permissionMode: session.permissionMode || 'bypassPermissions',
88
+ persistSession: true,
89
+ };
90
+ if (session.sdkSessionId) {
91
+ queryOptions.resume = session.sdkSessionId;
92
+ } else {
93
+ queryOptions.sessionId = crypto.randomUUID();
94
+ }
95
+
96
+ const botName = ctx.name;
97
+ const systemPrompt = buildSystemPrompt({
98
+ imModule: ctx.imModule || null,
99
+ botName,
100
+ });
101
+ console.log(`[Claude] 📝 system prompt built (${systemPrompt.length} chars, bot=${botName})`);
102
+ queryOptions.systemPrompt = systemPrompt;
103
+
104
+ const q = query({
105
+ prompt: session.generator.generate(),
106
+ options: queryOptions, env: customEnv,
107
+ });
108
+
109
+ let fullResponse = '', toolCalls = 0;
110
+ let callInput = 0, callOutput = 0, callCost = 0, callDur = 0;
111
+
112
+ for await (const msg of q) {
113
+ const msgAny = msg as any;
114
+ if (msgAny.session_id && !session.sdkSessionId) {
115
+ session.sdkSessionId = msgAny.session_id;
116
+ ctx.persistSession(chatId, session);
117
+ }
118
+ const text = extractText(msg);
119
+ if (text) fullResponse += text;
120
+ const toolInfo = extractToolInfo(msg);
121
+ if (toolInfo) { toolCalls++; ctx.addToolLog(chatId, toolInfo); }
122
+
123
+ if (msg.type === 'result') {
124
+ const result = msg as any;
125
+ callInput = result.usage?.input_tokens || 0;
126
+ callOutput = result.usage?.output_tokens || 0;
127
+ callCost = result.total_cost_usd || 0;
128
+ callDur = result.duration_ms || 0;
129
+
130
+ ctx.accumulateStats(session, {
131
+ inputTokens: callInput, outputTokens: callOutput,
132
+ costUSD: callCost, durationMs: callDur,
133
+ numTurns: result.num_turns || 0,
134
+ });
135
+
136
+ if (result.subtype === 'error' || result.subtype === 'cancelled') {
137
+ await ctx.reply(chatId, `❌ ${result.error || result.result || '未知错误'}`);
138
+ } else if (fullResponse) {
139
+ await ctx.sendFormattedReply(chatId, fullResponse);
140
+ } else {
141
+ await ctx.reply(chatId, `✅ 已完成 (${toolCalls} 步操作)`);
142
+ }
143
+
144
+ ctx.flushToolLog(chatId);
145
+ const costStr = callCost > 0 ? `费用 $${callCost.toFixed(4)}\n` : '';
146
+ await ctx.sendProgress(chatId,
147
+ `✅ 完成 (${toolCalls} 步)\n输入 ${callInput.toLocaleString()} Token\n输出 ${callOutput.toLocaleString()} Token\n${costStr}耗时 ${(callDur/1000).toFixed(1)}s`);
148
+ fullResponse = ''; toolCalls = 0;
149
+ }
150
+ }
151
+ } catch (e: any) {
152
+ console.error(`[${ctx.name}] Claude 错误: ${e.message}`);
153
+ await ctx.reply(chatId, `❌ ${e.message}`);
154
+ } finally {
155
+ session.running = false;
156
+ session.generator.close();
157
+ ctx.persistSession(chatId, session);
158
+ }
159
+ }
160
+ }
@@ -0,0 +1,232 @@
1
+ // ================================================================
2
+ // Codex Agent Adapter — 实现 SDK AgentAdapter 接口
3
+ // ================================================================
4
+ // 职责:对接 Codex CLI,将 AgentInput 转换为 AgentOutput
5
+ // 支持两条路径:
6
+ // 1. App-Server(优先):进程内 HTTP 流式,长记忆,崩溃不丢上下文
7
+ // 2. CLI 子进程(fallback):codex exec/resume
8
+ // ================================================================
9
+
10
+ import type { AgentAdapter, AgentInput, AgentOutput, Session } from '../core/types';
11
+ import { buildAttachmentHint } from '../core/types';
12
+ import { buildSystemPrompt } from '../prompt-builder';
13
+ import { getAppServerManager, type AgentEvent } from './codex-exec-server';
14
+
15
+ // ================================================================
16
+ // CodexAdapter 上下文
17
+ // ================================================================
18
+
19
+ export interface CodexAdapterContext {
20
+ imModule?: { getCapabilities(): any } | null;
21
+ botName: string;
22
+ }
23
+
24
+ // ================================================================
25
+ // Codex CLI 调用
26
+ // ================================================================
27
+
28
+ interface CodexJsonEvent {
29
+ type: string;
30
+ thread_id?: string;
31
+ item?: { type: string; text?: string; name?: string; arguments?: string; output?: string };
32
+ text?: string;
33
+ delta?: string;
34
+ message?: { content?: { type: string; text?: string }[] };
35
+ error?: string;
36
+ }
37
+
38
+ function processCodexStream(stdout: string): { threadId: string; response: string } {
39
+ let threadId = '', response = '';
40
+ for (const line of stdout.split('\n')) {
41
+ if (!line.trim()) continue;
42
+ try {
43
+ const evt: CodexJsonEvent = JSON.parse(line);
44
+ if (evt.type === 'thread.started' && evt.thread_id) {
45
+ threadId = evt.thread_id;
46
+ } else if (evt.type === 'item.completed') {
47
+ if (evt.item?.type === 'agent_message') {
48
+ response = (response ? response + '\n' : '') + (evt.item.text || '');
49
+ }
50
+ } else if (evt.type === 'error' || evt.type === 'thread.error') {
51
+ console.error(`[CodexAdapter] event error: ${(evt as any).message || (evt as any).error || JSON.stringify(evt)}`);
52
+ }
53
+ } catch {}
54
+ }
55
+ return { threadId, response };
56
+ }
57
+
58
+ async function spawnCodexExec(cwd: string, prompt: string): Promise<{ threadId: string; response: string }> {
59
+ const child = Bun.spawn(['codex', 'exec', '-p', 'imtoagent', '-s', 'danger-full-access',
60
+ '--skip-git-repo-check', '--json', prompt], {
61
+ cwd, stdout: 'pipe', stderr: 'pipe',
62
+ });
63
+
64
+ let stdout = '', stderr = '';
65
+ try {
66
+ [stdout, stderr] = await Promise.all([
67
+ new Response(child.stdout).text().catch((e: any) => { throw new Error(`stdout 读取失败: ${e?.message || e}`); }),
68
+ new Response(child.stderr).text().catch((e: any) => { throw new Error(`stderr 读取失败: ${e?.message || e}`); }),
69
+ ]);
70
+ } catch (ioErr: any) {
71
+ try { child.kill('SIGKILL'); } catch {}
72
+ throw new Error(`codex exec I/O 异常: ${ioErr.message}`);
73
+ }
74
+
75
+ const code = await child.exited.catch(() => -1);
76
+ const { threadId, response } = processCodexStream(stdout);
77
+ if (code !== 0 || !threadId) throw new Error(`codex exec exit ${code}: ${stderr.slice(0, 300)}`);
78
+ return { threadId, response };
79
+ }
80
+
81
+ async function spawnCodexResume(cwd: string, threadId: string, prompt: string): Promise<{ response: string }> {
82
+ const child = Bun.spawn(['codex', 'exec', 'resume', threadId,
83
+ '--dangerously-bypass-approvals-and-sandbox', '-c', 'model_provider=imtoagent', '-c', 'model=gpt-5.5', '--json', '--skip-git-repo-check', prompt], {
84
+ cwd, stdout: 'pipe', stderr: 'pipe',
85
+ });
86
+
87
+ let stdout = '', stderr = '';
88
+ try {
89
+ [stdout, stderr] = await Promise.all([
90
+ new Response(child.stdout).text().catch((e: any) => { throw new Error(`stdout 读取失败: ${e?.message || e}`); }),
91
+ new Response(child.stderr).text().catch((e: any) => { throw new Error(`stderr 读取失败: ${e?.message || e}`); }),
92
+ ]);
93
+ } catch (ioErr: any) {
94
+ try { child.kill('SIGKILL'); } catch {}
95
+ throw new Error(`codex exec resume I/O 异常: ${ioErr.message}`);
96
+ }
97
+
98
+ const code = await child.exited.catch(() => -1);
99
+ if (code !== 0) throw new Error(`codex exec resume exit ${code}: ${stderr.slice(0, 300)}`);
100
+ return { response: processCodexStream(stdout).response };
101
+ }
102
+
103
+ // ================================================================
104
+ // App-Server 路径(优先)
105
+ // ================================================================
106
+
107
+ async function runViaAppServer(
108
+ cwd: string, prompt: string, chatId: string, session: Session,
109
+ isFresh: boolean
110
+ ): Promise<{ threadId: string; response: string; usage: { inputTokens: number; outputTokens: number } }> {
111
+ const manager = getAppServerManager();
112
+ const client = await manager.getClient(chatId);
113
+
114
+ const currentGen = manager.generation;
115
+ const sessionAny = session as any;
116
+ const threadExpired = sessionAny._appServerGen !== currentGen;
117
+ if (isFresh || !sessionAny.codexThreadId || threadExpired) {
118
+ sessionAny.codexThreadId = await client.startThread(cwd);
119
+ sessionAny._appServerGen = currentGen;
120
+ session.metadata.codexThreadId = sessionAny.codexThreadId;
121
+ console.log(`[CodexAdapter] app-server 全新 thread=${sessionAny.codexThreadId.slice(-8)}${threadExpired ? ' (进程重启)' : ''}`);
122
+ }
123
+
124
+ await client.sendPrompt(sessionAny.codexThreadId, prompt, cwd);
125
+
126
+ let response = '';
127
+ let totalUsage = { inputTokens: 0, outputTokens: 0 };
128
+ const startTime = Date.now();
129
+ const MAX_DURATION = 600_000; // 10 分钟
130
+
131
+ for await (const event of client.receiveEvents()) {
132
+ if (Date.now() - startTime > MAX_DURATION) {
133
+ console.error('[CodexAdapter] app-server 任务超时 (10min)');
134
+ break;
135
+ }
136
+
137
+ switch (event.type) {
138
+ case 'text_delta':
139
+ response += event.textDelta || '';
140
+ break;
141
+ case 'tool_call':
142
+ break; // tool 日志由 Runtime 层处理
143
+ case 'turn_result':
144
+ totalUsage.inputTokens += event.usage?.inputTokens || 0;
145
+ totalUsage.outputTokens += event.usage?.outputTokens || 0;
146
+ break;
147
+ case 'error':
148
+ throw new Error(`app-server 错误: ${event.error}`);
149
+ }
150
+ }
151
+
152
+ return { threadId: sessionAny.codexThreadId, response, usage: totalUsage };
153
+ }
154
+
155
+ // ================================================================
156
+ // CodexAdapter — 实现 AgentAdapter
157
+ // ================================================================
158
+
159
+ export class CodexAdapter implements AgentAdapter {
160
+ readonly name = 'Codex CLI';
161
+ private ctx: CodexAdapterContext;
162
+
163
+ constructor(ctx: CodexAdapterContext) {
164
+ this.ctx = ctx;
165
+ }
166
+
167
+ async handleMessage(input: AgentInput): Promise<AgentOutput> {
168
+ const { text, session, workingDir, systemPrompt: overrideSystemPrompt } = input;
169
+ const sessionAny = session as any;
170
+ const cwd = workingDir;
171
+
172
+ let effectiveText = text;
173
+
174
+ // 附件信息注入:让 Agent 知道用户发送了附件(图片/文件/语音)及本地路径
175
+ if (input.attachments && input.attachments.length > 0) {
176
+ effectiveText = buildAttachmentHint(input.attachments) + '\n\n---\n\n' + effectiveText;
177
+ }
178
+
179
+ if (session.codexMode === 'plan') {
180
+ effectiveText = `[模式: 先计划后执行] 请先制定一个清晰的计划,等我确认后再执行。用户请求: ${effectiveText}`;
181
+ }
182
+
183
+ const isFresh = session.startFresh || !sessionAny.codexThreadId;
184
+ session.startFresh = false;
185
+
186
+ // 优先尝试 app-server
187
+ let useExecFallback = false;
188
+ let response: string;
189
+ let execServerUsage: { inputTokens: number; outputTokens: number } | null = null;
190
+
191
+ try {
192
+ const r = await runViaAppServer(cwd, effectiveText, input.chatId, session, isFresh);
193
+ response = r.response;
194
+ execServerUsage = r.usage;
195
+ } catch (appErr: any) {
196
+ const errMsg = appErr.message || '';
197
+ console.error(`[CodexAdapter] app-server 失败: ${errMsg}`);
198
+
199
+ if (errMsg.includes('thread not found') || errMsg.includes('Thread not found')) {
200
+ try {
201
+ sessionAny.codexThreadId = undefined;
202
+ const r2 = await runViaAppServer(cwd, effectiveText, input.chatId, session, true);
203
+ response = r2.response;
204
+ execServerUsage = r2.usage;
205
+ console.error(`[CodexAdapter] app-server thread 重建成功`);
206
+ } catch {
207
+ useExecFallback = true;
208
+ }
209
+ } else {
210
+ useExecFallback = true;
211
+ }
212
+ }
213
+
214
+ if (useExecFallback) {
215
+ getAppServerManager().removeClient(input.chatId);
216
+ if (isFresh || !sessionAny.codexThreadId) {
217
+ const r = await spawnCodexExec(cwd, effectiveText);
218
+ sessionAny.codexThreadId = r.threadId;
219
+ session.metadata.codexThreadId = r.threadId;
220
+ response = r.response;
221
+ } else {
222
+ const r = await spawnCodexResume(cwd, sessionAny.codexThreadId, effectiveText);
223
+ response = r.response;
224
+ }
225
+ }
226
+
227
+ return {
228
+ text: response || '✅ 已完成',
229
+ usage: execServerUsage || undefined,
230
+ };
231
+ }
232
+ }