im-agent 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +100 -2
- package/bin/cli.js +52 -0
- package/package.json +64 -4
- package/src/agents/claude/driver.ts +149 -0
- package/src/agents/claude/errors.ts +129 -0
- package/src/agents/claude/permissions.ts +227 -0
- package/src/agents/claude/session.ts +430 -0
- package/src/agents/codex/account.ts +92 -0
- package/src/agents/codex/app-server.ts +346 -0
- package/src/agents/codex/driver.ts +183 -0
- package/src/agents/codex/errors.ts +158 -0
- package/src/agents/codex/protocol.ts +284 -0
- package/src/agents/codex/refusals.ts +62 -0
- package/src/agents/codex/session.ts +738 -0
- package/src/channels/feishu/bot.ts +609 -0
- package/src/channels/feishu/cards.ts +309 -0
- package/src/channels/feishu/client.ts +185 -0
- package/src/channels/feishu/envelope.ts +112 -0
- package/src/channels/feishu/index.ts +52 -0
- package/src/channels/feishu/message.ts +115 -0
- package/src/channels/feishu/register.ts +171 -0
- package/src/channels/feishu/turn-stream.ts +98 -0
- package/src/channels/web/api.ts +505 -0
- package/src/channels/web/auth.ts +204 -0
- package/src/channels/web/http.ts +118 -0
- package/src/channels/web/server.ts +363 -0
- package/src/cli/doctor.ts +39 -0
- package/src/cli/import.ts +155 -0
- package/src/cli/pair.ts +35 -0
- package/src/cli/passwd.ts +15 -0
- package/src/cli/service.ts +117 -0
- package/src/core/agent-env.ts +11 -0
- package/src/core/commands.ts +99 -0
- package/src/core/config.ts +227 -0
- package/src/core/dedup.ts +16 -0
- package/src/core/hub.ts +933 -0
- package/src/core/interactions.ts +261 -0
- package/src/core/pairing.ts +156 -0
- package/src/core/store.ts +174 -0
- package/src/core/types.ts +159 -0
- package/src/core/uploads.ts +200 -0
- package/src/home.ts +36 -0
- package/src/main.ts +68 -0
- package/web/app.js +1939 -0
- package/web/icon.svg +6 -0
- package/web/index.html +23 -0
- package/web/manifest.webmanifest +13 -0
- package/web/style.css +1267 -0
- package/web/sw.js +5 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// 账号额度:Codex 的 5 小时 / 7 天用量窗口。
|
|
2
|
+
//
|
|
3
|
+
// 这块是补一个认知落差:Codex 自己的 TUI 里 `/status` 会显示剩余额度,
|
|
4
|
+
// 而im-agent的 `/status` 是另一条命令(同名而已),本来只报会话和目录。
|
|
5
|
+
// 用的人不会关心这个区别,他只知道「同样敲 /status,这边少了额度」。
|
|
6
|
+
// 与其解释,不如把它显示出来 —— app-server 本来就给了。
|
|
7
|
+
import { appServer } from "./app-server.js";
|
|
8
|
+
|
|
9
|
+
type UsageWindow = {
|
|
10
|
+
usedPercent: number;
|
|
11
|
+
/** 窗口长度(分钟)。300 = 5 小时,10080 = 7 天 */
|
|
12
|
+
windowDurationMins: number;
|
|
13
|
+
/** 重置时刻,Unix 秒 */
|
|
14
|
+
resetsAt: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type RateLimits = {
|
|
18
|
+
planType?: string | null;
|
|
19
|
+
primary?: UsageWindow | null;
|
|
20
|
+
secondary?: UsageWindow | null;
|
|
21
|
+
credits?: { hasCredits?: boolean; unlimited?: boolean; balance?: string } | null;
|
|
22
|
+
/** 非空表示已经撞上限了 */
|
|
23
|
+
rateLimitReachedType?: string | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 额度是会变的,但 /status 也可能被连敲几次,缓存一小会儿。
|
|
28
|
+
* 60 秒足够挡住手抖,又不至于让人看到明显过期的数字。
|
|
29
|
+
*/
|
|
30
|
+
const CACHE_MS = 60_000;
|
|
31
|
+
let cache: { at: number; value: RateLimits | null } | null = null;
|
|
32
|
+
|
|
33
|
+
export async function readRateLimits(): Promise<RateLimits | null> {
|
|
34
|
+
if (cache && Date.now() - cache.at < CACHE_MS) return cache.value;
|
|
35
|
+
try {
|
|
36
|
+
const res = await appServer.call<{ rateLimits?: RateLimits }>(
|
|
37
|
+
"account/rateLimits/read",
|
|
38
|
+
{},
|
|
39
|
+
);
|
|
40
|
+
cache = { at: Date.now(), value: res.rateLimits ?? null };
|
|
41
|
+
} catch (err) {
|
|
42
|
+
// 读不到额度不该让整条 /status 失败 —— 它只是其中一行
|
|
43
|
+
console.error("[额度] 读取失败:", err instanceof Error ? err.message : err);
|
|
44
|
+
cache = { at: Date.now(), value: null };
|
|
45
|
+
}
|
|
46
|
+
return cache.value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 300 → 「5 小时」,10080 → 「7 天」,45 → 「45 分钟」 */
|
|
50
|
+
function windowName(mins: number): string {
|
|
51
|
+
if (mins % (60 * 24) === 0) return `${mins / (60 * 24)} 天`;
|
|
52
|
+
if (mins % 60 === 0) return `${mins / 60} 小时`;
|
|
53
|
+
return `${mins} 分钟`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 距离重置还有多久。已经过了就说「即将重置」,不显示负数。 */
|
|
57
|
+
function untilReset(resetsAt: number, now = Date.now()): string {
|
|
58
|
+
const seconds = resetsAt - Math.floor(now / 1000);
|
|
59
|
+
if (seconds <= 0) return "即将重置";
|
|
60
|
+
const days = Math.floor(seconds / 86400);
|
|
61
|
+
const hours = Math.floor((seconds % 86400) / 3600);
|
|
62
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
63
|
+
if (days > 0) return `${days} 天 ${hours} 小时后重置`;
|
|
64
|
+
if (hours > 0) return `${hours} 小时 ${minutes} 分后重置`;
|
|
65
|
+
return `${minutes} 分钟后重置`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function line(label: string, w: UsageWindow): string {
|
|
69
|
+
return ` ${label} 已用 ${w.usedPercent}% ${untilReset(w.resetsAt)}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 渲染成 /status 里的几行。没有可显示的就返回空数组,让调用方自然略过
|
|
74
|
+
* —— 与其印一行「额度:未知」,不如什么都不印。
|
|
75
|
+
*/
|
|
76
|
+
export function describeRateLimits(limits: RateLimits | null): string[] {
|
|
77
|
+
if (!limits) return [];
|
|
78
|
+
const rows: string[] = [];
|
|
79
|
+
if (limits.primary) rows.push(line(windowName(limits.primary.windowDurationMins), limits.primary));
|
|
80
|
+
if (limits.secondary) {
|
|
81
|
+
rows.push(line(windowName(limits.secondary.windowDurationMins), limits.secondary));
|
|
82
|
+
}
|
|
83
|
+
if (rows.length === 0) return [];
|
|
84
|
+
|
|
85
|
+
const credits = limits.credits;
|
|
86
|
+
if (credits?.unlimited) rows.push(" 额度包 无限");
|
|
87
|
+
else if (credits?.hasCredits && credits.balance) rows.push(` 额度包 余 ${credits.balance}`);
|
|
88
|
+
|
|
89
|
+
if (limits.rateLimitReachedType) rows.push(" ⚠️ 已达上限,等窗口重置后才能继续");
|
|
90
|
+
|
|
91
|
+
return [`额度${limits.planType ? `(${limits.planType})` : ""}`, ...rows];
|
|
92
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// 与 `codex app-server` 的 JSON-RPC 连接:一个进程,多条 thread。
|
|
2
|
+
//
|
|
3
|
+
// 这是整个im-agent的地基。
|
|
4
|
+
//
|
|
5
|
+
// 为什么不用官方的 @openai/codex-sdk:它是 `codex exec --json` 的薄封装,
|
|
6
|
+
// 事件流够用,但**没有任何审批回调** —— approvalPolicy 只是个启动参数,
|
|
7
|
+
// 没有地方接住「Codex 想跑这条命令,准不准」。用它做出来的东西没有卡片授权。
|
|
8
|
+
// app-server 才是 Codex 桌面版和 IDE 插件走的那条路:服务端能反过来向我们
|
|
9
|
+
// 发起请求,审批和提问都从那儿来。
|
|
10
|
+
//
|
|
11
|
+
// 为什么一个进程带多条 thread:app-server 的每条消息都带 threadId,天然是
|
|
12
|
+
// 多路复用的。给每条飞书会话各拉一个进程也能跑,但那样一个群开几个话题就是
|
|
13
|
+
// 几个常驻进程;共用一个进程只需要自己按 threadId 分发,便宜得多。
|
|
14
|
+
// 代价是单点故障,由下面的自动重连 + onRestart 兜住。
|
|
15
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
16
|
+
import readline from "node:readline";
|
|
17
|
+
import { config } from "../../core/config.js";
|
|
18
|
+
import { agentEnvironment } from "../../core/agent-env.js";
|
|
19
|
+
import type { Incoming, InitializeResult, RequestId } from "./protocol.js";
|
|
20
|
+
import { UnsupportedRequest } from "./refusals.js";
|
|
21
|
+
|
|
22
|
+
/** 服务端反过来向我们发起的请求。返回值会作为 result 回给它。 */
|
|
23
|
+
export type ServerRequestHandler = (
|
|
24
|
+
method: string,
|
|
25
|
+
params: unknown,
|
|
26
|
+
) => Promise<unknown>;
|
|
27
|
+
|
|
28
|
+
export type NotificationHandler = (method: string, params: unknown) => void;
|
|
29
|
+
|
|
30
|
+
/** 连接被重建时通知上层:挂着的 thread 需要重新 resume。 */
|
|
31
|
+
export type RestartHandler = (reason: string) => void;
|
|
32
|
+
|
|
33
|
+
const CLIENT_INFO = {
|
|
34
|
+
name: "im-agent",
|
|
35
|
+
title: "im-agent",
|
|
36
|
+
version: "0.1.0",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** stderr 只留最后这么多行,用来给错误诊断当线索,不无限占内存。 */
|
|
40
|
+
const STDERR_KEEP = 40;
|
|
41
|
+
|
|
42
|
+
export class AppServer {
|
|
43
|
+
private child: ChildProcessWithoutNullStreams | null = null;
|
|
44
|
+
private nextId = 0;
|
|
45
|
+
private readonly pending = new Map<
|
|
46
|
+
RequestId,
|
|
47
|
+
{ resolve: (value: unknown) => void; reject: (err: Error) => void }
|
|
48
|
+
>();
|
|
49
|
+
private readonly notificationHandlers = new Set<NotificationHandler>();
|
|
50
|
+
private readonly restartHandlers = new Set<RestartHandler>();
|
|
51
|
+
private serverRequestHandler: ServerRequestHandler | null = null;
|
|
52
|
+
private ready: Promise<InitializeResult> | null = null;
|
|
53
|
+
private recentStderr: string[] = [];
|
|
54
|
+
private stopping = false;
|
|
55
|
+
/** 连续拉起失败的次数,用来退避,避免 codex 装坏时疯狂重启刷屏。 */
|
|
56
|
+
private consecutiveFailures = 0;
|
|
57
|
+
|
|
58
|
+
onNotification(handler: NotificationHandler): () => void {
|
|
59
|
+
this.notificationHandlers.add(handler);
|
|
60
|
+
return () => this.notificationHandlers.delete(handler);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 进程一退出就通知(不等重连成功):在途的轮次得立刻收尾,不能一直显示忙 */
|
|
64
|
+
private readonly exitHandlers = new Set<(reason: string) => void>();
|
|
65
|
+
|
|
66
|
+
onExit(handler: (reason: string) => void): () => void {
|
|
67
|
+
this.exitHandlers.add(handler);
|
|
68
|
+
return () => this.exitHandlers.delete(handler);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
onRestart(handler: RestartHandler): () => void {
|
|
72
|
+
this.restartHandlers.add(handler);
|
|
73
|
+
return () => this.restartHandlers.delete(handler);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 只能有一个:审批和提问都从这儿进来,分发由上层按 threadId 做。 */
|
|
77
|
+
setServerRequestHandler(handler: ServerRequestHandler): void {
|
|
78
|
+
this.serverRequestHandler = handler;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 最近的 stderr,出错时拿去做诊断。 */
|
|
82
|
+
stderrTail(): string {
|
|
83
|
+
return this.recentStderr.join("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 拉起并完成握手。重复调用返回同一个 promise。 */
|
|
87
|
+
start(): Promise<InitializeResult> {
|
|
88
|
+
this.ready ??= this.spawnAndInitialize().catch((err) => {
|
|
89
|
+
// 握手失败要把 promise 清掉,否则这个连接永远是坏的,重试也拿到同一个 rejected
|
|
90
|
+
this.ready = null;
|
|
91
|
+
throw err;
|
|
92
|
+
});
|
|
93
|
+
return this.ready;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private async spawnAndInitialize(): Promise<InitializeResult> {
|
|
97
|
+
const child = spawn(config().agents.codex.path, ["app-server"], {
|
|
98
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
99
|
+
env: {
|
|
100
|
+
...agentEnvironment(),
|
|
101
|
+
...((config().agents.codex.home || undefined) ? { CODEX_HOME: (config().agents.codex.home || undefined) } : {}),
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
this.child = child;
|
|
105
|
+
this.recentStderr = [];
|
|
106
|
+
|
|
107
|
+
// 逐行读 JSONL。app-server 的一帧可能很大(整个 turn 的 items),
|
|
108
|
+
// readline 没有行长上限,够用。
|
|
109
|
+
readline.createInterface({ input: child.stdout }).on("line", (line) => {
|
|
110
|
+
this.handleLine(line);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
readline.createInterface({ input: child.stderr }).on("line", (line) => {
|
|
114
|
+
this.recentStderr.push(line);
|
|
115
|
+
if (this.recentStderr.length > STDERR_KEEP) this.recentStderr.shift();
|
|
116
|
+
// codex 自己的 tracing 日志噪音很大,只把明显的错误抬到我们的日志里
|
|
117
|
+
if (/\bERROR\b|^Error:/.test(line)) console.error(`[codex] ${line}`);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
child.on("exit", (code, signal) => this.handleExit(code, signal));
|
|
121
|
+
child.on("error", (err) => {
|
|
122
|
+
// spawn 本身失败(找不到 codex):这条路径上 exit 不一定会来
|
|
123
|
+
this.failAllPending(new Error(`无法启动 ${config().agents.codex.path}: ${err.message}`));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const result = (await this.request("initialize", {
|
|
127
|
+
clientInfo: CLIENT_INFO,
|
|
128
|
+
capabilities: null,
|
|
129
|
+
})) as InitializeResult;
|
|
130
|
+
|
|
131
|
+
// 握手的第二拍。少了它,服务端会一直当我们还没准备好。
|
|
132
|
+
this.notify("initialized", {});
|
|
133
|
+
this.consecutiveFailures = 0;
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private handleLine(line: string): void {
|
|
138
|
+
let frame: Incoming;
|
|
139
|
+
try {
|
|
140
|
+
frame = JSON.parse(line) as Incoming;
|
|
141
|
+
} catch {
|
|
142
|
+
// 不是 JSON 的行只可能是 codex 往 stdout 漏的东西,忽略但留个痕
|
|
143
|
+
console.warn(`[codex] stdout 收到非 JSON 行: ${line.slice(0, 200)}`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 三种帧靠 id / method 的有无区分:
|
|
148
|
+
// 有 id 有 method → 服务端向我们发起的请求(审批、提问)
|
|
149
|
+
// 有 id 无 method → 对我们某个请求的响应
|
|
150
|
+
// 无 id 有 method → 通知
|
|
151
|
+
if (frame.id !== undefined && frame.method) {
|
|
152
|
+
void this.handleServerRequest(frame.id, frame.method, frame.params);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (frame.id !== undefined) {
|
|
156
|
+
const waiter = this.pending.get(frame.id);
|
|
157
|
+
if (!waiter) return;
|
|
158
|
+
this.pending.delete(frame.id);
|
|
159
|
+
if (frame.error) waiter.reject(new Error(frame.error.message));
|
|
160
|
+
else waiter.resolve(frame.result);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (frame.method) {
|
|
164
|
+
for (const handler of this.notificationHandlers) {
|
|
165
|
+
try {
|
|
166
|
+
handler(frame.method, frame.params);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
console.error("[codex] 通知处理出错:", err);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 服务端请求必须**永远**回一帧。
|
|
176
|
+
*
|
|
177
|
+
* 漏回一次,Codex 那一轮就永远卡在等待上 —— 表现是聊天这边彻底没反应,而且
|
|
178
|
+
* 看不出哪儿坏了。所以无论如何都要应答。
|
|
179
|
+
*
|
|
180
|
+
* 但「随便回点什么」也不行:每种请求的响应结构不一样(审批要 `decision`,
|
|
181
|
+
* MCP 的 elicitation 要 `action`),回错结构等于没回。所以答不上来的时候走
|
|
182
|
+
* JSON-RPC 的 error 通道 —— 明确说不支持,好过编一个形状让对端去猜。
|
|
183
|
+
*/
|
|
184
|
+
private async handleServerRequest(
|
|
185
|
+
id: RequestId,
|
|
186
|
+
method: string,
|
|
187
|
+
params: unknown,
|
|
188
|
+
): Promise<void> {
|
|
189
|
+
if (!this.serverRequestHandler) {
|
|
190
|
+
console.warn(`[codex] 没有处理器,自动拒绝服务端请求 ${method}`);
|
|
191
|
+
this.sendError(id, -32601, `im-agent未就绪,无法处理 ${method}`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const result = await this.serverRequestHandler(method, params);
|
|
196
|
+
this.send({ jsonrpc: "2.0", id, result });
|
|
197
|
+
} catch (err) {
|
|
198
|
+
const unsupported = err instanceof UnsupportedRequest;
|
|
199
|
+
if (!unsupported) {
|
|
200
|
+
console.error(`[codex] 处理服务端请求 ${method} 出错:`, err);
|
|
201
|
+
}
|
|
202
|
+
this.sendError(
|
|
203
|
+
id,
|
|
204
|
+
unsupported ? -32601 : -32603,
|
|
205
|
+
err instanceof Error ? err.message : String(err),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private sendError(id: RequestId, code: number, message: string): void {
|
|
211
|
+
try {
|
|
212
|
+
this.send({ jsonrpc: "2.0", id, error: { code, message } });
|
|
213
|
+
} catch (err) {
|
|
214
|
+
console.error("[codex] 连错误响应都发不出去:", err);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private handleExit(code: number | null, signal: NodeJS.Signals | null): void {
|
|
219
|
+
const why = `codex app-server 退出(code=${code ?? "null"} signal=${signal ?? "null"})`;
|
|
220
|
+
this.child = null;
|
|
221
|
+
this.ready = null;
|
|
222
|
+
this.failAllPending(new Error(why));
|
|
223
|
+
for (const handler of this.exitHandlers) {
|
|
224
|
+
try {
|
|
225
|
+
handler(why);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
console.error("[codex] 退出回调出错:", err);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (this.stopping) return;
|
|
232
|
+
|
|
233
|
+
this.consecutiveFailures += 1;
|
|
234
|
+
// 退避到最多 30 秒:codex 装坏时(比如二进制缺失)无脑重启只会刷屏
|
|
235
|
+
const delay = Math.min(1000 * 2 ** (this.consecutiveFailures - 1), 30_000);
|
|
236
|
+
console.error(`[codex] ${why},${delay / 1000}s 后重连`);
|
|
237
|
+
const timer = setTimeout(() => {
|
|
238
|
+
this.start()
|
|
239
|
+
.then(() => {
|
|
240
|
+
console.log("[codex] app-server 已重新拉起");
|
|
241
|
+
for (const handler of this.restartHandlers) handler(why);
|
|
242
|
+
})
|
|
243
|
+
.catch((err) => console.error("[codex] 重连失败:", err));
|
|
244
|
+
}, delay);
|
|
245
|
+
timer.unref?.();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private failAllPending(err: Error): void {
|
|
249
|
+
for (const waiter of this.pending.values()) waiter.reject(err);
|
|
250
|
+
this.pending.clear();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private send(frame: unknown): void {
|
|
254
|
+
const child = this.child;
|
|
255
|
+
if (!child?.stdin.writable) {
|
|
256
|
+
throw new Error("codex app-server 未运行");
|
|
257
|
+
}
|
|
258
|
+
child.stdin.write(`${JSON.stringify(frame)}\n`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 发一个请求并等响应。
|
|
263
|
+
*
|
|
264
|
+
* 带超时:进程活着却不响应时,不能让调用方(以及排在它后面的整条会话操作队列)
|
|
265
|
+
* 永远等下去。turn/start 的响应只是「轮次已开始」,同样很快返回,不需要例外。
|
|
266
|
+
*/
|
|
267
|
+
request(method: string, params?: unknown, timeoutMs = 60_000): Promise<unknown> {
|
|
268
|
+
const id = ++this.nextId;
|
|
269
|
+
return new Promise((resolve, reject) => {
|
|
270
|
+
const timer = setTimeout(() => {
|
|
271
|
+
if (!this.pending.delete(id)) return;
|
|
272
|
+
reject(new Error(`codex app-server 在 ${timeoutMs / 1000} 秒内没有响应 ${method}`));
|
|
273
|
+
}, timeoutMs);
|
|
274
|
+
timer.unref?.();
|
|
275
|
+
const done = <A extends unknown[]>(fn: (...args: A) => void) => (...args: A) => {
|
|
276
|
+
clearTimeout(timer);
|
|
277
|
+
fn(...args);
|
|
278
|
+
};
|
|
279
|
+
this.pending.set(id, { resolve: done(resolve), reject: done(reject) });
|
|
280
|
+
try {
|
|
281
|
+
this.send({ jsonrpc: "2.0", id, method, params });
|
|
282
|
+
} catch (err) {
|
|
283
|
+
clearTimeout(timer);
|
|
284
|
+
this.pending.delete(id);
|
|
285
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
notify(method: string, params?: unknown): void {
|
|
291
|
+
try {
|
|
292
|
+
this.send({ jsonrpc: "2.0", method, params });
|
|
293
|
+
} catch (err) {
|
|
294
|
+
console.error(`[codex] 通知 ${method} 发送失败:`, err);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** 确保连接可用后再发请求。 */
|
|
299
|
+
async call<T>(method: string, params?: unknown): Promise<T> {
|
|
300
|
+
await this.start();
|
|
301
|
+
return (await this.request(method, params)) as T;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async stop(): Promise<void> {
|
|
305
|
+
this.stopping = true;
|
|
306
|
+
this.child?.kill();
|
|
307
|
+
this.child = null;
|
|
308
|
+
this.ready = null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** 配置(codex 路径 / CODEX_HOME)变了:杀掉重拉,挂着的 thread 通过 onRestart 重新 resume。 */
|
|
312
|
+
async restart(reason: string): Promise<void> {
|
|
313
|
+
this.stopping = true;
|
|
314
|
+
const child = this.child;
|
|
315
|
+
this.child = null;
|
|
316
|
+
this.ready = null;
|
|
317
|
+
// 旧进程的 exit 不能再触发自动重连,否则会把新拉起的连接冲掉
|
|
318
|
+
child?.removeAllListeners("exit");
|
|
319
|
+
child?.kill();
|
|
320
|
+
this.failAllPending(new Error(reason));
|
|
321
|
+
// 主动重启同样是「旧连接失效」:在途轮次要收尾、thread 要标记为待恢复
|
|
322
|
+
for (const handler of this.exitHandlers) {
|
|
323
|
+
try {
|
|
324
|
+
handler(reason);
|
|
325
|
+
} catch (err) {
|
|
326
|
+
console.error("[codex] 退出回调出错:", err);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
this.stopping = false;
|
|
330
|
+
await this.start();
|
|
331
|
+
for (const handler of this.restartHandlers) handler(reason);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
get running(): boolean {
|
|
335
|
+
return !!this.child;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* 全局唯一的连接。
|
|
341
|
+
*
|
|
342
|
+
* 所有会话共用它,thread 之间靠 threadId 区分。单点故障由上面的自动重连
|
|
343
|
+
* 和 onRestart 兜住:thread 是落在 ~/.codex/sessions 里的,重连后 resume 回来
|
|
344
|
+
* 就行,上下文不丢。
|
|
345
|
+
*/
|
|
346
|
+
export const appServer = new AppServer();
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Codex 驱动:官方 `codex app-server`(Codex 桌面版和 IDE 插件走的那条路)。
|
|
2
|
+
//
|
|
3
|
+
// 不用 @openai/codex-sdk:它是 `codex exec --json` 的薄封装,没有任何审批回调。
|
|
4
|
+
import { appServer } from "./app-server.js";
|
|
5
|
+
import { describeRateLimits, readRateLimits } from "./account.js";
|
|
6
|
+
import { diagnose } from "./errors.js";
|
|
7
|
+
import { CodexSession, summarize } from "./session.js";
|
|
8
|
+
import { config, onConfigChange } from "../../core/config.js";
|
|
9
|
+
import type {
|
|
10
|
+
AgentDriver,
|
|
11
|
+
AgentEvents,
|
|
12
|
+
AgentSession,
|
|
13
|
+
HistoryMessage,
|
|
14
|
+
InteractionHost,
|
|
15
|
+
ModelInfo,
|
|
16
|
+
RuntimeInfo,
|
|
17
|
+
SessionOptions,
|
|
18
|
+
SessionSummary,
|
|
19
|
+
} from "../../core/types.js";
|
|
20
|
+
import type { ModelListResponse, Thread, ThreadListResponse } from "./protocol.js";
|
|
21
|
+
|
|
22
|
+
function titleOf(thread: Thread): string {
|
|
23
|
+
const name = thread.name?.trim();
|
|
24
|
+
if (name) return name;
|
|
25
|
+
const preview = thread.preview?.replace(/\s+/g, " ").trim();
|
|
26
|
+
if (!preview) return "(无标题)";
|
|
27
|
+
return preview.length > 60 ? `${preview.slice(0, 60)}…` : preview;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type HistoryItem = {
|
|
31
|
+
type: string;
|
|
32
|
+
text?: string;
|
|
33
|
+
phase?: string | null;
|
|
34
|
+
command?: string;
|
|
35
|
+
content?: { type: string; text?: string; path?: string }[];
|
|
36
|
+
changes?: { path: string }[];
|
|
37
|
+
server?: string;
|
|
38
|
+
tool?: string;
|
|
39
|
+
query?: string;
|
|
40
|
+
summary?: string[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export class CodexDriver implements AgentDriver {
|
|
44
|
+
readonly id = "codex";
|
|
45
|
+
readonly label = "Codex";
|
|
46
|
+
readonly capabilities = { effort: true, fork: true, cost: false, streaming: true };
|
|
47
|
+
private unsubscribe: (() => void) | null = null;
|
|
48
|
+
|
|
49
|
+
async start(): Promise<void> {
|
|
50
|
+
const info = await appServer.start();
|
|
51
|
+
console.log(`[codex] app-server 就绪,CODEX_HOME=${info.codexHome ?? "(默认)"}`);
|
|
52
|
+
// 换了 codex 路径或 CODEX_HOME 就重拉 app-server
|
|
53
|
+
this.unsubscribe = onConfigChange((next, prev) => {
|
|
54
|
+
const a = next.agents.codex;
|
|
55
|
+
const b = prev.agents.codex;
|
|
56
|
+
if (a.path === b.path && a.home === b.home) return;
|
|
57
|
+
if (!a.enabled) return;
|
|
58
|
+
void appServer
|
|
59
|
+
.restart("codex 配置已变更")
|
|
60
|
+
.then(() => console.log("[codex] 配置变更,app-server 已重启"))
|
|
61
|
+
.catch((err) => console.error("[codex] 重启 app-server 失败:", err));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async stop(): Promise<void> {
|
|
66
|
+
this.unsubscribe?.();
|
|
67
|
+
await appServer.stop();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
createSession(opts: SessionOptions, events: AgentEvents, host: InteractionHost): AgentSession {
|
|
71
|
+
return new CodexSession(opts, events, host);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async listSessions(cwd: string | undefined, limit = 10): Promise<SessionSummary[]> {
|
|
75
|
+
try {
|
|
76
|
+
const res = await appServer.call<ThreadListResponse>("thread/list", {
|
|
77
|
+
...(cwd ? { cwd } : {}),
|
|
78
|
+
limit,
|
|
79
|
+
sortKey: "updated_at",
|
|
80
|
+
sortDirection: "desc",
|
|
81
|
+
});
|
|
82
|
+
return (res.data ?? []).map((thread) => ({
|
|
83
|
+
id: thread.id,
|
|
84
|
+
title: titleOf(thread),
|
|
85
|
+
...(thread.cwd ? { cwd: thread.cwd } : {}),
|
|
86
|
+
updatedAt: ((thread.updatedAt ?? thread.createdAt) || 0) * 1000,
|
|
87
|
+
}));
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error("[codex] 读取会话列表失败:", err);
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async sessionExists(id: string): Promise<boolean> {
|
|
95
|
+
try {
|
|
96
|
+
await appServer.call("thread/read", { threadId: id, includeTurns: false });
|
|
97
|
+
return true;
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async readHistory(id: string): Promise<HistoryMessage[]> {
|
|
104
|
+
const res = await appServer.call<{ thread: { turns?: { items?: HistoryItem[]; startedAt?: number | null }[] } }>(
|
|
105
|
+
"thread/read",
|
|
106
|
+
{ threadId: id, includeTurns: true },
|
|
107
|
+
);
|
|
108
|
+
const out: HistoryMessage[] = [];
|
|
109
|
+
for (const turn of res.thread.turns ?? []) {
|
|
110
|
+
let assistant: HistoryMessage | null = null;
|
|
111
|
+
const ts = turn.startedAt ? turn.startedAt * 1000 : undefined;
|
|
112
|
+
for (const item of turn.items ?? []) {
|
|
113
|
+
if (item.type === "userMessage") {
|
|
114
|
+
const text = (item.content ?? [])
|
|
115
|
+
.map((c) => (c.type === "text" ? (c.text ?? "") : c.type === "localImage" ? "📎 图片" : ""))
|
|
116
|
+
.filter(Boolean)
|
|
117
|
+
.join("\n");
|
|
118
|
+
const cut = text.indexOf("[用户");
|
|
119
|
+
out.push({ role: "user", text: cut > 0 ? text.slice(0, cut).trim() || text : text, ...(ts ? { ts } : {}) });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
assistant ??= { role: "assistant", text: "", steps: [] };
|
|
123
|
+
if (item.type === "agentMessage" && item.text?.trim()) {
|
|
124
|
+
if (item.phase === "commentary") assistant.steps!.push(`💬 ${summarize(item.text)}`);
|
|
125
|
+
else assistant.text += (assistant.text ? "\n\n" : "") + item.text;
|
|
126
|
+
} else if (item.type === "commandExecution" && item.command) {
|
|
127
|
+
assistant.steps!.push(`🔧 ${summarize(item.command, 70)}`);
|
|
128
|
+
} else if (item.type === "fileChange" && item.changes?.length) {
|
|
129
|
+
assistant.steps!.push(`📝 ${summarize(item.changes.map((c) => c.path).join("、"), 90)}`);
|
|
130
|
+
} else if (item.type === "mcpToolCall") {
|
|
131
|
+
assistant.steps!.push(`🔌 ${item.server}.${item.tool}`);
|
|
132
|
+
} else if (item.type === "webSearch") {
|
|
133
|
+
assistant.steps!.push(`🔍 ${summarize(item.query ?? "", 60)}`);
|
|
134
|
+
} else if (item.type === "reasoning" && item.summary?.length) {
|
|
135
|
+
assistant.steps!.push(`💭 ${summarize(item.summary.join(" "))}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (assistant && (assistant.text || assistant.steps?.length)) out.push(assistant);
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async listModels(): Promise<ModelInfo[]> {
|
|
144
|
+
const res = await appServer.call<ModelListResponse>("model/list", {});
|
|
145
|
+
return (res.data ?? [])
|
|
146
|
+
.filter((m) => !m.hidden)
|
|
147
|
+
.map((m) => ({
|
|
148
|
+
id: m.model,
|
|
149
|
+
label: m.displayName || m.model,
|
|
150
|
+
...(m.description ? { description: m.description } : {}),
|
|
151
|
+
...(m.supportedReasoningEfforts?.length
|
|
152
|
+
? { efforts: m.supportedReasoningEfforts.map((e) => e.reasoningEffort) }
|
|
153
|
+
: {}),
|
|
154
|
+
...(m.isDefault ? { isDefault: true } : {}),
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** config.toml 里的默认值(config/read),不需要为此建 thread。 */
|
|
159
|
+
async defaults(): Promise<RuntimeInfo> {
|
|
160
|
+
const c = config().agents.codex;
|
|
161
|
+
try {
|
|
162
|
+
const res = await appServer.call<{
|
|
163
|
+
config?: { model?: string | null; model_reasoning_effort?: string | null };
|
|
164
|
+
}>("config/read", {});
|
|
165
|
+
const fromToml = res.config ?? {};
|
|
166
|
+
const model = c.defaultModel || fromToml.model || undefined;
|
|
167
|
+
const effort = c.defaultEffort || fromToml.model_reasoning_effort || undefined;
|
|
168
|
+
return { ...(model ? { model } : {}), ...(effort ? { effort } : {}) };
|
|
169
|
+
} catch (err) {
|
|
170
|
+
console.error("[codex] 读取默认配置失败:", err);
|
|
171
|
+
return {};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async statusLines(): Promise<string[]> {
|
|
176
|
+
const quota = describeRateLimits(await readRateLimits());
|
|
177
|
+
return [`沙箱:${config().agents.codex.sandbox}`, ...(quota.length > 0 ? ["", ...quota] : [])];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
diagnose(err: unknown) {
|
|
181
|
+
return diagnose(err);
|
|
182
|
+
}
|
|
183
|
+
}
|