agent-loft 0.2.33 → 0.2.35

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.
@@ -0,0 +1,154 @@
1
+ // 网页终端的接口:REST(/api/terminals)开、列、关终端;WebSocket(/ws/term?id=…&since=…)收发。
2
+ // 一个终端一条 WebSocket,和会话的 /ws 分开:终端的输出量大,别挤占会话事件。shell 的管理见 core/terminals.ts。
3
+ //
4
+ // 页面 → 服务端:{ t: "i", d } 输入,{ t: "r", c, r } 改大小,{ t: "ping" }
5
+ // 服务端 → 页面:{ t: "a", reset, d, total } 连上时补发,{ t: "o", d } 输出,{ t: "x", code, reason } 结束了
6
+ import type { IncomingMessage, ServerResponse } from "node:http";
7
+ import type { Duplex } from "node:stream";
8
+ import type { WebSocket, WebSocketServer } from "ws";
9
+ import { config } from "../../core/config.js";
10
+ import {
11
+ attachTerminal,
12
+ canUseTerminal,
13
+ createTerminal,
14
+ detachTerminal,
15
+ getTerminal,
16
+ killTerminal,
17
+ listTerminals,
18
+ resizeTerminal,
19
+ terminalAvailability,
20
+ writeTerminal,
21
+ type TermPeer,
22
+ } from "../../core/terminals.js";
23
+ import type { User } from "../../core/users.js";
24
+ import { sessionUser } from "./auth.js";
25
+ import { HttpError, readJsonBody, sendJson } from "./http.js";
26
+
27
+ /** 页面要知道的:能不能用、为什么不能用、空闲多久结束 */
28
+ export function terminalStatus(user: User) {
29
+ const { available, reason } = terminalAvailability();
30
+ return {
31
+ enabled: config().remote.terminal,
32
+ allowed: canUseTerminal(user),
33
+ available,
34
+ ...(reason && canUseTerminal(user) ? { reason } : {}),
35
+ keepAliveMin: config().remote.keepAliveMin,
36
+ };
37
+ }
38
+
39
+ export async function handleTerminalApi(req: IncomingMessage, res: ServerResponse, p: string, user: User): Promise<boolean> {
40
+ const method = req.method ?? "GET";
41
+ if (p === "/api/terminals" && method === "GET") {
42
+ sendJson(res, 200, { ...terminalStatus(user), terminals: canUseTerminal(user) ? listTerminals(user.id) : [] });
43
+ return true;
44
+ }
45
+ if (!canUseTerminal(user)) throw new HttpError(403, "你没有使用终端的权限,找管理员在 设置 → 用户 里开通");
46
+ if (p === "/api/terminals" && method === "POST") {
47
+ const body = await readJsonBody<{ cwd?: unknown; cols?: unknown; rows?: unknown }>(req);
48
+ try {
49
+ sendJson(res, 200, createTerminal(user, body));
50
+ } catch (err) {
51
+ throw new HttpError(400, err instanceof Error ? err.message : String(err));
52
+ }
53
+ return true;
54
+ }
55
+ const m = /^\/api\/terminals\/([A-Za-z0-9_]+)$/.exec(p);
56
+ if (m && method === "DELETE") {
57
+ if (!getTerminal(m[1]!, user.id)) throw new HttpError(404, "终端不存在(可能已经结束了)");
58
+ killTerminal(m[1]!);
59
+ sendJson(res, 200, { ok: true });
60
+ return true;
61
+ }
62
+ return false;
63
+ }
64
+
65
+ // ---------- WebSocket ----------
66
+
67
+ type TermClient = { ws: WebSocket; peer: TermPeer; id: string; token: string; alive: boolean };
68
+ const clients = new Set<TermClient>();
69
+
70
+ /** 页面收得太慢(网太差):攒着的超过这么多就断开它,重连时从输出记录里补 */
71
+ const MAX_BUFFERED = 8 * 1024 * 1024;
72
+
73
+ function drop(client: TermClient): void {
74
+ if (!clients.delete(client)) return;
75
+ detachTerminal(client.id, client.peer);
76
+ }
77
+
78
+ /** 登录态还有效、并且还能用终端;否则断开(4401 让页面回登录页) */
79
+ function stillAllowed(client: TermClient): boolean {
80
+ const user = sessionUser(client.token);
81
+ if (user && canUseTerminal(user)) return true;
82
+ client.ws.close(user ? 4403 : 4401, user ? "终端权限已被收回" : "登录已失效");
83
+ drop(client);
84
+ return false;
85
+ }
86
+
87
+ /** /ws/term 的握手:user 已经由调用方核对过登录和 Origin */
88
+ export function upgradeTerminal(wss: WebSocketServer, req: IncomingMessage, socket: Duplex, head: Buffer, user: User, token: string, url: URL): void {
89
+ const id = url.searchParams.get("id") ?? "";
90
+ if (!canUseTerminal(user) || !getTerminal(id, user.id)) {
91
+ socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
92
+ socket.destroy();
93
+ return;
94
+ }
95
+ const since = Number(url.searchParams.get("since") ?? "-1");
96
+ wss.handleUpgrade(req, socket, head, (ws) => {
97
+ const peer: TermPeer = {
98
+ send: (msg) => {
99
+ if (ws.readyState !== ws.OPEN) return;
100
+ if (ws.bufferedAmount > MAX_BUFFERED) return ws.terminate();
101
+ ws.send(JSON.stringify(msg));
102
+ },
103
+ close: (code, reason) => ws.close(code, reason),
104
+ };
105
+ const client: TermClient = { ws, peer, id, token, alive: true };
106
+ // 握手到这里的一小会儿里终端可能刚好结束了
107
+ if (!attachTerminal(id, user.id, peer, since)) {
108
+ ws.close(4000, "终端已结束");
109
+ return;
110
+ }
111
+ clients.add(client);
112
+ ws.on("pong", () => (client.alive = true));
113
+ ws.on("message", (data) => {
114
+ let msg: { t?: unknown; d?: unknown; c?: unknown; r?: unknown };
115
+ try {
116
+ msg = JSON.parse(data.toString());
117
+ } catch {
118
+ return;
119
+ }
120
+ if (typeof msg !== "object" || msg === null || !stillAllowed(client)) return;
121
+ if (msg.t === "i" && typeof msg.d === "string") writeTerminal(id, msg.d);
122
+ else if (msg.t === "r") resizeTerminal(id, msg.c, msg.r);
123
+ else if (msg.t === "ping") peer.send({ t: "pong" });
124
+ });
125
+ ws.on("close", () => drop(client));
126
+ ws.on("error", () => drop(client));
127
+ });
128
+ }
129
+
130
+ /** 退出登录、被踢、权限收回:把还连着的断开 */
131
+ export function recheckTerminalClients(): void {
132
+ for (const client of [...clients]) stillAllowed(client);
133
+ }
134
+
135
+ /** 心跳:清掉死连接(服务端 30 秒一次,和 /ws 一起) */
136
+ export function pingTerminalClients(): void {
137
+ for (const client of [...clients]) {
138
+ if (!stillAllowed(client)) continue;
139
+ if (!client.alive) {
140
+ client.ws.terminate();
141
+ drop(client);
142
+ continue;
143
+ }
144
+ client.alive = false;
145
+ client.ws.ping();
146
+ }
147
+ }
148
+
149
+ export function closeTerminalClients(): void {
150
+ for (const client of [...clients]) {
151
+ client.ws.close(1001, "服务重启");
152
+ drop(client);
153
+ }
154
+ }
@@ -72,6 +72,23 @@ export type AppConfig = {
72
72
  };
73
73
  /** 输入「/」时命令列表的顺序和隐藏项,按 agent 分开(设置 → 命令) */
74
74
  commands: { claude: CommandPrefs; codex: CommandPrefs };
75
+ /** 远程控制:网页终端和远程桌面。管理员能用;成员要在 设置 → 用户 里单独开(users.json 的 remote) */
76
+ remote: {
77
+ terminal: boolean;
78
+ desktop: boolean;
79
+ /** 没有页面连着多久后结束 shell、停掉共享桌面用的 x11vnc(分钟) */
80
+ keepAliveMin: number;
81
+ /**
82
+ * 桌面从哪来。auto:Linux 上用 x11vnc 共享这台机器当前的图形桌面(按需起、只听本机);
83
+ * vnc:连一个已有的 VNC 服务,比如 macOS 的屏幕共享(127.0.0.1:5900)
84
+ */
85
+ desktopSource: "auto" | "vnc";
86
+ /** auto 时用哪个 X 显示,留空自动找(如 :0、:1) */
87
+ display: string;
88
+ vncHost: string;
89
+ vncPort: number;
90
+ vncPassword: string;
91
+ };
75
92
  feishu: {
76
93
  /** 长连接掉线多久就重建,0 关闭 */
77
94
  watchdogMs: number;
@@ -100,6 +117,16 @@ export function defaultConfig(): AppConfig {
100
117
  },
101
118
  web: { enabled: true, host: "0.0.0.0", port: 8800, passwordHash: "", trustedProxies: ["127.0.0.1", "::1"], quotaRefreshMin: 10, quotaEnabled: true },
102
119
  commands: { claude: { order: [], hidden: [], custom: [] }, codex: { order: [], hidden: [], custom: [] } },
120
+ remote: {
121
+ terminal: true,
122
+ desktop: true,
123
+ keepAliveMin: 30,
124
+ desktopSource: process.platform === "darwin" ? "vnc" : "auto",
125
+ display: "",
126
+ vncHost: "127.0.0.1",
127
+ vncPort: 5900,
128
+ vncPassword: "",
129
+ },
103
130
  feishu: { watchdogMs: 10 * 60 * 1000, bots: [] },
104
131
  };
105
132
  }
@@ -0,0 +1,281 @@
1
+ // 远程桌面:把这台机器的图形桌面通过 VNC 给到网页(noVNC)。agent-loft 只做两件事:
2
+ // - 找到 VNC 服务:Linux 上按需起一个 x11vnc 共享当前桌面(只听 127.0.0.1、随机端口、每次随机密码),
3
+ // 没人连着 keepAliveMin 分钟后停掉;或者用配置里的现成 VNC 服务(macOS 的屏幕共享)。
4
+ // - 把页面的 WebSocket 原样转给它(见 channels/web/desktop.ts)。画面编码、鼠标键盘、剪贴板都是 VNC 协议自己的。
5
+ //
6
+ // 这台机器没有图形桌面(服务器、NAS)时远程桌面不可用,远程控制里就只有终端。
7
+ // 权限和终端一样(canUseRemote):拿到桌面就能以登录桌面的那个人的身份做任何事。
8
+ import { spawn, type ChildProcess } from "node:child_process";
9
+ import crypto from "node:crypto";
10
+ import fs from "node:fs";
11
+ import net from "node:net";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { EventEmitter } from "node:events";
15
+ import { home } from "../home.js";
16
+ import { config } from "./config.js";
17
+ import { canUseRemote, type User } from "./users.js";
18
+
19
+ export function canUseDesktop(user: Pick<User, "role" | "disabled" | "remote"> | undefined): boolean {
20
+ return config().remote.desktop && canUseRemote(user);
21
+ }
22
+
23
+ // ---------- 找桌面 ----------
24
+
25
+ function which(cmd: string): string | undefined {
26
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter).concat(["/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"])) {
27
+ const file = path.join(dir, cmd);
28
+ try {
29
+ fs.accessSync(file, fs.constants.X_OK);
30
+ return file;
31
+ } catch {
32
+ // 下一个
33
+ }
34
+ }
35
+ return undefined;
36
+ }
37
+
38
+ const X_SOCKETS = "/tmp/.X11-unix";
39
+
40
+ /** 现在有哪些 X 显示(:0、:1 …),按编号排 */
41
+ function xDisplays(): string[] {
42
+ try {
43
+ return fs
44
+ .readdirSync(X_SOCKETS)
45
+ .map((f) => /^X(\d+)$/.exec(f)?.[1])
46
+ .filter((n): n is string => n !== undefined)
47
+ .sort((a, b) => Number(a) - Number(b))
48
+ .map((n) => `:${n}`);
49
+ } catch {
50
+ return [];
51
+ }
52
+ }
53
+
54
+ /**
55
+ * 用哪个显示:配置里写了就用它;否则环境变量里的 DISPLAY(前台运行时);
56
+ * 再不然取编号最大的那个 —— gdm 的登录界面通常是 :0,登录后的桌面排在后面。
57
+ */
58
+ function pickDisplay(): string | undefined {
59
+ const all = xDisplays();
60
+ const wanted = config().remote.display.trim();
61
+ if (wanted) return all.includes(wanted.replace(/\.\d$/, "")) ? wanted : undefined;
62
+ const env = process.env.DISPLAY?.trim();
63
+ if (env && all.includes(env.replace(/\.\d$/, ""))) return env;
64
+ return all[all.length - 1];
65
+ }
66
+
67
+ /** X 的授权文件:系统服务下没有 XAUTHORITY 环境变量,按常见位置找;都没有交给 x11vnc 自己猜 */
68
+ function xAuthority(): string {
69
+ const uid = os.userInfo().uid;
70
+ const candidates = [process.env.XAUTHORITY, `/run/user/${uid}/gdm/Xauthority`, path.join(os.homedir(), ".Xauthority")];
71
+ return candidates.find((f): f is string => !!f && fs.existsSync(f)) ?? "guess";
72
+ }
73
+
74
+ export type DesktopStatus = { available: boolean; reason?: string; source: "auto" | "vnc"; display?: string };
75
+
76
+ /** 远程桌面能不能用。不能用时 reason 写给管理员看(成员看到的只是「没有桌面」) */
77
+ export function desktopAvailability(): DesktopStatus {
78
+ const cfg = config().remote;
79
+ if (cfg.desktopSource === "vnc") return { available: true, source: "vnc" };
80
+ if (process.platform !== "linux") {
81
+ return {
82
+ available: false,
83
+ source: "auto",
84
+ reason:
85
+ process.platform === "darwin"
86
+ ? "macOS 上请改用「已有的 VNC 服务」:打开 系统设置 → 通用 → 共享 → 屏幕共享,在「电脑设置」里允许 VNC 用密码控制,再把密码填到这里"
87
+ : "这个系统上没法自动共享桌面,改用「已有的 VNC 服务」",
88
+ };
89
+ }
90
+ const display = pickDisplay();
91
+ if (!display) {
92
+ return {
93
+ available: false,
94
+ source: "auto",
95
+ reason: cfg.display ? `找不到显示 ${cfg.display}` : "这台机器没有图形桌面(没有运行中的 X 显示)。Wayland 桌面也共享不了,登录时选「Xorg」会话",
96
+ };
97
+ }
98
+ if (!which("x11vnc")) return { available: false, source: "auto", display, reason: "没装 x11vnc:sudo apt install x11vnc(或 dnf install x11vnc)" };
99
+ return { available: true, source: "auto", display };
100
+ }
101
+
102
+ // ---------- x11vnc ----------
103
+
104
+ type Vnc = { proc: ChildProcess; port: number; password: string; display: string; ready: Promise<void> };
105
+
106
+ let vnc: Vnc | null = null;
107
+ /** 正在起 x11vnc(找空闲端口是异步的):同时来的几个请求等同一个,别起两份 */
108
+ let starting: Promise<Vnc> | null = null;
109
+ /** 每停一次加一:起到一半时设置改了(停过了),起好的这份就不要了 */
110
+ let generation = 0;
111
+ /** 连着桌面的页面数:到 0 开始计时,keepAliveMin 分钟后停掉 x11vnc */
112
+ let viewers = 0;
113
+ let idleTimer: NodeJS.Timeout | null = null;
114
+ export const desktopEvents = new EventEmitter<{ stopped: [] }>();
115
+
116
+ function freePort(): Promise<number> {
117
+ return new Promise((resolve, reject) => {
118
+ const srv = net.createServer();
119
+ srv.unref();
120
+ srv.on("error", reject);
121
+ srv.listen(0, "127.0.0.1", () => {
122
+ const port = (srv.address() as net.AddressInfo).port;
123
+ srv.close(() => resolve(port));
124
+ });
125
+ });
126
+ }
127
+
128
+ /** 上一次 agent-loft 被强杀(没机会收尾)时留下的 x11vnc:按记下的 pid 停掉 */
129
+ function killStale(runDir: string): void {
130
+ const pidFile = path.join(runDir, "x11vnc.pid");
131
+ let pid = 0;
132
+ try {
133
+ pid = Number(fs.readFileSync(pidFile, "utf8").trim());
134
+ } catch {
135
+ return;
136
+ }
137
+ fs.rmSync(pidFile, { force: true });
138
+ if (!Number.isInteger(pid) || pid <= 1) return;
139
+ try {
140
+ // 只认我们自己起的那种:命令行里有 x11vnc 和我们的 run 目录(pid 可能已经被别的进程复用了)
141
+ const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8");
142
+ if (cmdline.includes("x11vnc") && cmdline.includes(runDir)) process.kill(pid);
143
+ } catch {
144
+ // 已经没了
145
+ }
146
+ }
147
+
148
+ async function startX11vnc(display: string): Promise<Vnc> {
149
+ const runDir = path.join(home, "run");
150
+ fs.mkdirSync(runDir, { recursive: true, mode: 0o700 });
151
+ killStale(runDir);
152
+ const port = await freePort();
153
+ // VNC 密码最多 8 个字符。只在本机能连,又是每次新起的,够了
154
+ const password = crypto.randomBytes(6).toString("base64url").slice(0, 8);
155
+ const pwFile = path.join(runDir, `x11vnc-${crypto.randomBytes(4).toString("hex")}.pw`);
156
+ // 密码放文件里(命令行参数别的用户用 ps 就能看到),x11vnc 读完就删(rm: 前缀)
157
+ fs.writeFileSync(pwFile, `${password}\n`, { mode: 0o600 });
158
+ const args = [
159
+ "-display", display,
160
+ "-auth", xAuthority(),
161
+ "-localhost",
162
+ "-rfbport", String(port),
163
+ "-passwdfile", `rm:${pwFile}`,
164
+ "-forever",
165
+ "-shared",
166
+ "-xkb",
167
+ "-noxdamage",
168
+ "-quiet",
169
+ ];
170
+ const proc = spawn(which("x11vnc")!, args, { stdio: ["ignore", "pipe", "pipe"] });
171
+ if (proc.pid) fs.writeFileSync(path.join(runDir, "x11vnc.pid"), String(proc.pid));
172
+ let stderr = "";
173
+ proc.stderr!.on("data", (d: Buffer) => (stderr = (stderr + d.toString()).slice(-4000)));
174
+ const ready = new Promise<void>((resolve, reject) => {
175
+ const timer = setTimeout(() => {
176
+ proc.kill();
177
+ reject(new Error("x11vnc 10 秒内没有启动起来"));
178
+ }, 10_000);
179
+ // 起不来(没权限执行之类):spawn 自己报错,不会有 exit
180
+ proc.on("error", (err) => {
181
+ clearTimeout(timer);
182
+ fs.rmSync(pwFile, { force: true });
183
+ reject(new Error(`x11vnc 起不来:${err.message}`));
184
+ });
185
+ // 准备好了会在标准输出打一行 PORT=xxxx
186
+ proc.stdout!.on("data", (d: Buffer) => {
187
+ if (/PORT=\d+/.test(d.toString())) {
188
+ clearTimeout(timer);
189
+ resolve();
190
+ }
191
+ });
192
+ proc.on("exit", (code) => {
193
+ clearTimeout(timer);
194
+ fs.rmSync(pwFile, { force: true });
195
+ const why = stderr.split("\n").find((l) => /fail|error|unable|cannot|refused/i.test(l))?.replace(/^\S+ \S+ /, "");
196
+ reject(new Error(`x11vnc 退出了(${code})${why ? `:${why}` : ""}`));
197
+ });
198
+ });
199
+ const v: Vnc = { proc, port, password, display, ready };
200
+ proc.on("exit", () => {
201
+ // 只删自己的:可能已经换上新的那份、pid 文件里记的是它
202
+ const pidFile = path.join(runDir, "x11vnc.pid");
203
+ try {
204
+ if (fs.readFileSync(pidFile, "utf8").trim() === String(proc.pid)) fs.rmSync(pidFile, { force: true });
205
+ } catch {
206
+ // 没有就算了
207
+ }
208
+ if (vnc !== v) return;
209
+ vnc = null;
210
+ console.log("[桌面] x11vnc 已停止");
211
+ desktopEvents.emit("stopped");
212
+ });
213
+ ready.then(() => console.log(`[桌面] 已在 ${display} 上起 x11vnc(127.0.0.1:${port})`), () => {});
214
+ return v;
215
+ }
216
+
217
+ export type DesktopTarget = { host: string; port: number; password: string };
218
+
219
+ /** 要连桌面了:拿到 VNC 服务的地址和密码,必要时先把 x11vnc 起来 */
220
+ export async function desktopTarget(): Promise<DesktopTarget> {
221
+ const status = desktopAvailability();
222
+ if (!status.available) throw new Error(status.reason ?? "这台机器上没有可用的桌面");
223
+ const cfg = config().remote;
224
+ if (status.source === "vnc") return { host: cfg.vncHost, port: cfg.vncPort, password: cfg.vncPassword };
225
+ // 换了显示(配置改了):旧的停掉
226
+ if (vnc && vnc.display !== status.display) stopDesktop();
227
+ const gen = generation;
228
+ if (!vnc) {
229
+ starting ??= startX11vnc(status.display!).finally(() => (starting = null));
230
+ const started = await starting;
231
+ // 等的这会儿里被停过(设置改了、服务在停):这一份也不要了
232
+ if (gen !== generation) {
233
+ started.proc.kill();
234
+ throw new Error("远程桌面的设置刚改过,再连一次");
235
+ }
236
+ vnc ??= started;
237
+ if (vnc !== started) started.proc.kill();
238
+ }
239
+ const current = vnc;
240
+ try {
241
+ await current.ready;
242
+ } catch (err) {
243
+ if (vnc === current) vnc = null;
244
+ // 没起好就被停了(设置改了):说清楚,别让人以为 x11vnc 坏了
245
+ if (gen !== generation) throw new Error("远程桌面的设置刚改过,再连一次");
246
+ throw err;
247
+ }
248
+ armIdle();
249
+ return { host: "127.0.0.1", port: current.port, password: current.password };
250
+ }
251
+
252
+ /** 页面连上 / 断开桌面:没人连着了就开始计时 */
253
+ export function desktopViewer(delta: 1 | -1): void {
254
+ viewers = Math.max(0, viewers + delta);
255
+ armIdle();
256
+ }
257
+
258
+ function armIdle(): void {
259
+ if (idleTimer) clearTimeout(idleTimer);
260
+ idleTimer = null;
261
+ if (viewers > 0 || !vnc) return;
262
+ idleTimer = setTimeout(() => stopDesktop(), Math.max(1, config().remote.keepAliveMin) * 60_000);
263
+ idleTimer.unref();
264
+ }
265
+
266
+ export function stopDesktop(): void {
267
+ generation++;
268
+ if (idleTimer) clearTimeout(idleTimer);
269
+ idleTimer = null;
270
+ const v = vnc;
271
+ if (!v) return;
272
+ vnc = null;
273
+ v.proc.kill();
274
+ console.log("[桌面] 已停掉 x11vnc");
275
+ desktopEvents.emit("stopped");
276
+ }
277
+
278
+ /** 是不是我们自己起的 x11vnc 在跑(给测试和状态页用) */
279
+ export function desktopRunning(): boolean {
280
+ return !!vnc;
281
+ }
package/src/core/hub.ts CHANGED
@@ -649,7 +649,13 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
649
649
  if (prev?.used !== ctx.used || prev?.window !== ctx.window) this.emitEvent(key, { type: "meta" });
650
650
  },
651
651
  // 不用另发 meta:一轮结束的 result / error 会把会话信息(含用量)推给页面
652
- onUsage: (usage, cumulative) => alive() && recordUsage(holder.session?.sessionId, usage, cumulative),
652
+ onUsage: (usage, cumulative) => {
653
+ if (!alive()) return;
654
+ // 模型用来按单价估算没报费用的 agent(Codex)的金额
655
+ const current = getConversation(key);
656
+ const model = current ? this.displayRuntime(current, agentBinding(current, agent)).model : undefined;
657
+ recordUsage(holder.session?.sessionId, usage, cumulative, { owner: current?.owner, agent, model });
658
+ },
653
659
  onDropped: (n) => {
654
660
  if (!alive() || n <= 0) return;
655
661
  // 丢的是排在最后、还没开始的那 n 条(Codex 先报正在跑的那轮出错、再报丢弃,那时队头已经取走了)
@@ -0,0 +1,103 @@
1
+ {
2
+ "chat-latest": [5,0.5,30],
3
+ "computer-use-preview": [3,null,12],
4
+ "computer-use-preview-2025-03-11": [3,null,12],
5
+ "daybreak-blue-latest": [4,0.4,20],
6
+ "daybreak-red-latest": [12.5,1.25,75],
7
+ "ft:gpt-3.5-turbo": [3,null,6],
8
+ "ft:gpt-3.5-turbo-0125": [3,null,6],
9
+ "ft:gpt-3.5-turbo-0613": [3,null,6],
10
+ "ft:gpt-3.5-turbo-1106": [3,null,6],
11
+ "ft:gpt-4-0613": [30,null,60],
12
+ "ft:gpt-4.1-2025-04-14": [3,0.75,12],
13
+ "ft:gpt-4.1-mini-2025-04-14": [0.8,0.2,3.2],
14
+ "ft:gpt-4.1-nano-2025-04-14": [0.2,0.05,0.8],
15
+ "ft:gpt-4o-2024-08-06": [3.75,1.875,15],
16
+ "ft:gpt-4o-2024-11-20": [3.75,null,15],
17
+ "ft:gpt-4o-mini-2024-07-18": [0.3,0.15,1.2],
18
+ "ft:o4-mini-2025-04-16": [4,1,16],
19
+ "gpt-3.5-turbo": [0.5,null,1.5],
20
+ "gpt-3.5-turbo-0125": [0.5,null,1.5],
21
+ "gpt-3.5-turbo-1106": [1,null,2],
22
+ "gpt-3.5-turbo-16k": [3,null,4],
23
+ "gpt-4": [30,null,60],
24
+ "gpt-4-0613": [30,null,60],
25
+ "gpt-4-1106-preview": [10,null,30],
26
+ "gpt-4-turbo": [10,null,30],
27
+ "gpt-4-turbo-2024-04-09": [10,null,30],
28
+ "gpt-4.1": [2,0.5,8],
29
+ "gpt-4.1-2025-04-14": [2,0.5,8],
30
+ "gpt-4.1-mini": [0.4,0.1,1.6],
31
+ "gpt-4.1-mini-2025-04-14": [0.4,0.1,1.6],
32
+ "gpt-4.1-nano": [0.1,0.025,0.4],
33
+ "gpt-4.1-nano-2025-04-14": [0.1,0.025,0.4],
34
+ "gpt-4o": [2.5,1.25,10],
35
+ "gpt-4o-2024-05-13": [5,null,15],
36
+ "gpt-4o-2024-08-06": [2.5,1.25,10],
37
+ "gpt-4o-2024-11-20": [2.5,1.25,10],
38
+ "gpt-4o-mini": [0.15,0.075,0.6],
39
+ "gpt-4o-mini-2024-07-18": [0.15,0.075,0.6],
40
+ "gpt-5": [1.25,0.125,10],
41
+ "gpt-5-2025-08-07": [1.25,0.125,10],
42
+ "gpt-5-chat": [1.25,0.125,10],
43
+ "gpt-5-chat-latest": [1.25,0.125,10],
44
+ "gpt-5-codex": [1.25,0.125,10],
45
+ "gpt-5-mini": [0.25,0.025,2],
46
+ "gpt-5-mini-2025-08-07": [0.25,0.025,2],
47
+ "gpt-5-nano": [0.05,0.005,0.4],
48
+ "gpt-5-nano-2025-08-07": [0.05,0.005,0.4],
49
+ "gpt-5-pro": [15,null,120],
50
+ "gpt-5-pro-2025-10-06": [15,null,120],
51
+ "gpt-5.1": [1.25,0.125,10],
52
+ "gpt-5.1-2025-11-13": [1.25,0.125,10],
53
+ "gpt-5.1-chat-latest": [1.25,0.125,10],
54
+ "gpt-5.1-codex": [1.25,0.125,10],
55
+ "gpt-5.1-codex-max": [1.25,0.125,10],
56
+ "gpt-5.1-codex-mini": [0.25,0.025,2],
57
+ "gpt-5.2": [1.75,0.175,14],
58
+ "gpt-5.2-2025-12-11": [1.75,0.175,14],
59
+ "gpt-5.2-chat-latest": [1.75,0.175,14],
60
+ "gpt-5.2-codex": [1.75,0.175,14],
61
+ "gpt-5.2-pro": [21,null,168],
62
+ "gpt-5.2-pro-2025-12-11": [21,null,168],
63
+ "gpt-5.3-chat-latest": [1.75,0.175,14],
64
+ "gpt-5.3-codex": [1.75,0.175,14],
65
+ "gpt-5.4": [2.5,0.25,15],
66
+ "gpt-5.4-2026-03-05": [2.5,0.25,15],
67
+ "gpt-5.4-mini": [0.75,0.075,4.5],
68
+ "gpt-5.4-mini-2026-03-17": [0.75,0.075,4.5],
69
+ "gpt-5.4-nano": [0.2,0.02,1.25],
70
+ "gpt-5.4-nano-2026-03-17": [0.2,0.02,1.25],
71
+ "gpt-5.4-pro": [30,null,180],
72
+ "gpt-5.4-pro-2026-03-05": [30,null,180],
73
+ "gpt-5.5": [5,0.5,30],
74
+ "gpt-5.5-2026-04-23": [5,0.5,30],
75
+ "gpt-5.5-cyber": [12.5,1.25,75],
76
+ "gpt-5.5-cyber-preview": [12.5,1.25,75],
77
+ "gpt-5.5-pro": [30,null,180],
78
+ "gpt-5.5-pro-2026-04-23": [30,null,180],
79
+ "gpt-5.6": [4,0.4,20],
80
+ "gpt-5.6-cyber": [12.5,1.25,75],
81
+ "gpt-5.6-luna": [0.2,0.02,1.2],
82
+ "gpt-5.6-sol": [4,0.4,20],
83
+ "gpt-5.6-terra": [2,0.2,12],
84
+ "gpt-6-astra": [10,1,50],
85
+ "gpt-6-luna": [0.1,0.01,0.5],
86
+ "gpt-6-sol": [2,0.2,10],
87
+ "gpt-6.1-sol": [2,0.1,10],
88
+ "gpt-daybreak-blue-latest": [4,0.4,20],
89
+ "gpt-daybreak-red-latest": [12.5,1.25,75],
90
+ "gpt-rosalind-discovery": [5,0.5,25],
91
+ "o1": [15,7.5,60],
92
+ "o1-2024-12-17": [15,7.5,60],
93
+ "o1-pro": [150,null,600],
94
+ "o1-pro-2025-03-19": [150,null,600],
95
+ "o3": [2,0.5,8],
96
+ "o3-2025-04-16": [2,0.5,8],
97
+ "o3-mini": [1.1,0.55,4.4],
98
+ "o3-mini-2025-01-31": [1.1,0.55,4.4],
99
+ "o3-pro": [20,null,80],
100
+ "o3-pro-2025-06-10": [20,null,80],
101
+ "o4-mini": [1.1,0.275,4.4],
102
+ "o4-mini-2025-04-16": [1.1,0.275,4.4]
103
+ }